Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ against a mocked Altertable API
([altertable-mock](https://github.com/altertable-ai/altertable-mock), DuckDB)
started with Testcontainers and stopped on exit. Public API tests use their own deterministic consumer fixtures.

Run `bun run dev:file <path>` to preview a single-file data app such as
`examples/starter-data-app/index.tsx` from anywhere on disk, or `bun run dev:dir <path>`
for a local data app directory with `src/server.ts`. Both resolve `@altertable/data-app`
and React from this checkout's `dist`, so the app needs no install. Run `bun run build`
after package changes. File previews replace the app iframe on edits and preserve
URL filters. Directory previews restart the app server when its imports change.
Unlike `bun run dev`, neither uses the mocked API: set `ALTERTABLE_USERNAME` and `ALTERTABLE_PASSWORD` (or the
`ALTERTABLE_LAKEHOUSE_` names) to query `https://api.altertable.ai`, or another lakehouse
with `ALTERTABLE_API_BASE`. A CLI proxy (`ALTERTABLE_DATA_PROXY_URL`) takes precedence.
Each command prints the lakehouse it targets on startup.

Open the **Widgets** tab (`/gallery?view=widgets`) for one ready example of each
composable data display inside `<VisualizationWidget>`. The other tabs cover
composed apps, variants, and request states.
Expand Down
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 12 additions & 26 deletions dev/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createReloadChannel } from '@/src/server/reload-channel';
import starterPage from '@/examples/starter-local-data-app/src/index.html';
import { localLakehouse, serveLocalApp } from '@altertable/data-app/server/bun';
import { operations as starterOperations } from '@/examples/starter-local-data-app/src/operations';
Expand Down Expand Up @@ -56,35 +57,20 @@ async function createBundleLoader(entry: string) {

/** Pages reload when sources outside their own module graph change. */
function createReloadHandler(paths: string[]) {
const sessionId = crypto.randomUUID();
const clients = new Set<ReadableStreamDefaultController<string>>();
const reload = createReloadChannel();
let pending: ReturnType<typeof setTimeout> | undefined;
for (const path of paths)
const watchers = paths.map(path =>
watch(new URL(path, import.meta.url).pathname, { recursive: true }, () => {
clearTimeout(pending);
pending = setTimeout(() => {
for (const client of clients) client.enqueue('data: reload\n\n');
}, 100);
});

return (request: Request) =>
new Response(
new ReadableStream<string>({
start(client) {
clients.add(client);
client.enqueue(`event: ready\ndata: ${sessionId}\n\n`);
request.signal.addEventListener('abort', () =>
clients.delete(client)
);
},
}),
{
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
},
}
);
pending = setTimeout(() => reload.notify(), 100);
})
);
process.once('exit', () => {
clearTimeout(pending);
for (const watcher of watchers) watcher.close();
reload.close();
});
return reload.connect;
}

// Exercise the published single-file deployment without rebundling it.
Expand Down
25 changes: 25 additions & 0 deletions docs/server-bun.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,28 @@ Missing credentials fail the query. Keep these variables out of browser code.
Local serving does not authenticate hosted viewers.
Use [the portable server handler](server.md) with per-request authorization when
hosting an app for other people.

## Preview a single-file app

Import `serveAppFile()` from `@altertable/data-app/server/bun/preview` to preview
an app in the hosted sandbox. The caller supplies the server-only lakehouse.
The runtime owns bundling, imported-file watching, reloads, navigation, and exports.

```ts
import { serveAppFile } from '@altertable/data-app/server/bun/preview';
import { localLakehouse } from '@altertable/data-app/server/bun';

const preview = await serveAppFile({
file: '/absolute/path/index.tsx',
lakehouse: localLakehouse(),
});
console.log(preview.url);
// When the launcher stops:
await preview.stop();
```

The preview resolves `@altertable/data-app` and React from its own installation.
It binds to localhost and accepts SQL only from its own origin,
with a streaming limit of 100,000 UTF-8 bytes per query request. The caller
owns profile validation, dependency installation, and credentials. This entry requires Bun and adds no Bun installation or dependency
to browser consumers. Keep it separate from browser imports.
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
"types": "./dist/types/server/local.d.ts",
"import": "./dist/local.js"
},
"./server/bun/preview": {
"types": "./dist/types/server/preview/index.d.ts",
"import": "./dist/preview.js"
},
"./react": {
"types": "./dist/types/react/index.d.ts",
"import": "./dist/react/index.js"
Expand Down Expand Up @@ -96,6 +100,8 @@
"scripts": {
"build": "bun run scripts/build.ts && tsc -p tsconfig.build.json && bun run scripts/build-declarations.ts",
"dev": "bun run scripts/dev.ts",
"dev:file": "bun run scripts/dev-file.ts",
"dev:dir": "bun run scripts/dev-dir.ts",
"typecheck": "bun install --frozen-lockfile && tsc --noEmit",
"lint": "oxlint --type-aware src tests scripts dev examples/starter-data-app vitest.config.ts",
"format": "oxfmt .",
Expand Down Expand Up @@ -127,6 +133,7 @@
"@types/bun": "1.4.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"get-tsconfig": "4.14.3",
"markdownlint-cli2": "0.23.3",
"oxfmt": "0.70.0",
"oxlint": "1.85.0",
Expand Down
25 changes: 25 additions & 0 deletions scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,34 @@ const worker = await Bun.build({
},
});

const previewHost = await Bun.build({
entrypoints: ['src/server/preview/host.tsx'],
target: 'browser',
format: 'iife',
minify: true,
jsx: { runtime: 'automatic', development: false },
define: {
DATA_APP_BOOTSTRAP: JSON.stringify(bootstrapScript),
'process.env.NODE_ENV': '"production"',
},
});
if (!previewHost.success) throw new Error('Could not build preview host.');
const previewHostScript = (await previewHost.outputs[0]!.text()).replace(
/<\/script/gi,
'<\\/script'
);
const previewHtml = (
await Bun.file('src/server/preview/host.html').text()
).replace(
'<script type="module" src="./host.tsx"></script>',
() => `<script>${previewHostScript}</script>`
);

const results = [browser, worker];
for (const [name, entry] of Object.entries({
server: 'src/server/index.ts',
local: 'src/server/local.ts',
preview: 'src/server/preview/index.ts',
})) {
results.push(
await Bun.build({
Expand All @@ -95,6 +119,7 @@ for (const [name, entry] of Object.entries({
target: 'bun',
format: 'esm',
external,
define: { DATA_APP_PREVIEW_HTML: JSON.stringify(previewHtml) },
naming: `${name}.[ext]`,
sourcemap: 'external',
})
Expand Down
40 changes: 40 additions & 0 deletions scripts/check-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,39 @@ export const api = { defineDataApp, createDataClient, Grid, MetricWidget, define
export { DataAppBridge };
`
);
await writeFile(
join(temporary, 'preview-app.tsx'),
"document.title = 'Packed preview';\n"
);
await writeFile(
join(temporary, 'server.ts'),
`import { defineDataApp } from "@altertable/data-app";
import { createDataHandler } from "@altertable/data-app/server";
import { localLakehouse } from "@altertable/data-app/server/bun";
import { serveAppFile } from "@altertable/data-app/server/bun/preview";
if (typeof serveAppFile !== "function") throw new Error("Preview export is unavailable");
if (typeof createDataHandler !== "function" || typeof localLakehouse !== "function")
throw new Error("Server exports are unavailable");
const app = defineDataApp({ title: "Server app", description: "Declare an app on the server.", scope: { organization: "demo", environment: "test" }, queries: {} });
if (typeof app.defineOperation !== "function") throw new Error("App declaration is unavailable on the server");
const preview = await serveAppFile({
file: new URL('./preview-app.tsx', import.meta.url).pathname,
port: 0,
lakehouse: { async queryAll() { return { columns: [], rows: [] }; } },
});
try {
const page = await fetch(preview.url);
if (!page.ok || !(await page.text()).includes('<script>')) throw new Error('Packed preview host is unavailable');
const bundle = await fetch(new URL('/bundle.js', preview.url));
if (!bundle.ok || !(await bundle.text()).includes('Packed preview')) throw new Error('Packed preview bundle is unavailable');
const query = await fetch(new URL('/api/sql', preview.url), {
method: 'POST',
headers: { origin: preview.url.origin },
body: JSON.stringify({ statement: 'SELECT 1', limit: 1 }),
});
if (!query.ok) throw new Error('Packed preview SQL is unavailable');
await query.json();
} finally { await preview.stop(); }
`
);
await writeFile(
Expand Down Expand Up @@ -337,6 +361,22 @@ export { Grid };
throw new Error('Tree-shaking removed the used Grid component.');
if (/data-altertable-styles|\.altertable-grid/.test(shakenSource))
throw new Error('Tree-shaking retained unused style injection.');
for (const source of [...javascript, hostSource, bootstrapSource]) {
if (
/\bBun\.(?:serve|build|spawn)|node:fs|serveAppFile|DATA_APP_PREVIEW_HTML|data-app-preview-dependencies/.test(
source
)
)
throw new Error('Bun preview runtime leaked into a browser entry.');
}
for (const dependency of ['bun', 'get-tsconfig'])
if (
Object.hasOwn(manifest.dependencies, dependency) ||
Object.hasOwn(manifest.peerDependencies, dependency)
)
throw new Error(
`Browser consumers must not install ${dependency} as a dependency.`
);
if (javascript.some(source => source.includes('ALTERTABLE_DATA_PROXY_TOKEN')))
throw new Error('Server credentials leaked into the browser build.');
await writeFile(
Expand Down
102 changes: 102 additions & 0 deletions scripts/dev-dir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve, dirname } from 'node:path';
import {
getTsconfig,
createPathsMatcher,
type TsConfigJson,
} from 'get-tsconfig';
import manifest from '@/package.json';
import {
previewLakehouseEnvironment,
ensurePackageBuild,
checkoutRoot,
} from '@/scripts/dev-resolve';

type ImportPaths = Record<string, string[]>;

const usage =
'Usage: bun run dev:dir <path>\nThe directory must contain a local data app with src/server.ts. Use `bun run dev:file <path>` for a single-file app.';
const argument = process.argv[2];
if (!argument) {
console.error(usage);
process.exit(1);
}
const directory = resolve(process.env.INIT_CWD ?? process.cwd(), argument);
const server = join(directory, 'src/server.ts');
if (!existsSync(server)) {
console.error(`No local data app server at ${server}.\n${usage}`);
process.exit(1);
}
await ensurePackageBuild();

const appConfig = readAppImportPaths(directory);
const checkoutPaths = checkoutImportPaths();
const previewConfig = {
extends: appConfig.configPath,
compilerOptions: {
paths: { ...appConfig.paths, ...checkoutPaths },
},
} satisfies TsConfigJson;

const configDirectory = mkdtempSync(join(tmpdir(), 'data-app-dev-dir-'));
const tsconfig = join(configDirectory, 'tsconfig.json');
writeFileSync(tsconfig, JSON.stringify(previewConfig));

const { environment, target } = previewLakehouseEnvironment();
console.log(target);
Comment thread
francoischalifour marked this conversation as resolved.
Fixed
const child = Bun.spawn(
[
process.execPath,
`--tsconfig-override=${tsconfig}`,
'--watch',
'--no-clear-screen',
'src/server.ts',
],
{
cwd: directory,
env: environment,
stdout: 'inherit',
stderr: 'inherit',
stdin: 'inherit',
}
);
for (const signal of ['SIGINT', 'SIGTERM'] as const)
process.on(signal, () => child.kill(signal));
const exitCode = await child.exited;
rmSync(configDirectory, { recursive: true, force: true });
process.exit(exitCode);

function readAppImportPaths(directory: string): {
configPath: string | undefined;
paths: ImportPaths;
} {
const appTsconfig = getTsconfig(directory);
const matchPaths = appTsconfig ? createPathsMatcher(appTsconfig) : null;
const patterns = Object.keys(
appTsconfig?.config.compilerOptions?.paths ?? {}
);
// Absolute targets preserve inherited aliases when the override lives elsewhere.
const paths = matchPaths
? Object.fromEntries(
patterns.map(pattern => [pattern, matchPaths(pattern)])
)
: {};
return { configPath: appTsconfig?.path, paths };
}

function checkoutImportPaths(): ImportPaths {
const paths: ImportPaths = {};
for (const [entry, target] of Object.entries(manifest.exports)) {
const subpath = entry === '.' ? '' : entry.slice(1);
const specifier = `@altertable/data-app${subpath}`;
const javascript = 'import' in target ? target.import : target.default;
paths[specifier] = [resolve(checkoutRoot, javascript)];
}
for (const name of ['react', 'react-dom']) {
const entry = Bun.resolveSync(name, checkoutRoot);
paths[name] = [entry];
paths[`${name}/*`] = [join(dirname(entry), '*')];
}
return paths;
}
36 changes: 36 additions & 0 deletions scripts/dev-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { existsSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
import {
previewLakehouseEnvironment,
ensurePackageBuild,
} from '@/scripts/dev-resolve';

const usage =
'Usage: bun run dev:file <path>\nThe file must be a single-file data app such as examples/starter-data-app/index.tsx. Use `bun run dev:dir <path>` for a local app directory.';
const argument = process.argv[2];
if (!argument) {
console.error(usage);
process.exit(1);
}
const appFile = resolve(process.env.INIT_CWD ?? process.cwd(), argument);
if (!existsSync(appFile) || !statSync(appFile).isFile()) {
console.error(`No data app file at ${appFile}.\n${usage}`);
process.exit(1);
}
await ensurePackageBuild();
const [{ serveAppFile }, { localLakehouse }] = await Promise.all([
import('@altertable/data-app/server/bun/preview'),
import('@altertable/data-app/server/bun'),
]);
const { environment, target } = previewLakehouseEnvironment();
const preview = await serveAppFile({
file: appFile,
port: Number(process.env.PORT ?? 25937),
lakehouse: localLakehouse(environment),
});
console.log(`Data app ${appFile} running at ${preview.url}\n${target}`);
Comment thread
francoischalifour marked this conversation as resolved.
Fixed
for (const signal of ['SIGINT', 'SIGTERM'] as const)
process.on(signal, async () => {
await preview.stop();
process.exit(0);
});
Loading
Loading