From a710b969f033637474f2e4edc11df245bd3b4d56 Mon Sep 17 00:00:00 2001 From: Sylvain Utard Date: Tue, 13 Oct 2026 21:23:01 +0200 Subject: [PATCH 1/4] feat(apps): preview single-file data apps with app dev --file `altertable app dev --file index.tsx` previews a single-file data app locally. The CLI reads the literal `defineDataApp` scope, refuses a profile mismatch, installs a pinned `@altertable/data-app` runtime under the config directory, and serves the file through the loopback lakehouse proxy with live reload. The preview server only accepts SQL from its own origin and host. Co-authored-by: Cursor --- CHANGELOG.md | 4 + COMMANDS.md | 9 +- cli-reference.json | 18 +- cli/src/commands/app/dev.ts | 69 +++- cli/src/commands/app/index.test.ts | 23 ++ cli/src/commands/app/index.ts | 1 + cli/src/commands/app/lib/declared-app.test.ts | 87 +++++ cli/src/commands/app/lib/declared-app.ts | 216 ++++++++++ .../commands/app/lib/file-dev-host.html.txt | 33 ++ cli/src/commands/app/lib/file-dev-host.js.txt | 128 ++++++ .../commands/app/lib/file-dev-server.ts.txt | 194 +++++++++ cli/src/commands/app/lib/file-dev.test.ts | 368 ++++++++++++++++++ cli/src/commands/app/lib/file-dev.ts | 81 ++++ cli/src/commands/app/lib/run.ts | 23 +- cli/src/commands/app/lib/scope.ts | 29 +- 15 files changed, 1243 insertions(+), 40 deletions(-) create mode 100644 cli/src/commands/app/lib/declared-app.test.ts create mode 100644 cli/src/commands/app/lib/declared-app.ts create mode 100644 cli/src/commands/app/lib/file-dev-host.html.txt create mode 100644 cli/src/commands/app/lib/file-dev-host.js.txt create mode 100644 cli/src/commands/app/lib/file-dev-server.ts.txt create mode 100644 cli/src/commands/app/lib/file-dev.test.ts create mode 100644 cli/src/commands/app/lib/file-dev.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9155cf9d..b76d9b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Preview a single-file data app locally with `altertable app dev --file index.tsx`. The file's `defineDataApp` scope must match the selected profile, and queries run through the CLI's loopback proxy. + ### Changed - Generate and upgrade local data apps with `@altertable/data-app` 0.68.0, using app declarations and registered SQL parameters. diff --git a/COMMANDS.md b/COMMANDS.md index a7a16330..7ddde615 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -422,7 +422,7 @@ altertable app create|dev|build|lint|upgrade|validate|publish|update **Subcommands** - `create` — Create a local data app project with a live connection check. -- `dev` — Preview a data app locally with lakehouse access. +- `dev` — Preview a local data app directory, or a single-file data app, with lakehouse access. - `build` — Typecheck and build a local data app without Altertable credentials. - `lint` — Lint a local data app project: format, lint rules, types, contracts, build, and client credential boundary. - `upgrade` — Update a local data app's package to the CLI's tested version. @@ -435,6 +435,7 @@ altertable app create|dev|build|lint|upgrade|validate|publish|update ```bash altertable app create my-app altertable app dev +altertable app dev --file index.tsx altertable app lint altertable app build altertable app upgrade @@ -476,7 +477,7 @@ altertable --profile production app create product-pulse #### `altertable app dev` -Preview a data app locally with lakehouse access. +Preview a local data app directory, or a single-file data app, with lakehouse access. **Usage** @@ -489,7 +490,8 @@ altertable app dev [options] | Option | Description | | --- | --- | | `--dir ` | App directory (default: current directory). | -| `--port ` | Local dev server port (1–65535; default: app setting). | +| `--file ` | Path to a single-file React data app (index.tsx) to preview instead of --dir. Queries are declared in the file via defineDataApp. | +| `--port ` | Local dev server port (1–65535; default: app setting, or 25837 for --file). | **Examples** @@ -497,6 +499,7 @@ altertable app dev [options] altertable app dev altertable app dev --port 3022 altertable --profile staging app dev --dir ./my-app +altertable app dev --file ./index.tsx ``` #### `altertable app build` diff --git a/cli-reference.json b/cli-reference.json index ab0bcb64..64a02ee0 100644 --- a/cli-reference.json +++ b/cli-reference.json @@ -813,6 +813,7 @@ "examples": [ "altertable app create my-app", "altertable app dev", + "altertable app dev --file index.tsx", "altertable app lint", "altertable app build", "altertable app upgrade", @@ -874,7 +875,7 @@ { "id": "altertable-app-dev", "command": "altertable app dev", - "description": "Preview a data app locally with lakehouse access.", + "description": "Preview a local data app directory, or a single-file data app, with lakehouse access.", "usage": [ "altertable app dev [options]" ], @@ -891,11 +892,21 @@ "scope": "command", "values": [] }, + { + "name": "file", + "aliases": [], + "type": "string", + "description": "Path to a single-file React data app (index.tsx) to preview instead of --dir. Queries are declared in the file via defineDataApp.", + "required": false, + "repeatable": false, + "scope": "command", + "values": [] + }, { "name": "port", "aliases": [], "type": "string", - "description": "Local dev server port (1–65535; default: app setting).", + "description": "Local dev server port (1–65535; default: app setting, or 25837 for --file).", "required": false, "repeatable": false, "scope": "command", @@ -905,7 +916,8 @@ "examples": [ "altertable app dev", "altertable app dev --port 3022", - "altertable --profile staging app dev --dir ./my-app" + "altertable --profile staging app dev --dir ./my-app", + "altertable app dev --file ./index.tsx" ], "subcommands": [] }, diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index c21d0001..8f6ad450 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -1,6 +1,9 @@ +import { resolve } from "node:path"; +import { fileDevPort, previewAppFile } from "@/commands/app/lib/file-dev.ts"; import { checkAppScope } from "@/commands/app/lib/scope.ts"; import { defineCommand } from "@/lib/command.ts"; import { CliError, EXIT_GENERIC } from "@/lib/errors.ts"; +import type { ExecutionContext } from "@/lib/execution-context.ts"; import { startAppDevProxy } from "@/commands/app/lib/dev-proxy.ts"; import { appDirectory, @@ -12,39 +15,69 @@ import { export const appDevCommand = defineCommand({ metadata: { name: "dev", - description: "Preview a data app locally with lakehouse access.", + description: + "Preview a local data app directory, or a single-file data app, with lakehouse access.", examples: [ "altertable app dev", "altertable app dev --port 3022", "altertable --profile staging app dev --dir ./my-app", + "altertable app dev --file ./index.tsx", ], }, args: { dir: { type: "string", description: "App directory (default: current directory)." }, - port: { type: "string", description: "Local dev server port (1–65535; default: app setting)." }, + file: { + type: "string", + description: + "Path to a single-file React data app (index.tsx) to preview instead of --dir. Queries are declared in the file via defineDataApp.", + completion: "file", + }, + port: { + type: "string", + description: `Local dev server port (1–65535; default: app setting, or ${fileDevPort} for --file).`, + }, }, async run({ args, execution, runtime }) { assertAppOutputMode(runtime.context.json, runtime.context.agent); const port = appPort(args.port); - const directory = appDirectory(args.dir); - requireAppScripts(directory, ["dev"]); - await checkAppScope(directory, execution.profile); - const installExitCode = await runAppCommand("install", directory); - if (installExitCode !== 0) return { exitCode: EXIT_GENERIC }; - const proxy = startAppDevProxy(execution); - try { - const environment = { - ...proxy.environment, - ...(port ? { PORT: port } : {}), - }; - const exitCode = await runAppCommand("dev", directory, environment); - return { exitCode: exitCode === 0 ? 0 : EXIT_GENERIC }; - } finally { - await proxy.stop(); - } + const exitCode = + args.file === undefined + ? await previewAppDirectory(appDirectory(args.dir), port, execution) + : await previewAppFile(appFile(args.file, args.dir), port ?? fileDevPort, execution); + return { exitCode }; }, }); +async function previewAppDirectory( + directory: string, + port: string | undefined, + execution: ExecutionContext, +): Promise { + requireAppScripts(directory, ["dev"]); + await checkAppScope(directory, execution.profile); + const installExitCode = await runAppCommand("install", directory); + if (installExitCode !== 0) return EXIT_GENERIC; + const proxy = startAppDevProxy(execution); + try { + const environment = { + ...proxy.environment, + ...(port ? { PORT: port } : {}), + }; + const exitCode = await runAppCommand("dev", directory, environment); + return exitCode === 0 ? 0 : EXIT_GENERIC; + } finally { + await proxy.stop(); + } +} + +function appFile(file: unknown, dir: unknown): string { + if (dir !== undefined) throw new CliError("Pass either --dir or --file."); + if (typeof file !== "string" || file.length === 0) { + throw new CliError("--file requires a file path."); + } + return resolve(file); +} + function appPort(value: unknown): string | undefined { if (value === undefined) return undefined; if (typeof value !== "string" || !/^[1-9]\d{0,4}$/.test(value) || Number(value) > 65535) { diff --git a/cli/src/commands/app/index.test.ts b/cli/src/commands/app/index.test.ts index 9b242417..e719bc28 100644 --- a/cli/src/commands/app/index.test.ts +++ b/cli/src/commands/app/index.test.ts @@ -179,4 +179,27 @@ describe("app commands", () => { expect(build.exitCode).toBe(1); expect(existsSync(join(directory, "build.json"))).toBe(false); }); + + test("dev validates a single file before preparing its preview", async () => { + const file = workspace.writeFile( + "index.tsx", + `defineDataApp({ scope: { organization: "acme", environment: "staging" } });`, + ); + async function failure(args: string[]): Promise { + return runAppCommand(["app", "dev", ...args]).catch((error: unknown) => error); + } + function message(text: string) { + return { message: expect.stringContaining(text) }; + } + + expect(await failure(["--file", file])).toMatchObject(message("data app file scope")); + expect(await failure(["--dir", directory, "--file", file])).toMatchObject( + message("either --dir or --file"), + ); + expect(await failure(["--file", ""])).toMatchObject(message("--file requires a file path")); + expect(await failure(["--file", join(directory, "missing.tsx")])).toMatchObject( + message("Could not read source"), + ); + expect(await failure(["--file", directory])).toMatchObject(message("Could not read source")); + }); }); diff --git a/cli/src/commands/app/index.ts b/cli/src/commands/app/index.ts index e68e29d5..7f43ecd2 100644 --- a/cli/src/commands/app/index.ts +++ b/cli/src/commands/app/index.ts @@ -17,6 +17,7 @@ export const appCommand = defineCommand({ examples: [ "altertable app create my-app", "altertable app dev", + "altertable app dev --file index.tsx", "altertable app lint", "altertable app build", "altertable app upgrade", diff --git a/cli/src/commands/app/lib/declared-app.test.ts b/cli/src/commands/app/lib/declared-app.test.ts new file mode 100644 index 00000000..1779aabd --- /dev/null +++ b/cli/src/commands/app/lib/declared-app.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { readDeclaredAppScope } from "@/commands/app/lib/declared-app.ts"; + +const hostedApp = ` +import { defineDataApp } from "@altertable/data-app"; +const dataApp = defineDataApp({ + title: "Sample counts", + description: "Explore sample counts by group.", + scope: { organization: "demo", environment: "sample" }, + queries: { + sampleCountsByGroup: { + statement: \` +SELECT '{' AS brace, \${groupName} AS group_name +WHERE $groupName = '' OR group_name = $groupName\`, + params: { groupName: { defaultValue: "" } }, + }, + }, +}); +`; + +const acmeScope = `defineDataApp({ scope: { organization: "acme", environment: "production" } });`; + +test("reads a literal scope from a single-file data app", () => { + expect(readDeclaredAppScope(hostedApp)).toEqual({ + organization: "demo", + environment: "sample", + }); + expect( + readDeclaredAppScope(` + const sample = "defineDataApp({ scope: { organization: \\"nope\\", environment: \\"nope\\" } })"; + // defineDataApp({ scope: { organization: "nope", environment: "nope" } }) + export const app = defineDataApp({ + queries: { counts: { statement: "select 1", params: {} } }, + scope: { + organization: "acme", + environment: "production", + }, + }); + `), + ).toEqual({ organization: "acme", environment: "production" }); + expect( + readDeclaredAppScope( + "defineDataApp({ scope: { organization : 'O\\'Brien', environment: \"prod\\\"east\" } })", + ), + ).toEqual({ organization: "O'Brien", environment: 'prod"east' }); +}); + +test("reads the scope past JSX text, regular expressions, and escapes", () => { + expect( + readDeclaredAppScope( + `function Hint(): JSX.Element { return

Don't panic

; }\n${acmeScope}`, + ), + ).toEqual({ organization: "acme", environment: "production" }); + expect(readDeclaredAppScope(`const quote = /"[}']/g;\n${acmeScope}`)).toEqual({ + organization: "acme", + environment: "production", + }); + expect( + readDeclaredAppScope( + `defineDataApp({ scope: { organization: "\\u0041cme", environment: "\\x70rod" } })`, + ), + ).toEqual({ organization: "Acme", environment: "prod" }); + expect( + readDeclaredAppScope( + `const total = 4 / 2; defineDataApp({ title: \`\${total} / \${"}"}\`, scope: { organization: "acme", environment: "production" } as const })`, + ), + ).toEqual({ organization: "acme", environment: "production" }); +}); + +test("requires a literal defineDataApp scope", () => { + expect(() => readDeclaredAppScope("export const app = 1;")).toThrow("defineDataApp"); + expect(() => readDeclaredAppScope("defineDataApp(config)")).toThrow("object literal"); + expect(() => readDeclaredAppScope("defineDataApp({ title: 'App', queries: {} })")).toThrow( + "scope.organization", + ); + expect(() => + readDeclaredAppScope( + "const scope = { organization: 'acme', environment: 'production' }; defineDataApp({ scope })", + ), + ).toThrow("scope.organization"); + expect(() => + readDeclaredAppScope( + "defineDataApp({ scope: { organization: 'acme' + suffix, environment: 'production' } })", + ), + ).toThrow("scope.organization"); + expect(() => readDeclaredAppScope("defineDataApp({ scope: {")).toThrow("could not be parsed"); +}); diff --git a/cli/src/commands/app/lib/declared-app.ts b/cli/src/commands/app/lib/declared-app.ts new file mode 100644 index 00000000..6dcf3b99 --- /dev/null +++ b/cli/src/commands/app/lib/declared-app.ts @@ -0,0 +1,216 @@ +import { ConfigurationError } from "@/lib/errors.ts"; + +export type DeclaredAppScope = { organization: string; environment: string }; + +type Token = { kind: "identifier" | "string" | "punctuator" | "operand"; value: string }; + +/** Scope is a literal so preview can refuse a profile mismatch before querying. */ +export function readDeclaredAppScope(source: string): DeclaredAppScope { + const tokens = tokenize(transpile(source)).tokens; + const scope = objectProperties(tokens, dataAppConfig(tokens)).get("scope"); + const fields = + scope !== undefined && tokens[scope]!.value === "{" + ? objectProperties(tokens, scope) + : new Map(); + const organization = literalString(tokens, fields.get("organization")); + const environment = literalString(tokens, fields.get("environment")); + if (!organization?.trim() || !environment?.trim()) { + throw new ConfigurationError( + "The data app file must declare scope.organization and scope.environment as string literals.", + ); + } + return { organization, environment }; +} + +function literalString(tokens: Token[], index: number | undefined): string | undefined { + if (index === undefined) return undefined; + const token = tokens[index]!; + const after = tokens[index + 1]?.value; + return token.kind === "string" && (after === "," || after === "}") ? token.value : undefined; +} + +/** Bun removes TypeScript and JSX, so the scanner only needs JavaScript's lexical grammar. */ +function transpile(source: string): string { + try { + return new Bun.Transpiler({ loader: "tsx" }).transformSync(source); + } catch (error) { + throw new ConfigurationError("The data app file could not be parsed.", { cause: error }); + } +} + +function dataAppConfig(tokens: Token[]): number { + for (let index = 0; index < tokens.length; index++) { + if (tokens[index]!.value !== "defineDataApp" || tokens[index + 1]?.value !== "(") continue; + if (tokens[index + 2]?.value !== "{") { + throw new ConfigurationError("defineDataApp must be called with an object literal."); + } + return index + 2; + } + throw new ConfigurationError( + "Could not find defineDataApp(...) in the data app file. Declare the app with defineDataApp({ scope: { organization, environment } }).", + ); +} + +/** Maps each literal key of the object opening at `open` to the index of its value's first token. */ +function objectProperties(tokens: Token[], open: number): Map { + const properties = new Map(); + let index = open + 1; + while (index < tokens.length && tokens[index]!.value !== "}") { + const key = tokens[index]!; + if ((key.kind === "identifier" || key.kind === "string") && tokens[index + 1]?.value === ":") { + index += 2; + properties.set(key.value, index); + } + let depth = 0; + for (; index < tokens.length; index++) { + const value = tokens[index]!.value; + if (depth === 0 && (value === "," || value === "}")) break; + if (value === "{" || value === "(" || value === "[") depth += 1; + else if (value === "}" || value === ")" || value === "]") depth -= 1; + } + if (tokens[index]?.value === ",") index += 1; + } + return properties; +} + +const regexKeywords = new Set([ + "await", + "case", + "delete", + "do", + "else", + "in", + "instanceof", + "new", + "of", + "return", + "throw", + "typeof", + "void", + "yield", +]); + +/** Templates and regexes are opaque operands. With `nested`, stops after the `}` closing `${`. */ +function tokenize(source: string, start = 0, nested = false): { tokens: Token[]; end: number } { + const tokens: Token[] = []; + let depth = 0; + let index = start; + while (index < source.length) { + const char = source[index]!; + const next = source[index + 1]; + if (/\s/.test(char)) { + index += 1; + } else if (char === "/" && next === "/") { + const newline = source.indexOf("\n", index); + index = newline < 0 ? source.length : newline + 1; + } else if (char === "/" && next === "*") { + const end = source.indexOf("*/", index + 2); + if (end < 0) throw new ConfigurationError("The data app file has an unterminated comment."); + index = end + 2; + } else if (char === "'" || char === '"') { + const text = readString(source, index); + tokens.push({ kind: "string", value: text.value }); + index = text.end; + } else if (char === "`") { + index = skipTemplate(source, index); + tokens.push({ kind: "operand", value: "`" }); + } else if (char === "/" && startsRegex(tokens.at(-1))) { + index = skipRegex(source, index); + tokens.push({ kind: "operand", value: "/" }); + } else if (/[A-Za-z_$]/.test(char)) { + const end = scan(source, index, /[\w$]/); + tokens.push({ kind: "identifier", value: source.slice(index, end) }); + index = end; + } else if (/\d/.test(char)) { + const end = scan(source, index, /[\w.]/); + tokens.push({ kind: "operand", value: source.slice(index, end) }); + index = end; + } else { + if (char === "}" && nested && depth === 0) return { tokens, end: index + 1 }; + if (char === "{") depth += 1; + else if (char === "}") depth -= 1; + tokens.push({ kind: "punctuator", value: char }); + index += 1; + } + } + if (nested) throw new ConfigurationError("The data app file has an unterminated template."); + return { tokens, end: index }; +} + +function scan(source: string, index: number, pattern: RegExp): number { + let end = index + 1; + while (end < source.length && pattern.test(source[end]!)) end += 1; + return end; +} + +function startsRegex(previous: Token | undefined): boolean { + if (previous === undefined) return true; + if (previous.kind === "identifier") return regexKeywords.has(previous.value); + return previous.kind === "punctuator" && !")]}".includes(previous.value); +} + +function readString(source: string, start: number): { value: string; end: number } { + const quote = source[start]!; + let value = ""; + for (let index = start + 1; index < source.length; index++) { + const char = source[index]!; + if (char === quote) return { value, end: index + 1 }; + if (char === "\n") break; + if (char !== "\\") { + value += char; + continue; + } + const escape = readEscape(source, index + 1); + value += escape.value; + index = escape.end - 1; + } + throw new ConfigurationError("The data app file has an unterminated string."); +} + +const simpleEscapes: Record = { + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t", + v: "\v", + "0": "\0", + "\n": "", +}; + +function readEscape(source: string, index: number): { value: string; end: number } { + const code = /^(?:x([\da-f]{2})|u([\da-f]{4})|u\{([\da-f]{1,6})\})/i.exec( + source.slice(index, index + 10), + ); + if (code) { + const hex = code[1] ?? code[2] ?? code[3]!; + return { value: String.fromCodePoint(Number.parseInt(hex, 16)), end: index + code[0].length }; + } + const char = source[index] ?? ""; + return { value: simpleEscapes[char] ?? char, end: index + 1 }; +} + +function skipTemplate(source: string, start: number): number { + for (let index = start + 1; index < source.length; index++) { + const char = source[index]!; + if (char === "\\") index += 1; + else if (char === "`") return index + 1; + else if (char === "$" && source[index + 1] === "{") { + index = tokenize(source, index + 2, true).end - 1; + } + } + throw new ConfigurationError("The data app file has an unterminated template."); +} + +function skipRegex(source: string, start: number): number { + let inClass = false; + for (let index = start + 1; index < source.length; index++) { + const char = source[index]!; + if (char === "\\") index += 1; + else if (char === "\n") break; + else if (char === "[") inClass = true; + else if (char === "]") inClass = false; + else if (char === "/" && !inClass) return scan(source, index, /[a-z]/i); + } + throw new ConfigurationError("The data app file has an unterminated regular expression."); +} diff --git a/cli/src/commands/app/lib/file-dev-host.html.txt b/cli/src/commands/app/lib/file-dev-host.html.txt new file mode 100644 index 00000000..3fb9aaca --- /dev/null +++ b/cli/src/commands/app/lib/file-dev-host.html.txt @@ -0,0 +1,33 @@ + + + + + + Altertable data app + + + + + + + + diff --git a/cli/src/commands/app/lib/file-dev-host.js.txt b/cli/src/commands/app/lib/file-dev-host.js.txt new file mode 100644 index 00000000..ff1efbf5 --- /dev/null +++ b/cli/src/commands/app/lib/file-dev-host.js.txt @@ -0,0 +1,128 @@ +import { + attachDataAppBridge, + createNavigationHandler, + createSqlQueryHandler, +} from "@altertable/data-app/embed"; +import { + createMessageRouter, + defineMessageRoute, + navigationUpdateRoute, + sqlQueryRoute, + DataSourceError, +} from "@altertable/data-app/contract"; + +const status = document.querySelector("#status"); +const iframe = document.querySelector("#app"); +const themeQuery = matchMedia("(prefers-color-scheme: dark)"); + +function presentation() { + return { surface: "standalone", theme: themeQuery.matches ? "dark" : "light" }; +} + +const fileRoute = defineMessageRoute({ + input(value) { + if ( + !value || + typeof value !== "object" || + typeof value.filename !== "string" || + !(value.blob instanceof Blob) + ) { + throw new Error("Invalid export."); + } + return { filename: value.filename, blob: value.blob }; + }, + output(value) { + if (value !== null) throw new Error("Invalid export response."); + return null; + }, +}); + +function download(file) { + const url = URL.createObjectURL(file.blob); + const link = document.createElement("a"); + link.href = url; + link.download = file.filename; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + return null; +} + +const router = createMessageRouter( + { + "data:sql": sqlQueryRoute, + "navigation:update": navigationUpdateRoute, + "export:csv": fileRoute, + "export:zip": fileRoute, + }, + { + "navigation:update": createNavigationHandler(), + "data:sql": createSqlQueryHandler(async () => ({ + async queryAll(statement, { limit, signal, params }) { + const response = await fetch("/api/sql", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + statement, + limit, + ...(params === undefined ? {} : { params }), + }), + signal, + }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new DataSourceError(body.reason ?? "unavailable"); + } + return response.json(); + }, + })), + "export:csv": download, + "export:zip": download, + }, +); + +let host; +let generation = 0; + +function showStatus(message) { + status.hidden = false; + status.textContent = message; +} + +async function mount() { + const current = ++generation; + host?.dispose(); + host = undefined; + iframe.hidden = true; + showStatus("Loading data app..."); + const response = await fetch("/bundle.js", { cache: "no-store" }); + if (current !== generation) return; + if (!response.ok) { + showStatus(await response.text()); + return; + } + host = attachDataAppBridge({ + iframe, + source: { type: "bundle", javascript: await response.text() }, + presentation: presentation(), + onMessage: router.dispatch, + onStatusChange(value) { + if (current !== generation) return; + const ready = value === "ready"; + iframe.hidden = !ready; + if (value === "failed") showStatus("Could not load the data app."); + if (ready) status.hidden = true; + }, + }); +} + +themeQuery.addEventListener("change", () => { + host?.setPresentation(presentation()); +}); +await mount(); + +const reload = new EventSource("/__dev/reload"); +reload.addEventListener("message", () => { + void mount(); +}); diff --git a/cli/src/commands/app/lib/file-dev-server.ts.txt b/cli/src/commands/app/lib/file-dev-server.ts.txt new file mode 100644 index 00000000..b0f868fb --- /dev/null +++ b/cli/src/commands/app/lib/file-dev-server.ts.txt @@ -0,0 +1,194 @@ +import { watch } from "node:fs"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import hostPage from "./host.html"; +import { DataSourceError, sqlQueryRoute } from "@altertable/data-app/contract"; +import { localLakehouse } from "@altertable/data-app/server/bun"; + +const appFile = process.env.ALTERTABLE_APP_FILE; +if (typeof appFile !== "string" || appFile.length === 0) { + throw new Error("Missing data app file."); +} +const port = Number(process.env.PORT); +if (!process.env.PORT || !Number.isInteger(port)) throw new Error("Missing preview port."); + +const lakehouse = localLakehouse(); +const clients = new Set(); +let reloadTimer; + +function scheduleReload() { + clearTimeout(reloadTimer); + reloadTimer = setTimeout(() => { + console.log("Reloading data app."); + for (const client of clients) { + try { + client.enqueue("data: reload\n\n"); + } catch { + clients.delete(client); + } + } + }, 50); +} + +try { + watch(dirname(appFile), (_, filename) => { + const name = filename == null ? undefined : filename.toString(); + if (name === undefined || name === basename(appFile)) scheduleReload(); + }); +} catch (error) { + console.error( + `Watching ${appFile} failed: ${error instanceof Error ? error.message : "unknown error"}.`, + ); +} + +function runtimePackages() { + const from = join(import.meta.dir, "package.json"); + const installed = `${join(import.meta.dir, "node_modules")}/`.replaceAll("\\", "/"); + return { + name: "altertable-file-dev-packages", + setup(build) { + // The app file lives outside this runtime. Resolve its package imports here, + // then let Bun resolve dependencies from the installed package tree. + build.onResolve({ filter: /^[^./]/ }, (args) => { + if (args.namespace !== "file" || args.importer.replaceAll("\\", "/").startsWith(installed)) + return; + if (args.path.startsWith("node:") || args.path.startsWith("bun:")) return; + try { + const resolved = Bun.resolveSync(args.path, from); + return isAbsolute(resolved) ? { path: resolved } : undefined; + } catch { + return undefined; + } + }); + }, + }; +} + +async function bundleApp() { + const result = await Bun.build({ + entrypoints: [appFile], + target: "browser", + format: "iife", + // Prefer browser entry points. Bun's default conditions include "node" and + // would pull Node builds such as fflate's createRequire entry. + conditions: ["browser", "import", "module", "default"], + define: { "import.meta.env": "{}" }, + plugins: [runtimePackages()], + }); + if (!result.success) { + const details = result.logs + .map((log) => { + const notes = (log.notes ?? []).map((note) => note.text).filter(Boolean); + return [log.message, ...notes].filter(Boolean).join("\n"); + }) + .filter(Boolean) + .join("\n"); + throw new Error(details || "Bundling the data app failed."); + } + const scripts = result.outputs.filter((output) => output.path.endsWith(".js")); + if (scripts.length !== 1) throw new Error("The data app must bundle to one script."); + const javascript = await scripts[0].text(); + const stylesheet = result.outputs.find((output) => output.path.endsWith(".css")); + if (!stylesheet) return javascript; + const css = await stylesheet.text(); + return `const style = document.createElement("style"); style.textContent = ${JSON.stringify(css)}; document.head.append(style);\n${javascript}`; +} + +function readQuery(body) { + try { + return sqlQueryRoute.input(JSON.parse(body)); + } catch { + return undefined; + } +} + +// Queries run with the profile's credentials. The Host check defeats DNS rebinding; the Origin +// check rejects cross-site POSTs, which browsers send without a CORS preflight. +function localHosts(server) { + return [`127.0.0.1:${server.port}`, `localhost:${server.port}`]; +} + +function isLocalHost(request, server) { + return localHosts(server).includes(request.headers.get("host")); +} + +function isSameOrigin(request, server) { + const site = request.headers.get("sec-fetch-site"); + const origin = request.headers.get("origin"); + return ( + (site === null || site === "same-origin") && + localHosts(server).some((host) => origin === `http://${host}`) + ); +} + +function forbidden() { + return new Response("Forbidden", { status: 403 }); +} + +const server = Bun.serve({ + hostname: "127.0.0.1", + port, + idleTimeout: 60, + routes: { "/": hostPage }, + async fetch(request, server) { + if (!isLocalHost(request, server)) return forbidden(); + const url = new URL(request.url); + if (url.pathname === "/bundle.js") { + try { + return new Response(await bundleApp(), { + headers: { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "no-store", + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Bundling the data app failed."; + console.error(message); + return new Response(message, { + status: 500, + headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }, + }); + } + } + if (url.pathname === "/__dev/reload") { + server.timeout(request, 0); + return new Response( + new ReadableStream({ + start(controller) { + clients.add(controller); + controller.enqueue("event: ready\ndata: ok\n\n"); + request.signal.addEventListener("abort", () => clients.delete(controller)); + }, + }), + { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } }, + ); + } + if (url.pathname === "/api/sql" && request.method === "POST") { + if (!isSameOrigin(request, server)) return forbidden(); + const body = await request.text(); + if (body.length > 100_000) return new Response("Query too large", { status: 413 }); + const query = readQuery(body); + if (!query) return new Response("Invalid query", { status: 400 }); + try { + const result = await lakehouse.queryAll(query.statement, { + limit: query.limit, + signal: request.signal, + ...(query.params === undefined ? {} : { params: query.params }), + }); + return Response.json({ + columns: result.columns, + rows: result.rows, + ...(result.queryId === undefined ? {} : { queryId: result.queryId }), + }); + } catch (error) { + const reason = error instanceof DataSourceError ? error.reason : "unavailable"; + console.error( + `Data app query failed (${reason}): ${error instanceof Error ? error.message : reason}.`, + ); + return Response.json({ reason }, { status: 502 }); + } + } + return new Response("Not found", { status: 404 }); + }, +}); + +console.log(`Data app running at ${server.url} (${appFile})`); diff --git a/cli/src/commands/app/lib/file-dev.test.ts b/cli/src/commands/app/lib/file-dev.test.ts new file mode 100644 index 00000000..5869b579 --- /dev/null +++ b/cli/src/commands/app/lib/file-dev.test.ts @@ -0,0 +1,368 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + fileDevRuntimeDirectory, + prepareFileDevRuntime, + previewAppFile, + writeFileDevRuntime, +} from "@/commands/app/lib/file-dev.ts"; +import { recommendedDataAppVersion } from "@/commands/app/lib/package.ts"; +import { EXIT_GENERIC } from "@/lib/errors.ts"; +import type { ExecutionContext } from "@/lib/execution-context.ts"; +import { FROM_ENV_PSEUDOPROFILE_NAME } from "@/lib/profile-store.ts"; + +type ProcessCall = { args: readonly string[]; directory: string; env?: Record }; + +const directories: string[] = []; +const processes: Bun.Subprocess[] = []; +const savedEnvironment: Record = {}; + +beforeEach(() => { + for (const key of ["ALTERTABLE_CONFIG_HOME", "ALTERTABLE_ENV"]) { + savedEnvironment[key] = process.env[key]; + } +}); + +afterEach(async () => { + for (const [key, value] of Object.entries(savedEnvironment)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + for (const child of processes.splice(0)) { + child.kill(); + await child.exited; + } + await Promise.all( + directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function expectFailure(run: () => Promise, message: string): Promise { + let failure: unknown; + try { + await run(); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ message: expect.stringContaining(message) }); +} + +async function tempDir(): Promise { + const directory = await mkdtemp(join(tmpdir(), "file-dev-")); + directories.push(directory); + return directory; +} + +function recordProcesses(exitCodes: number[] = []) { + const calls: ProcessCall[] = []; + async function run( + args: readonly string[], + directory: string, + env?: Record, + ): Promise { + calls.push({ args, directory, env }); + return exitCodes[calls.length - 1] ?? 0; + } + return { calls, run }; +} + +test("writes and installs the pinned preview runtime", async () => { + const directory = await tempDir(); + const { calls, run } = recordProcesses(); + + await prepareFileDevRuntime(directory, run); + expect(calls).toEqual([ + { args: ["install", "--frozen-lockfile", "--production"], directory, env: undefined }, + ]); + const manifest = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); + expect(manifest.dependencies["@altertable/data-app"]).toBe(recommendedDataAppVersion()); + expect(manifest.name).toBe("altertable-file-dev"); + expect(await readFile(join(directory, "server.ts"), "utf8")).toContain("localLakehouse"); + + await expectFailure( + () => prepareFileDevRuntime(directory, recordProcesses([1]).run), + "Could not install", + ); +}); + +test("previews a file through the loopback proxy without profile credentials", async () => { + const home = await tempDir(); + process.env.ALTERTABLE_CONFIG_HOME = home; + process.env.ALTERTABLE_ENV = "production"; + const file = join(home, "index.tsx"); + await writeFile(file, scopedApp("production")); + const execution = { profile: FROM_ENV_PSEUDOPROFILE_NAME } as ExecutionContext; + const { calls, run } = recordProcesses([0, 3]); + + expect(await previewAppFile(file, "3022", execution, run)).toBe(EXIT_GENERIC); + const runtime = fileDevRuntimeDirectory(); + expect(calls.map((call) => [call.args, call.directory])).toEqual([ + [["install", "--frozen-lockfile", "--production"], runtime], + [["server.ts"], runtime], + ]); + const env = calls[1]!.env!; + expect(Object.keys(env).sort()).toEqual([ + "ALTERTABLE_APP_FILE", + "ALTERTABLE_DATA_PROXY_TOKEN", + "ALTERTABLE_DATA_PROXY_URL", + "PORT", + ]); + expect(env.ALTERTABLE_APP_FILE).toBe(file); + expect(env.PORT).toBe("3022"); + expect(env.ALTERTABLE_DATA_PROXY_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(env.ALTERTABLE_DATA_PROXY_TOKEN).toMatch(/^[a-f0-9]{64}$/); +}); + +test("refuses a file whose scope does not match the profile before installing", async () => { + const home = await tempDir(); + process.env.ALTERTABLE_CONFIG_HOME = home; + process.env.ALTERTABLE_ENV = "production"; + const file = join(home, "index.tsx"); + await writeFile(file, scopedApp("staging")); + const { calls, run } = recordProcesses(); + + await expectFailure( + () => + previewAppFile( + file, + "3022", + { profile: FROM_ENV_PSEUDOPROFILE_NAME } as ExecutionContext, + run, + ), + "data app file scope", + ); + await expectFailure( + () => + previewAppFile( + home, + "3022", + { profile: FROM_ENV_PSEUDOPROFILE_NAME } as ExecutionContext, + run, + ), + "Could not read source", + ); + expect(calls).toEqual([]); +}); + +test("serves a single file and forwards same-origin SQL from the preview server", async () => { + const root = await tempDir(); + const runtime = join(root, "runtime"); + const appFile = join(root, "index.tsx"); + await writeFileDevRuntime(runtime); + await writePackageStubs(runtime); + await writeFile(appFile, appSource("Preview")); + + const stderr: string[] = []; + const child = Bun.spawn([process.execPath, "server.ts"], { + cwd: runtime, + env: { + ...process.env, + ALTERTABLE_APP_FILE: appFile, + ALTERTABLE_DATA_PROXY_TOKEN: "secret-token-value", + PORT: "0", + }, + stdout: "pipe", + stderr: "pipe", + }); + processes.push(child); + drain(child.stderr, stderr); + const url = await readServerUrl(child, stderr); + const origin = url.slice(0, -1); + function query(body: string, headers: Record = { origin }) { + return fetch(`${url}api/sql`, { method: "POST", headers, body }); + } + + const page = await fetch(url); + expect(page.status).toBe(200); + expect(page.headers.get("content-type")).toContain("text/html"); + + const bundle = await (await fetch(`${url}bundle.js`)).text(); + expect(bundle).toContain("Preview"); + expect(bundle).not.toContain("secret-token-value"); + + const accepted = await query( + JSON.stringify({ statement: "select 1", limit: 1, params: { groupName: "Alpha" } }), + { origin, "content-type": "application/json", "sec-fetch-site": "same-origin" }, + ); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toEqual({ + columns: [{ name: "statement" }, { name: "limit" }], + rows: [["select 1", 1]], + queryId: "Alpha", + }); + + const rejected = await query(JSON.stringify({ statement: "fail", limit: 1 })); + expect(rejected.status).toBe(502); + expect(await rejected.json()).toEqual({ reason: "query_rejected" }); + expect((await query("{")).status).toBe(400); + expect((await query(JSON.stringify({ statement: "select 1", limit: 0 }))).status).toBe(400); + expect((await fetch(`${url}missing`)).status).toBe(404); + + const valid = JSON.stringify({ statement: "select 1", limit: 1 }); + expect((await query(valid, {})).status).toBe(403); + expect((await query(valid, { origin: "https://example.com" })).status).toBe(403); + expect((await query(valid, { origin, "sec-fetch-site": "cross-site" })).status).toBe(403); + const rebound = await fetch(`${url}bundle.js`, { headers: { host: "attacker.example" } }); + expect(rebound.status).toBe(403); + + await writeFile(appFile, appSource("Updated")); + await Bun.sleep(100); + expect(await (await fetch(`${url}bundle.js`)).text()).toContain("Updated"); + + const reload = await fetch(`${url}__dev/reload`); + const reader = reload.body?.getReader(); + expect(reader).toBeDefined(); + await writeFile(appFile, appSource("Reloaded")); + const events = await readUntil(reader!, "reload"); + expect(events).toContain("reload"); + await reader?.cancel(); +}, 15_000); + +function scopedApp(environment: string): string { + return `import { defineDataApp } from "@altertable/data-app"; +const app = defineDataApp({ scope: { organization: "acme", environment: "${environment}" } }); +`; +} + +function appSource(title: string): string { + return `import { defineDataApp } from "@altertable/data-app"; +const app = defineDataApp({ title: ${JSON.stringify(title)} }); +export function View() { + return

{app.title}

; +} +`; +} + +async function writePackageStubs(runtime: string): Promise { + const dataApp = join(runtime, "node_modules/@altertable/data-app"); + const react = join(runtime, "node_modules/react"); + await mkdir(join(dataApp, "server"), { recursive: true }); + await mkdir(react, { recursive: true }); + await writeFile( + join(dataApp, "package.json"), + JSON.stringify({ name: "@altertable/data-app", version: "0.0.0", type: "module" }), + ); + await writeFile( + join(dataApp, "index.js"), + "export function defineDataApp(config) { return config; }\n", + ); + await writeFile( + join(dataApp, "server/bun.js"), + `import { DataSourceError } from "../contract.js"; +export function localLakehouse() { + return { + async queryAll(statement, options) { + if (statement === "fail") throw new DataSourceError("query_rejected"); + return { + columns: [{ name: "statement" }, { name: "limit" }], + rows: [[statement, options.limit]], + queryId: options.params?.groupName ?? "q1", + }; + }, + }; +} +`, + ); + await writeFile( + join(dataApp, "embed.js"), + `export function attachDataAppBridge() { return { dispose() {}, setPresentation() {} }; } +export function createNavigationHandler() { return () => null; } +export function createSqlQueryHandler(authorize) { return authorize; } +`, + ); + await writeFile( + join(dataApp, "contract.js"), + `export function createMessageRouter() { return { dispatch() {} }; } +export function defineMessageRoute(route) { return route; } +export const navigationUpdateRoute = {}; +export const sqlQueryRoute = { + input(value) { + if (typeof value?.statement !== "string" || !(value.limit >= 1)) throw new Error("Invalid SQL query."); + return value; + }, +}; +const brand = Symbol.for("@altertable/data-app/DataSourceError"); +export class DataSourceError extends Error { + [brand] = true; + static [Symbol.hasInstance](value) { + return value instanceof Error && value[brand] === true; + } + constructor(reason) { + super(reason); + this.reason = reason; + } +} +`, + ); + await writeFile( + join(react, "package.json"), + JSON.stringify({ + name: "react", + type: "module", + exports: { + "./jsx-runtime": "./jsx-runtime.js", + "./jsx-dev-runtime": "./jsx-dev-runtime.js", + }, + }), + ); + await writeFile( + join(react, "jsx-runtime.js"), + 'export function jsx(type, props, key) { return { type, props, key }; }\nexport const jsxs = jsx;\nexport const Fragment = "Fragment";\n', + ); + await writeFile( + join(react, "jsx-dev-runtime.js"), + 'export function jsxDEV(type, props, key) { return { type, props, key }; }\nexport const Fragment = "Fragment";\n', + ); +} + +function drain(stream: ReadableStream, chunks: string[]): void { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + void (async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(decoder.decode(value)); + } + })(); +} + +async function readServerUrl(child: Bun.Subprocess, stderr: string[]): Promise { + const stdout = child.stdout; + if (!(stdout instanceof ReadableStream)) throw new Error("Missing preview server output."); + const reader = stdout.getReader(); + const decoder = new TextDecoder(); + let text = ""; + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const next = await Promise.race([ + reader.read(), + child.exited.then(() => ({ done: true as const, value: undefined })), + ]); + if (next.done) break; + text += decoder.decode(next.value); + const match = text.match(/http:\/\/127\.0\.0\.1:\d+\//); + if (match) return match[0]; + } + throw new Error(`Preview server did not start.\n${text}\n${stderr.join("")}`); +} + +async function readUntil( + reader: { read(): Promise<{ done: boolean; value?: Uint8Array }> }, + needle: string, +): Promise { + const decoder = new TextDecoder(); + let text = ""; + const deadline = Date.now() + 2_000; + while (Date.now() < deadline && !text.includes(needle)) { + const next = await Promise.race([ + reader.read(), + Bun.sleep(200).then(() => ({ done: false as const, value: undefined })), + ]); + if (next.done) break; + if (next.value) text += decoder.decode(next.value); + } + return text; +} diff --git a/cli/src/commands/app/lib/file-dev.ts b/cli/src/commands/app/lib/file-dev.ts new file mode 100644 index 00000000..eb472d7f --- /dev/null +++ b/cli/src/commands/app/lib/file-dev.ts @@ -0,0 +1,81 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import serverScript from "@/commands/app/lib/file-dev-server.ts.txt"; +import hostPage from "@/commands/app/lib/file-dev-host.html.txt"; +import hostScript from "@/commands/app/lib/file-dev-host.js.txt"; +import { readDeclaredAppScope } from "@/commands/app/lib/declared-app.ts"; +import { startAppDevProxy } from "@/commands/app/lib/dev-proxy.ts"; +import { createAppFiles } from "@/commands/app/lib/distribution.ts"; +import { recommendedDataAppVersion } from "@/commands/app/lib/package.ts"; +import { dataAppPayload } from "@/commands/app/lib/payload.ts"; +import { readRequiredFile } from "@/commands/app/lib/remote.ts"; +import { runAppProcess } from "@/commands/app/lib/run.ts"; +import { assertAppScope, profileAppScope } from "@/commands/app/lib/scope.ts"; +import { configDir } from "@/lib/config-files.ts"; +import { CliError, EXIT_GENERIC } from "@/lib/errors.ts"; +import type { ExecutionContext } from "@/lib/execution-context.ts"; + +export const fileDevPort = "25837"; + +export function fileDevRuntimeDirectory(): string { + return join(configDir(), "data-app-dev", recommendedDataAppVersion()); +} + +export async function writeFileDevRuntime(directory: string): Promise { + await mkdir(directory, { recursive: true }); + await Promise.all( + Object.entries(runtimeFiles()).map(([name, content]) => + writeFile(join(directory, name), content), + ), + ); +} + +/** The frozen lockfile pins the package version; an installed runtime makes this an offline no-op. */ +export async function prepareFileDevRuntime( + directory: string, + run: typeof runAppProcess = runAppProcess, +): Promise { + await writeFileDevRuntime(directory); + if ((await run(["install", "--frozen-lockfile", "--production"], directory)) !== 0) { + throw new CliError("Could not install the data app package used to preview a single file."); + } +} + +export async function previewAppFile( + file: string, + port: string, + execution: ExecutionContext, + run: typeof runAppProcess = runAppProcess, +): Promise { + const source = await readRequiredFile(file, "source"); + assertAppScope(readDeclaredAppScope(source), profileAppScope(execution.profile), "data app file"); + const directory = fileDevRuntimeDirectory(); + await prepareFileDevRuntime(directory, run); + const proxy = startAppDevProxy(execution); + try { + const exitCode = await run(["server.ts"], directory, { + ...proxy.environment, + ALTERTABLE_APP_FILE: file, + PORT: port, + }); + return exitCode === 0 ? 0 : EXIT_GENERIC; + } finally { + await proxy.stop(); + } +} + +function runtimeFiles(): Record { + const starter = createAppFiles(dataAppPayload, { + name: "altertable-file-dev", + title: "File dev", + scope: { organization: "local", environment: "local" }, + }); + return { + "package.json": starter["package.json"]!, + "bun.lock": starter["bun.lock"]!, + "bunfig.toml": starter["bunfig.toml"]!, + "server.ts": serverScript, + "host.html": hostPage, + "host.js": hostScript, + }; +} diff --git a/cli/src/commands/app/lib/run.ts b/cli/src/commands/app/lib/run.ts index 95993505..5226cfc5 100644 --- a/cli/src/commands/app/lib/run.ts +++ b/cli/src/commands/app/lib/run.ts @@ -47,6 +47,21 @@ export async function runAppCommand( directory: string, appEnvironment?: Record, signal?: AbortSignal, +): Promise { + const arguments_ = + command === "install" + ? ["install"] + : command === "install-frozen" + ? ["install", "--frozen-lockfile"] + : ["run", command]; + return runAppProcess(arguments_, directory, appEnvironment, signal); +} + +export async function runAppProcess( + args: readonly string[], + directory: string, + appEnvironment?: Record, + signal?: AbortSignal, ): Promise { const env = copyProcessEnv(); for (const key of Object.keys(env)) { @@ -57,13 +72,7 @@ export async function runAppCommand( // command and supports `bun` calls inside the project's scripts. env.BUN_BE_BUN = "1"; - const arguments_ = - command === "install" - ? ["install"] - : command === "install-frozen" - ? ["install", "--frozen-lockfile"] - : ["run", command]; - const child = Bun.spawn([process.execPath, ...arguments_], { + const child = Bun.spawn([process.execPath, ...args], { cwd: directory, env, stdin: "inherit", diff --git a/cli/src/commands/app/lib/scope.ts b/cli/src/commands/app/lib/scope.ts index 019d0e81..2e2d7da7 100644 --- a/cli/src/commands/app/lib/scope.ts +++ b/cli/src/commands/app/lib/scope.ts @@ -5,9 +5,16 @@ import { readEnv } from "@/lib/env.ts"; import { isFromEnvProfile } from "@/lib/profile-store.ts"; import { ConfigurationError } from "@/lib/errors.ts"; +export type ProfileAppScope = { + organization?: string; + organizationName?: string; + environment?: string; +}; + export function assertAppScope( scope: unknown, - profile: { organization?: string; organizationName?: string; environment?: string }, + profile: ProfileAppScope, + source = "app.json", ): void { const value = scope as { organization?: unknown; environment?: unknown } | null; if ( @@ -21,11 +28,21 @@ export function assertAppScope( (Boolean(profile.environment) && value.environment !== profile.environment) ) { throw new ConfigurationError( - "app.json scope does not match the selected profile. Select the matching profile or update the app scope before querying.", + `${source} scope does not match the selected profile. Select the matching profile or update the app scope before querying.`, ); } } +export function profileAppScope(profile: string): ProfileAppScope { + return { + organization: configGet("organization_slug", profile).trim(), + organizationName: configGet("organization_name", profile).trim(), + environment: isFromEnvProfile(profile) + ? readEnv("ALTERTABLE_ENV") + : configGet("api_key_env", profile).trim(), + }; +} + export async function checkAppScope(directory: string, profile: string): Promise { let manifest; try { @@ -33,11 +50,5 @@ export async function checkAppScope(directory: string, profile: string): Promise } catch { throw new ConfigurationError("Cannot read a valid app.json for profile scope verification."); } - assertAppScope(manifest?.scope, { - organization: configGet("organization_slug", profile).trim(), - organizationName: configGet("organization_name", profile).trim(), - environment: isFromEnvProfile(profile) - ? readEnv("ALTERTABLE_ENV") - : configGet("api_key_env", profile).trim(), - }); + assertAppScope(manifest?.scope, profileAppScope(profile)); } From 9eeaf0bcfea03006116ae40f32fa52c09228d626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 19 Oct 2026 15:43:54 +0200 Subject: [PATCH 2/4] refactor(app): delegate file previews to published data-app packages Use the 0.72.0 manifest extractor for static scope verification and the Bun preview API for bundling, sandbox hosting, reload, and SQL routes. Remove the custom scope scanner and embedded host implementation while preserving profile isolation through the CLI loopback proxy. Align starter and browser-test packages and lockfiles at 0.72.0. Verify the released runtime through real preview and packaged CLI smoke checks. --- CHANGELOG.md | 2 + cli/bun.lock | 5 + cli/package.json | 1 + cli/scripts/smoke-data-app.ts | 2 +- cli/src/commands/app/index.test.ts | 3 +- cli/src/commands/app/lib/declared-app.test.ts | 109 +++------ cli/src/commands/app/lib/declared-app.ts | 226 ++---------------- cli/src/commands/app/lib/distribution.test.ts | 6 +- .../commands/app/lib/file-dev-host.html.txt | 33 --- cli/src/commands/app/lib/file-dev-host.js.txt | 128 ---------- .../commands/app/lib/file-dev-server.ts.txt | 205 ++-------------- cli/src/commands/app/lib/file-dev.test.ts | 123 +++------- cli/src/commands/app/lib/file-dev.ts | 4 - data-app/starter/bun.lock | 12 +- data-app/starter/package.json | 6 +- data-app/tests/bun.lock | 4 +- data-app/tests/package.json | 2 +- 17 files changed, 123 insertions(+), 748 deletions(-) delete mode 100644 cli/src/commands/app/lib/file-dev-host.html.txt delete mode 100644 cli/src/commands/app/lib/file-dev-host.js.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index eda05ec6..62aabb28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ If credentials were saved inside a project, sign in again after upgrading and re ### Changed +- Use the published 0.72.0 manifest extractor and Bun preview server for single-file data app development; align starter and browser-test packages at 0.72.0. + - Generate data apps with the split core, React, and Bun packages at 0.71.0; keep declared data-app packages aligned during upgrades. - Update generated data apps and fixtures to `@altertable/data-app` 0.69.0. diff --git a/cli/bun.lock b/cli/bun.lock index f8bd11da..7a112f10 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -5,6 +5,7 @@ "": { "name": "cli", "dependencies": { + "@altertable/data-app-manifest": "0.72.0", "@clack/prompts": "1.7.0", "oauth4webapi": "3.8.7", "yaml": "2.9.0", @@ -22,6 +23,8 @@ }, }, "packages": { + "@altertable/data-app-manifest": ["@altertable/data-app-manifest@0.72.0", "", { "dependencies": { "typescript": "5.9.3" } }, "sha512-404JXukgR2zGt52NbgDohuvtlx2kt8v54Yb8EWs2b2RjDKmOwQ2gnQJBQzAIUtsFu1Gup6awv4gc9qj7eazvPA=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], @@ -332,6 +335,8 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@altertable/data-app-manifest/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], } } diff --git a/cli/package.json b/cli/package.json index 934fbd82..6cf5bd06 100644 --- a/cli/package.json +++ b/cli/package.json @@ -48,6 +48,7 @@ "data-app:test:browser": "cd ../data-app/tests && bun run test" }, "dependencies": { + "@altertable/data-app-manifest": "0.72.0", "@clack/prompts": "1.7.0", "oauth4webapi": "3.8.7", "yaml": "2.9.0" diff --git a/cli/scripts/smoke-data-app.ts b/cli/scripts/smoke-data-app.ts index aa67c2ec..80553e24 100644 --- a/cli/scripts/smoke-data-app.ts +++ b/cli/scripts/smoke-data-app.ts @@ -114,7 +114,7 @@ export async function smokeDataApp(command: string[], scaffoldOnly = false): Pro "@altertable/data-app-react", "@altertable/data-app-bun", ]) { - if (manifest.dependencies?.[name] !== "0.71.0") + if (manifest.dependencies?.[name] !== "0.72.0") throw new Error(`Packaged starter must pin ${name} to the tested release`); } for (const name of ["@altertable/data-app-embed", "@altertable/data-app-worker"]) { diff --git a/cli/src/commands/app/index.test.ts b/cli/src/commands/app/index.test.ts index e719bc28..b56ece36 100644 --- a/cli/src/commands/app/index.test.ts +++ b/cli/src/commands/app/index.test.ts @@ -183,7 +183,8 @@ describe("app commands", () => { test("dev validates a single file before preparing its preview", async () => { const file = workspace.writeFile( "index.tsx", - `defineDataApp({ scope: { organization: "acme", environment: "staging" } });`, + `import { defineDataApp } from "@altertable/data-app"; +defineDataApp({ scope: { organization: "acme", environment: "staging" } });`, ); async function failure(args: string[]): Promise { return runAppCommand(["app", "dev", ...args]).catch((error: unknown) => error); diff --git a/cli/src/commands/app/lib/declared-app.test.ts b/cli/src/commands/app/lib/declared-app.test.ts index 1779aabd..92de07a5 100644 --- a/cli/src/commands/app/lib/declared-app.test.ts +++ b/cli/src/commands/app/lib/declared-app.test.ts @@ -1,87 +1,50 @@ import { expect, test } from "bun:test"; import { readDeclaredAppScope } from "@/commands/app/lib/declared-app.ts"; -const hostedApp = ` -import { defineDataApp } from "@altertable/data-app"; -const dataApp = defineDataApp({ - title: "Sample counts", - description: "Explore sample counts by group.", - scope: { organization: "demo", environment: "sample" }, - queries: { - sampleCountsByGroup: { - statement: \` -SELECT '{' AS brace, \${groupName} AS group_name -WHERE $groupName = '' OR group_name = $groupName\`, - params: { groupName: { defaultValue: "" } }, - }, - }, -}); -`; +const factoryImport = 'import { defineDataApp as define } from "@altertable/data-app";'; -const acmeScope = `defineDataApp({ scope: { organization: "acme", environment: "production" } });`; +function app(config: string): string { + return `${factoryImport}\nconst app = define(${config});`; +} -test("reads a literal scope from a single-file data app", () => { - expect(readDeclaredAppScope(hostedApp)).toEqual({ - organization: "demo", - environment: "sample", - }); +test("reads scope through the published extractor, including import aliases and TSX", () => { expect( - readDeclaredAppScope(` - const sample = "defineDataApp({ scope: { organization: \\"nope\\", environment: \\"nope\\" } })"; - // defineDataApp({ scope: { organization: "nope", environment: "nope" } }) - export const app = defineDataApp({ - queries: { counts: { statement: "select 1", params: {} } }, - scope: { - organization: "acme", - environment: "production", - }, - }); - `), + readDeclaredAppScope(`${app(`{ + scope: { organization: "acme", environment: "production" }, + queries: { counts: { statement: \`SELECT '{' AS brace\`, params: {} } }, + } as const`)} + function View() { return

Don't panic

; }`), ).toEqual({ organization: "acme", environment: "production" }); - expect( - readDeclaredAppScope( - "defineDataApp({ scope: { organization : 'O\\'Brien', environment: \"prod\\\"east\" } })", - ), - ).toEqual({ organization: "O'Brien", environment: 'prod"east' }); }); -test("reads the scope past JSX text, regular expressions, and escapes", () => { - expect( - readDeclaredAppScope( - `function Hint(): JSX.Element { return

Don't panic

; }\n${acmeScope}`, - ), - ).toEqual({ organization: "acme", environment: "production" }); - expect(readDeclaredAppScope(`const quote = /"[}']/g;\n${acmeScope}`)).toEqual({ - organization: "acme", - environment: "production", - }); - expect( - readDeclaredAppScope( - `defineDataApp({ scope: { organization: "\\u0041cme", environment: "\\x70rod" } })`, - ), - ).toEqual({ organization: "Acme", environment: "prod" }); - expect( - readDeclaredAppScope( - `const total = 4 / 2; defineDataApp({ title: \`\${total} / \${"}"}\`, scope: { organization: "acme", environment: "production" } as const })`, - ), - ).toEqual({ organization: "acme", environment: "production" }); +test("validates scope shape after extraction", () => { + for (const config of [ + "{}", + "{ scope: null }", + "{ scope: [] }", + '{ scope: { organization: 1, environment: "production" } }', + '{ scope: { organization: "acme" } }', + '{ scope: { organization: "acme", environment: " " } }', + ]) { + expect(() => readDeclaredAppScope(app(config))).toThrow("scope.organization"); + } }); -test("requires a literal defineDataApp scope", () => { - expect(() => readDeclaredAppScope("export const app = 1;")).toThrow("defineDataApp"); - expect(() => readDeclaredAppScope("defineDataApp(config)")).toThrow("object literal"); - expect(() => readDeclaredAppScope("defineDataApp({ title: 'App', queries: {} })")).toThrow( - "scope.organization", - ); - expect(() => - readDeclaredAppScope( - "const scope = { organization: 'acme', environment: 'production' }; defineDataApp({ scope })", - ), - ).toThrow("scope.organization"); - expect(() => +test("reports extractor diagnostics for invalid declarations without executing source", () => { + for (const source of [ + "export const app = 1;", + "function defineDataApp(value) { return value; } defineDataApp({ scope: {} });", + app("config"), + app("{ scope }"), + app('{ scope: { organization: "acme" + suffix, environment: "production" } }'), + app("{ scope: {"), + ]) { + expect(() => readDeclaredAppScope(source)).toThrow("Could not extract the data app manifest."); + } + expect( readDeclaredAppScope( - "defineDataApp({ scope: { organization: 'acme' + suffix, environment: 'production' } })", + `throw new Error("must not execute");\n${app('{ scope: { organization: "acme", environment: "production" } }')}`, ), - ).toThrow("scope.organization"); - expect(() => readDeclaredAppScope("defineDataApp({ scope: {")).toThrow("could not be parsed"); + ).toEqual({ organization: "acme", environment: "production" }); + expect(() => readDeclaredAppScope(`${factoryImport}\nconst app = define(config);`)).toThrow("2:"); }); diff --git a/cli/src/commands/app/lib/declared-app.ts b/cli/src/commands/app/lib/declared-app.ts index 6dcf3b99..85015e67 100644 --- a/cli/src/commands/app/lib/declared-app.ts +++ b/cli/src/commands/app/lib/declared-app.ts @@ -1,216 +1,30 @@ +import { extractDataAppManifest } from "@altertable/data-app-manifest"; import { ConfigurationError } from "@/lib/errors.ts"; +import { isRecord } from "@/lib/object.ts"; export type DeclaredAppScope = { organization: string; environment: string }; -type Token = { kind: "identifier" | "string" | "punctuator" | "operand"; value: string }; - -/** Scope is a literal so preview can refuse a profile mismatch before querying. */ +/** Read the authored scope without executing application code. */ export function readDeclaredAppScope(source: string): DeclaredAppScope { - const tokens = tokenize(transpile(source)).tokens; - const scope = objectProperties(tokens, dataAppConfig(tokens)).get("scope"); - const fields = - scope !== undefined && tokens[scope]!.value === "{" - ? objectProperties(tokens, scope) - : new Map(); - const organization = literalString(tokens, fields.get("organization")); - const environment = literalString(tokens, fields.get("environment")); - if (!organization?.trim() || !environment?.trim()) { + const { manifest, diagnostics } = extractDataAppManifest(source); + if (!manifest) { + const details = diagnostics + .slice(0, 3) + .map(({ message, start }) => `${start.line}:${start.column}: ${message}`) + .join("\n"); + throw new ConfigurationError(`Could not extract the data app manifest.\n${details}`); + } + const scope = manifest.scope; + if ( + !isRecord(scope) || + typeof scope.organization !== "string" || + !scope.organization.trim() || + typeof scope.environment !== "string" || + !scope.environment.trim() + ) { throw new ConfigurationError( "The data app file must declare scope.organization and scope.environment as string literals.", ); } - return { organization, environment }; -} - -function literalString(tokens: Token[], index: number | undefined): string | undefined { - if (index === undefined) return undefined; - const token = tokens[index]!; - const after = tokens[index + 1]?.value; - return token.kind === "string" && (after === "," || after === "}") ? token.value : undefined; -} - -/** Bun removes TypeScript and JSX, so the scanner only needs JavaScript's lexical grammar. */ -function transpile(source: string): string { - try { - return new Bun.Transpiler({ loader: "tsx" }).transformSync(source); - } catch (error) { - throw new ConfigurationError("The data app file could not be parsed.", { cause: error }); - } -} - -function dataAppConfig(tokens: Token[]): number { - for (let index = 0; index < tokens.length; index++) { - if (tokens[index]!.value !== "defineDataApp" || tokens[index + 1]?.value !== "(") continue; - if (tokens[index + 2]?.value !== "{") { - throw new ConfigurationError("defineDataApp must be called with an object literal."); - } - return index + 2; - } - throw new ConfigurationError( - "Could not find defineDataApp(...) in the data app file. Declare the app with defineDataApp({ scope: { organization, environment } }).", - ); -} - -/** Maps each literal key of the object opening at `open` to the index of its value's first token. */ -function objectProperties(tokens: Token[], open: number): Map { - const properties = new Map(); - let index = open + 1; - while (index < tokens.length && tokens[index]!.value !== "}") { - const key = tokens[index]!; - if ((key.kind === "identifier" || key.kind === "string") && tokens[index + 1]?.value === ":") { - index += 2; - properties.set(key.value, index); - } - let depth = 0; - for (; index < tokens.length; index++) { - const value = tokens[index]!.value; - if (depth === 0 && (value === "," || value === "}")) break; - if (value === "{" || value === "(" || value === "[") depth += 1; - else if (value === "}" || value === ")" || value === "]") depth -= 1; - } - if (tokens[index]?.value === ",") index += 1; - } - return properties; -} - -const regexKeywords = new Set([ - "await", - "case", - "delete", - "do", - "else", - "in", - "instanceof", - "new", - "of", - "return", - "throw", - "typeof", - "void", - "yield", -]); - -/** Templates and regexes are opaque operands. With `nested`, stops after the `}` closing `${`. */ -function tokenize(source: string, start = 0, nested = false): { tokens: Token[]; end: number } { - const tokens: Token[] = []; - let depth = 0; - let index = start; - while (index < source.length) { - const char = source[index]!; - const next = source[index + 1]; - if (/\s/.test(char)) { - index += 1; - } else if (char === "/" && next === "/") { - const newline = source.indexOf("\n", index); - index = newline < 0 ? source.length : newline + 1; - } else if (char === "/" && next === "*") { - const end = source.indexOf("*/", index + 2); - if (end < 0) throw new ConfigurationError("The data app file has an unterminated comment."); - index = end + 2; - } else if (char === "'" || char === '"') { - const text = readString(source, index); - tokens.push({ kind: "string", value: text.value }); - index = text.end; - } else if (char === "`") { - index = skipTemplate(source, index); - tokens.push({ kind: "operand", value: "`" }); - } else if (char === "/" && startsRegex(tokens.at(-1))) { - index = skipRegex(source, index); - tokens.push({ kind: "operand", value: "/" }); - } else if (/[A-Za-z_$]/.test(char)) { - const end = scan(source, index, /[\w$]/); - tokens.push({ kind: "identifier", value: source.slice(index, end) }); - index = end; - } else if (/\d/.test(char)) { - const end = scan(source, index, /[\w.]/); - tokens.push({ kind: "operand", value: source.slice(index, end) }); - index = end; - } else { - if (char === "}" && nested && depth === 0) return { tokens, end: index + 1 }; - if (char === "{") depth += 1; - else if (char === "}") depth -= 1; - tokens.push({ kind: "punctuator", value: char }); - index += 1; - } - } - if (nested) throw new ConfigurationError("The data app file has an unterminated template."); - return { tokens, end: index }; -} - -function scan(source: string, index: number, pattern: RegExp): number { - let end = index + 1; - while (end < source.length && pattern.test(source[end]!)) end += 1; - return end; -} - -function startsRegex(previous: Token | undefined): boolean { - if (previous === undefined) return true; - if (previous.kind === "identifier") return regexKeywords.has(previous.value); - return previous.kind === "punctuator" && !")]}".includes(previous.value); -} - -function readString(source: string, start: number): { value: string; end: number } { - const quote = source[start]!; - let value = ""; - for (let index = start + 1; index < source.length; index++) { - const char = source[index]!; - if (char === quote) return { value, end: index + 1 }; - if (char === "\n") break; - if (char !== "\\") { - value += char; - continue; - } - const escape = readEscape(source, index + 1); - value += escape.value; - index = escape.end - 1; - } - throw new ConfigurationError("The data app file has an unterminated string."); -} - -const simpleEscapes: Record = { - b: "\b", - f: "\f", - n: "\n", - r: "\r", - t: "\t", - v: "\v", - "0": "\0", - "\n": "", -}; - -function readEscape(source: string, index: number): { value: string; end: number } { - const code = /^(?:x([\da-f]{2})|u([\da-f]{4})|u\{([\da-f]{1,6})\})/i.exec( - source.slice(index, index + 10), - ); - if (code) { - const hex = code[1] ?? code[2] ?? code[3]!; - return { value: String.fromCodePoint(Number.parseInt(hex, 16)), end: index + code[0].length }; - } - const char = source[index] ?? ""; - return { value: simpleEscapes[char] ?? char, end: index + 1 }; -} - -function skipTemplate(source: string, start: number): number { - for (let index = start + 1; index < source.length; index++) { - const char = source[index]!; - if (char === "\\") index += 1; - else if (char === "`") return index + 1; - else if (char === "$" && source[index + 1] === "{") { - index = tokenize(source, index + 2, true).end - 1; - } - } - throw new ConfigurationError("The data app file has an unterminated template."); -} - -function skipRegex(source: string, start: number): number { - let inClass = false; - for (let index = start + 1; index < source.length; index++) { - const char = source[index]!; - if (char === "\\") index += 1; - else if (char === "\n") break; - else if (char === "[") inClass = true; - else if (char === "]") inClass = false; - else if (char === "/" && !inClass) return scan(source, index, /[a-z]/i); - } - throw new ConfigurationError("The data app file has an unterminated regular expression."); + return { organization: scope.organization, environment: scope.environment }; } diff --git a/cli/src/commands/app/lib/distribution.test.ts b/cli/src/commands/app/lib/distribution.test.ts index 4985af4b..07c5fc19 100644 --- a/cli/src/commands/app/lib/distribution.test.ts +++ b/cli/src/commands/app/lib/distribution.test.ts @@ -40,9 +40,9 @@ test("distribution embeds a deterministic starter with an exact package pin and recommendedDataAppVersion(), ); expect(JSON.parse(files["package.json"]!).dependencies).toMatchObject({ - "@altertable/data-app": "0.71.0", - "@altertable/data-app-react": "0.71.0", - "@altertable/data-app-bun": "0.71.0", + "@altertable/data-app": "0.72.0", + "@altertable/data-app-react": "0.72.0", + "@altertable/data-app-bun": "0.72.0", }); expect(files["src/main.tsx"]).toContain('from "@altertable/data-app-react"'); expect(files["src/preview.ts"]).toContain('from "@altertable/data-app-bun/preview"'); diff --git a/cli/src/commands/app/lib/file-dev-host.html.txt b/cli/src/commands/app/lib/file-dev-host.html.txt deleted file mode 100644 index 3fb9aaca..00000000 --- a/cli/src/commands/app/lib/file-dev-host.html.txt +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - Altertable data app - - - - - - - - diff --git a/cli/src/commands/app/lib/file-dev-host.js.txt b/cli/src/commands/app/lib/file-dev-host.js.txt deleted file mode 100644 index ff1efbf5..00000000 --- a/cli/src/commands/app/lib/file-dev-host.js.txt +++ /dev/null @@ -1,128 +0,0 @@ -import { - attachDataAppBridge, - createNavigationHandler, - createSqlQueryHandler, -} from "@altertable/data-app/embed"; -import { - createMessageRouter, - defineMessageRoute, - navigationUpdateRoute, - sqlQueryRoute, - DataSourceError, -} from "@altertable/data-app/contract"; - -const status = document.querySelector("#status"); -const iframe = document.querySelector("#app"); -const themeQuery = matchMedia("(prefers-color-scheme: dark)"); - -function presentation() { - return { surface: "standalone", theme: themeQuery.matches ? "dark" : "light" }; -} - -const fileRoute = defineMessageRoute({ - input(value) { - if ( - !value || - typeof value !== "object" || - typeof value.filename !== "string" || - !(value.blob instanceof Blob) - ) { - throw new Error("Invalid export."); - } - return { filename: value.filename, blob: value.blob }; - }, - output(value) { - if (value !== null) throw new Error("Invalid export response."); - return null; - }, -}); - -function download(file) { - const url = URL.createObjectURL(file.blob); - const link = document.createElement("a"); - link.href = url; - link.download = file.filename; - document.body.append(link); - link.click(); - link.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - return null; -} - -const router = createMessageRouter( - { - "data:sql": sqlQueryRoute, - "navigation:update": navigationUpdateRoute, - "export:csv": fileRoute, - "export:zip": fileRoute, - }, - { - "navigation:update": createNavigationHandler(), - "data:sql": createSqlQueryHandler(async () => ({ - async queryAll(statement, { limit, signal, params }) { - const response = await fetch("/api/sql", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - statement, - limit, - ...(params === undefined ? {} : { params }), - }), - signal, - }); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throw new DataSourceError(body.reason ?? "unavailable"); - } - return response.json(); - }, - })), - "export:csv": download, - "export:zip": download, - }, -); - -let host; -let generation = 0; - -function showStatus(message) { - status.hidden = false; - status.textContent = message; -} - -async function mount() { - const current = ++generation; - host?.dispose(); - host = undefined; - iframe.hidden = true; - showStatus("Loading data app..."); - const response = await fetch("/bundle.js", { cache: "no-store" }); - if (current !== generation) return; - if (!response.ok) { - showStatus(await response.text()); - return; - } - host = attachDataAppBridge({ - iframe, - source: { type: "bundle", javascript: await response.text() }, - presentation: presentation(), - onMessage: router.dispatch, - onStatusChange(value) { - if (current !== generation) return; - const ready = value === "ready"; - iframe.hidden = !ready; - if (value === "failed") showStatus("Could not load the data app."); - if (ready) status.hidden = true; - }, - }); -} - -themeQuery.addEventListener("change", () => { - host?.setPresentation(presentation()); -}); -await mount(); - -const reload = new EventSource("/__dev/reload"); -reload.addEventListener("message", () => { - void mount(); -}); diff --git a/cli/src/commands/app/lib/file-dev-server.ts.txt b/cli/src/commands/app/lib/file-dev-server.ts.txt index b0f868fb..0ba47a55 100644 --- a/cli/src/commands/app/lib/file-dev-server.ts.txt +++ b/cli/src/commands/app/lib/file-dev-server.ts.txt @@ -1,194 +1,17 @@ -import { watch } from "node:fs"; -import { basename, dirname, isAbsolute, join } from "node:path"; -import hostPage from "./host.html"; -import { DataSourceError, sqlQueryRoute } from "@altertable/data-app/contract"; -import { localLakehouse } from "@altertable/data-app/server/bun"; - -const appFile = process.env.ALTERTABLE_APP_FILE; -if (typeof appFile !== "string" || appFile.length === 0) { - throw new Error("Missing data app file."); -} -const port = Number(process.env.PORT); -if (!process.env.PORT || !Number.isInteger(port)) throw new Error("Missing preview port."); - -const lakehouse = localLakehouse(); -const clients = new Set(); -let reloadTimer; - -function scheduleReload() { - clearTimeout(reloadTimer); - reloadTimer = setTimeout(() => { - console.log("Reloading data app."); - for (const client of clients) { - try { - client.enqueue("data: reload\n\n"); - } catch { - clients.delete(client); - } - } - }, 50); -} - -try { - watch(dirname(appFile), (_, filename) => { - const name = filename == null ? undefined : filename.toString(); - if (name === undefined || name === basename(appFile)) scheduleReload(); - }); -} catch (error) { - console.error( - `Watching ${appFile} failed: ${error instanceof Error ? error.message : "unknown error"}.`, - ); -} - -function runtimePackages() { - const from = join(import.meta.dir, "package.json"); - const installed = `${join(import.meta.dir, "node_modules")}/`.replaceAll("\\", "/"); - return { - name: "altertable-file-dev-packages", - setup(build) { - // The app file lives outside this runtime. Resolve its package imports here, - // then let Bun resolve dependencies from the installed package tree. - build.onResolve({ filter: /^[^./]/ }, (args) => { - if (args.namespace !== "file" || args.importer.replaceAll("\\", "/").startsWith(installed)) - return; - if (args.path.startsWith("node:") || args.path.startsWith("bun:")) return; - try { - const resolved = Bun.resolveSync(args.path, from); - return isAbsolute(resolved) ? { path: resolved } : undefined; - } catch { - return undefined; - } - }); - }, - }; -} +import { localLakehouse } from "@altertable/data-app-bun"; +import { serveAppFile } from "@altertable/data-app-bun/preview"; + +const preview = await serveAppFile({ + file: process.env.ALTERTABLE_APP_FILE, + port: Number(process.env.PORT), + lakehouse: localLakehouse(), + resolveDependency: (specifier) => Bun.resolveSync(specifier, import.meta.dir), +}); -async function bundleApp() { - const result = await Bun.build({ - entrypoints: [appFile], - target: "browser", - format: "iife", - // Prefer browser entry points. Bun's default conditions include "node" and - // would pull Node builds such as fflate's createRequire entry. - conditions: ["browser", "import", "module", "default"], - define: { "import.meta.env": "{}" }, - plugins: [runtimePackages()], +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, async () => { + await preview.stop(); + process.exit(0); }); - if (!result.success) { - const details = result.logs - .map((log) => { - const notes = (log.notes ?? []).map((note) => note.text).filter(Boolean); - return [log.message, ...notes].filter(Boolean).join("\n"); - }) - .filter(Boolean) - .join("\n"); - throw new Error(details || "Bundling the data app failed."); - } - const scripts = result.outputs.filter((output) => output.path.endsWith(".js")); - if (scripts.length !== 1) throw new Error("The data app must bundle to one script."); - const javascript = await scripts[0].text(); - const stylesheet = result.outputs.find((output) => output.path.endsWith(".css")); - if (!stylesheet) return javascript; - const css = await stylesheet.text(); - return `const style = document.createElement("style"); style.textContent = ${JSON.stringify(css)}; document.head.append(style);\n${javascript}`; -} - -function readQuery(body) { - try { - return sqlQueryRoute.input(JSON.parse(body)); - } catch { - return undefined; - } -} - -// Queries run with the profile's credentials. The Host check defeats DNS rebinding; the Origin -// check rejects cross-site POSTs, which browsers send without a CORS preflight. -function localHosts(server) { - return [`127.0.0.1:${server.port}`, `localhost:${server.port}`]; -} - -function isLocalHost(request, server) { - return localHosts(server).includes(request.headers.get("host")); -} - -function isSameOrigin(request, server) { - const site = request.headers.get("sec-fetch-site"); - const origin = request.headers.get("origin"); - return ( - (site === null || site === "same-origin") && - localHosts(server).some((host) => origin === `http://${host}`) - ); -} - -function forbidden() { - return new Response("Forbidden", { status: 403 }); } - -const server = Bun.serve({ - hostname: "127.0.0.1", - port, - idleTimeout: 60, - routes: { "/": hostPage }, - async fetch(request, server) { - if (!isLocalHost(request, server)) return forbidden(); - const url = new URL(request.url); - if (url.pathname === "/bundle.js") { - try { - return new Response(await bundleApp(), { - headers: { - "content-type": "text/javascript; charset=utf-8", - "cache-control": "no-store", - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Bundling the data app failed."; - console.error(message); - return new Response(message, { - status: 500, - headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }, - }); - } - } - if (url.pathname === "/__dev/reload") { - server.timeout(request, 0); - return new Response( - new ReadableStream({ - start(controller) { - clients.add(controller); - controller.enqueue("event: ready\ndata: ok\n\n"); - request.signal.addEventListener("abort", () => clients.delete(controller)); - }, - }), - { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } }, - ); - } - if (url.pathname === "/api/sql" && request.method === "POST") { - if (!isSameOrigin(request, server)) return forbidden(); - const body = await request.text(); - if (body.length > 100_000) return new Response("Query too large", { status: 413 }); - const query = readQuery(body); - if (!query) return new Response("Invalid query", { status: 400 }); - try { - const result = await lakehouse.queryAll(query.statement, { - limit: query.limit, - signal: request.signal, - ...(query.params === undefined ? {} : { params: query.params }), - }); - return Response.json({ - columns: result.columns, - rows: result.rows, - ...(result.queryId === undefined ? {} : { queryId: result.queryId }), - }); - } catch (error) { - const reason = error instanceof DataSourceError ? error.reason : "unavailable"; - console.error( - `Data app query failed (${reason}): ${error instanceof Error ? error.message : reason}.`, - ); - return Response.json({ reason }, { status: 502 }); - } - } - return new Response("Not found", { status: 404 }); - }, -}); - -console.log(`Data app running at ${server.url} (${appFile})`); +console.log(`Data app running at ${preview.url} (${process.env.ALTERTABLE_APP_FILE})`); diff --git a/cli/src/commands/app/lib/file-dev.test.ts b/cli/src/commands/app/lib/file-dev.test.ts index 5869b579..0fbbc4d0 100644 --- a/cli/src/commands/app/lib/file-dev.test.ts +++ b/cli/src/commands/app/lib/file-dev.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, expect, test } from "bun:test"; @@ -9,6 +9,8 @@ import { writeFileDevRuntime, } from "@/commands/app/lib/file-dev.ts"; import { recommendedDataAppVersion } from "@/commands/app/lib/package.ts"; +import { dataAppDirectory } from "@/commands/app/lib/distribution.ts"; +import { startAppDevProxy } from "@/commands/app/lib/dev-proxy.ts"; import { EXIT_GENERIC } from "@/lib/errors.ts"; import type { ExecutionContext } from "@/lib/execution-context.ts"; import { FROM_ENV_PSEUDOPROFILE_NAME } from "@/lib/profile-store.ts"; @@ -151,7 +153,12 @@ test("serves a single file and forwards same-origin SQL from the preview server" const runtime = join(root, "runtime"); const appFile = join(root, "index.tsx"); await writeFileDevRuntime(runtime); - await writePackageStubs(runtime); + await symlink(join(dataAppDirectory, "starter/node_modules"), join(runtime, "node_modules")); + const queries: unknown[] = []; + const proxy = startAppDevProxy({} as ExecutionContext, async (request) => { + queries.push(JSON.parse(request.body as string)); + return new Response('{"query_id":"q1"}\n[{"name":"value","type":"INTEGER"}]\n[1]\n').body!; + }); await writeFile(appFile, appSource("Preview")); const stderr: string[] = []; @@ -160,13 +167,14 @@ test("serves a single file and forwards same-origin SQL from the preview server" env: { ...process.env, ALTERTABLE_APP_FILE: appFile, - ALTERTABLE_DATA_PROXY_TOKEN: "secret-token-value", + ...proxy.environment, PORT: "0", }, stdout: "pipe", stderr: "pipe", }); processes.push(child); + void child.exited.finally(() => proxy.stop()); drain(child.stderr, stderr); const url = await readServerUrl(child, stderr); const origin = url.slice(0, -1); @@ -180,7 +188,7 @@ test("serves a single file and forwards same-origin SQL from the preview server" const bundle = await (await fetch(`${url}bundle.js`)).text(); expect(bundle).toContain("Preview"); - expect(bundle).not.toContain("secret-token-value"); + expect(bundle).not.toContain(proxy.environment.ALTERTABLE_DATA_PROXY_TOKEN); const accepted = await query( JSON.stringify({ statement: "select 1", limit: 1, params: { groupName: "Alpha" } }), @@ -188,14 +196,17 @@ test("serves a single file and forwards same-origin SQL from the preview server" ); expect(accepted.status).toBe(200); expect(await accepted.json()).toEqual({ - columns: [{ name: "statement" }, { name: "limit" }], - rows: [["select 1", 1]], - queryId: "Alpha", + columns: [{ name: "value", type: "INTEGER" }], + rows: [[1]], + queryId: "q1", }); - const rejected = await query(JSON.stringify({ statement: "fail", limit: 1 })); - expect(rejected.status).toBe(502); - expect(await rejected.json()).toEqual({ reason: "query_rejected" }); + expect(queries).toEqual([ + expect.objectContaining({ + statement: "select 1", + params: { groupName: "Alpha" }, + }), + ]); expect((await query("{")).status).toBe(400); expect((await query(JSON.stringify({ statement: "select 1", limit: 0 }))).status).toBe(400); expect((await fetch(`${url}missing`)).status).toBe(404); @@ -208,15 +219,17 @@ test("serves a single file and forwards same-origin SQL from the preview server" expect(rebound.status).toBe(403); await writeFile(appFile, appSource("Updated")); - await Bun.sleep(100); + await Bun.sleep(500); expect(await (await fetch(`${url}bundle.js`)).text()).toContain("Updated"); - const reload = await fetch(`${url}__dev/reload`); + const reload = await fetch(`${url}reload`); const reader = reload.body?.getReader(); expect(reader).toBeDefined(); + expect(await readUntil(reader!, "event: ready")).toContain("event: ready"); await writeFile(appFile, appSource("Reloaded")); - const events = await readUntil(reader!, "reload"); - expect(events).toContain("reload"); + const events = await readUntil(reader!, "data:"); + expect(events).toContain("data:"); + expect(await (await fetch(`${url}bundle.js`)).text()).toContain("Reloaded"); await reader?.cancel(); }, 15_000); @@ -235,88 +248,6 @@ export function View() { `; } -async function writePackageStubs(runtime: string): Promise { - const dataApp = join(runtime, "node_modules/@altertable/data-app"); - const react = join(runtime, "node_modules/react"); - await mkdir(join(dataApp, "server"), { recursive: true }); - await mkdir(react, { recursive: true }); - await writeFile( - join(dataApp, "package.json"), - JSON.stringify({ name: "@altertable/data-app", version: "0.0.0", type: "module" }), - ); - await writeFile( - join(dataApp, "index.js"), - "export function defineDataApp(config) { return config; }\n", - ); - await writeFile( - join(dataApp, "server/bun.js"), - `import { DataSourceError } from "../contract.js"; -export function localLakehouse() { - return { - async queryAll(statement, options) { - if (statement === "fail") throw new DataSourceError("query_rejected"); - return { - columns: [{ name: "statement" }, { name: "limit" }], - rows: [[statement, options.limit]], - queryId: options.params?.groupName ?? "q1", - }; - }, - }; -} -`, - ); - await writeFile( - join(dataApp, "embed.js"), - `export function attachDataAppBridge() { return { dispose() {}, setPresentation() {} }; } -export function createNavigationHandler() { return () => null; } -export function createSqlQueryHandler(authorize) { return authorize; } -`, - ); - await writeFile( - join(dataApp, "contract.js"), - `export function createMessageRouter() { return { dispatch() {} }; } -export function defineMessageRoute(route) { return route; } -export const navigationUpdateRoute = {}; -export const sqlQueryRoute = { - input(value) { - if (typeof value?.statement !== "string" || !(value.limit >= 1)) throw new Error("Invalid SQL query."); - return value; - }, -}; -const brand = Symbol.for("@altertable/data-app/DataSourceError"); -export class DataSourceError extends Error { - [brand] = true; - static [Symbol.hasInstance](value) { - return value instanceof Error && value[brand] === true; - } - constructor(reason) { - super(reason); - this.reason = reason; - } -} -`, - ); - await writeFile( - join(react, "package.json"), - JSON.stringify({ - name: "react", - type: "module", - exports: { - "./jsx-runtime": "./jsx-runtime.js", - "./jsx-dev-runtime": "./jsx-dev-runtime.js", - }, - }), - ); - await writeFile( - join(react, "jsx-runtime.js"), - 'export function jsx(type, props, key) { return { type, props, key }; }\nexport const jsxs = jsx;\nexport const Fragment = "Fragment";\n', - ); - await writeFile( - join(react, "jsx-dev-runtime.js"), - 'export function jsxDEV(type, props, key) { return { type, props, key }; }\nexport const Fragment = "Fragment";\n', - ); -} - function drain(stream: ReadableStream, chunks: string[]): void { const reader = stream.getReader(); const decoder = new TextDecoder(); diff --git a/cli/src/commands/app/lib/file-dev.ts b/cli/src/commands/app/lib/file-dev.ts index eb472d7f..b941745f 100644 --- a/cli/src/commands/app/lib/file-dev.ts +++ b/cli/src/commands/app/lib/file-dev.ts @@ -1,8 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import serverScript from "@/commands/app/lib/file-dev-server.ts.txt"; -import hostPage from "@/commands/app/lib/file-dev-host.html.txt"; -import hostScript from "@/commands/app/lib/file-dev-host.js.txt"; import { readDeclaredAppScope } from "@/commands/app/lib/declared-app.ts"; import { startAppDevProxy } from "@/commands/app/lib/dev-proxy.ts"; import { createAppFiles } from "@/commands/app/lib/distribution.ts"; @@ -75,7 +73,5 @@ function runtimeFiles(): Record { "bun.lock": starter["bun.lock"]!, "bunfig.toml": starter["bunfig.toml"]!, "server.ts": serverScript, - "host.html": hostPage, - "host.js": hostScript, }; } diff --git a/data-app/starter/bun.lock b/data-app/starter/bun.lock index 047bc1a7..93b990cb 100644 --- a/data-app/starter/bun.lock +++ b/data-app/starter/bun.lock @@ -5,9 +5,9 @@ "": { "name": "data-app-starter", "dependencies": { - "@altertable/data-app": "0.71.0", - "@altertable/data-app-bun": "0.71.0", - "@altertable/data-app-react": "0.71.0", + "@altertable/data-app": "0.72.0", + "@altertable/data-app-bun": "0.72.0", + "@altertable/data-app-react": "0.72.0", "react": "19.3.0", "react-dom": "19.3.0", }, @@ -23,11 +23,11 @@ }, }, "packages": { - "@altertable/data-app": ["@altertable/data-app@0.71.0", "", { "dependencies": { "@internationalized/date": "3.12.4" } }, "sha512-2v4vy3BJnKHFp7LJ60xwSipBrwZED+6v+iFKmKYy0k3hHEf5iUjApR7NwUJVNC5dJxDoQPLGLesQZrA18VoOuw=="], + "@altertable/data-app": ["@altertable/data-app@0.72.0", "", { "dependencies": { "@internationalized/date": "3.12.4" } }, "sha512-F1wgk5ANgxrl2y48w2D0PpWkv8SHTiPapBbmGrlrb4OQjgj2JAjmitVBUW/luMj0M3E7hwVAjOvld4JT6KbloQ=="], - "@altertable/data-app-bun": ["@altertable/data-app-bun@0.71.0", "", { "dependencies": { "@altertable/data-app": "0.71.0", "@altertable/lakehouse": "0.4.1", "get-tsconfig": "4.14.3" } }, "sha512-uxYbvGeWhfRAVfdWAHWDKDW17csmpQocIK/STewR5Cb+X7HS51CJY/hdXSKXBBMsejMH6CW69oy49PBOnq1rTw=="], + "@altertable/data-app-bun": ["@altertable/data-app-bun@0.72.0", "", { "dependencies": { "@altertable/data-app": "0.72.0", "@altertable/lakehouse": "0.4.1", "get-tsconfig": "4.14.3" } }, "sha512-fMjSn8TIBo5oSQbimlHHyfUauZ/xxKvKCl7Jze7xUrIPSLccZXAesHjBIHwoKmKjXstBheWp47asrjooN5pLgw=="], - "@altertable/data-app-react": ["@altertable/data-app-react@0.71.0", "", { "dependencies": { "@altertable/data-app": "0.71.0", "@floating-ui/react": "0.27.20", "@internationalized/date": "3.12.4", "@tanstack/react-query": "5.104.0", "fflate": "0.8.3", "fuzzysort": "4.0.2", "html-to-image": "1.11.13", "lucide-react": "1.48.0", "react-aria-components": "1.21.1", "recharts": "3.10.1" }, "peerDependencies": { "react": ">=19.2.0", "react-dom": ">=19.2.0" } }, "sha512-1qT4h94ZUWUh6dkJnqpo1a+b0HNSdZeu6Fs32bIQQy/qZyevPhuE0HKK2O6pEgzcQmYJPlUKmxTn7HquKtprNQ=="], + "@altertable/data-app-react": ["@altertable/data-app-react@0.72.0", "", { "dependencies": { "@altertable/data-app": "0.72.0", "@floating-ui/react": "0.27.20", "@internationalized/date": "3.12.4", "@tanstack/react-query": "5.104.0", "fflate": "0.8.3", "fuzzysort": "4.0.2", "html-to-image": "1.11.13", "lucide-react": "1.48.0", "react-aria-components": "1.21.1", "recharts": "3.10.1" }, "peerDependencies": { "react": ">=19.2.0", "react-dom": ">=19.2.0" } }, "sha512-SEJ3tHdZ6oUZm+pJLiesfnknYQfSd3/Aymhip3qA08gu3uJCQ1I2ocXLmzj6zcZZXFmtDwGhkWprVGXiomtxGA=="], "@altertable/lakehouse": ["@altertable/lakehouse@0.4.1", "", {}, "sha512-m7d15cWt+WUGW7lVw2KYGD+Se8MzO25G7SWSuFd+OasBxWmOJNa2IrgvOHGVEbnZ+kexTUy9NjAGQ69sG4V91w=="], diff --git a/data-app/starter/package.json b/data-app/starter/package.json index ac9b6f6c..389733e9 100644 --- a/data-app/starter/package.json +++ b/data-app/starter/package.json @@ -15,9 +15,9 @@ "build": "bun build src/index.html --outdir=dist --production" }, "dependencies": { - "@altertable/data-app": "0.71.0", - "@altertable/data-app-react": "0.71.0", - "@altertable/data-app-bun": "0.71.0", + "@altertable/data-app": "0.72.0", + "@altertable/data-app-bun": "0.72.0", + "@altertable/data-app-react": "0.72.0", "react": "19.3.0", "react-dom": "19.3.0" }, diff --git a/data-app/tests/bun.lock b/data-app/tests/bun.lock index e327efa8..6f2b44b6 100644 --- a/data-app/tests/bun.lock +++ b/data-app/tests/bun.lock @@ -5,7 +5,7 @@ "": { "name": "data-app-browser-tests", "dependencies": { - "@altertable/data-app": "0.71.0", + "@altertable/data-app": "0.72.0", }, "devDependencies": { "@playwright/test": "1.63.0", @@ -16,7 +16,7 @@ }, }, "packages": { - "@altertable/data-app": ["@altertable/data-app@0.71.0", "", { "dependencies": { "@internationalized/date": "3.12.4" } }, "sha512-2v4vy3BJnKHFp7LJ60xwSipBrwZED+6v+iFKmKYy0k3hHEf5iUjApR7NwUJVNC5dJxDoQPLGLesQZrA18VoOuw=="], + "@altertable/data-app": ["@altertable/data-app@0.72.0", "", { "dependencies": { "@internationalized/date": "3.12.4" } }, "sha512-F1wgk5ANgxrl2y48w2D0PpWkv8SHTiPapBbmGrlrb4OQjgj2JAjmitVBUW/luMj0M3E7hwVAjOvld4JT6KbloQ=="], "@internationalized/date": ["@internationalized/date@3.12.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="], diff --git a/data-app/tests/package.json b/data-app/tests/package.json index d012dc63..ec16deec 100644 --- a/data-app/tests/package.json +++ b/data-app/tests/package.json @@ -15,6 +15,6 @@ "typescript": "7.0.2" }, "dependencies": { - "@altertable/data-app": "0.71.0" + "@altertable/data-app": "0.72.0" } } From 5f6bb4cbc4c10ee89620537b2cc755fbbf1501ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 19 Oct 2026 16:21:40 +0200 Subject: [PATCH 3/4] refactor(app): share the dev lifecycle for single-file previews Treat a single-file preview as a cached dev project and reuse the existing frozen install, script runner, credential-isolating proxy, and cleanup flow. Feed the released manifest directly into the existing scope validator. Remove duplicate scope parsing wrappers, custom process/runtime helpers, and tests for behavior owned by the published preview package. Keep focused coverage of static preflight and pinned project preparation. --- cli/src/commands/app/dev.ts | 54 ++- cli/src/commands/app/lib/declared-app.test.ts | 50 --- cli/src/commands/app/lib/declared-app.ts | 30 -- cli/src/commands/app/lib/file-dev.test.ts | 319 +++--------------- cli/src/commands/app/lib/file-dev.ts | 83 ++--- cli/src/commands/app/lib/run.ts | 23 +- cli/src/commands/app/lib/scope.ts | 10 +- 7 files changed, 97 insertions(+), 472 deletions(-) delete mode 100644 cli/src/commands/app/lib/declared-app.test.ts delete mode 100644 cli/src/commands/app/lib/declared-app.ts diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index 8f6ad450..b6f4baba 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -1,9 +1,8 @@ import { resolve } from "node:path"; -import { fileDevPort, previewAppFile } from "@/commands/app/lib/file-dev.ts"; +import { prepareFilePreview } from "@/commands/app/lib/file-dev.ts"; import { checkAppScope } from "@/commands/app/lib/scope.ts"; import { defineCommand } from "@/lib/command.ts"; import { CliError, EXIT_GENERIC } from "@/lib/errors.ts"; -import type { ExecutionContext } from "@/lib/execution-context.ts"; import { startAppDevProxy } from "@/commands/app/lib/dev-proxy.ts"; import { appDirectory, @@ -34,42 +33,35 @@ export const appDevCommand = defineCommand({ }, port: { type: "string", - description: `Local dev server port (1–65535; default: app setting, or ${fileDevPort} for --file).`, + description: "Local dev server port (1–65535; default: app setting, or 25837 for --file).", }, }, async run({ args, execution, runtime }) { assertAppOutputMode(runtime.context.json, runtime.context.agent); - const port = appPort(args.port); - const exitCode = - args.file === undefined - ? await previewAppDirectory(appDirectory(args.dir), port, execution) - : await previewAppFile(appFile(args.file, args.dir), port ?? fileDevPort, execution); - return { exitCode }; + const file = args.file === undefined ? undefined : appFile(args.file, args.dir); + const port = appPort(args.port) ?? (file ? "25837" : undefined); + const directory = file + ? await prepareFilePreview(file, execution.profile) + : appDirectory(args.dir); + requireAppScripts(directory, ["dev"]); + if (!file) await checkAppScope(directory, execution.profile); + const installExitCode = await runAppCommand(file ? "install-frozen" : "install", directory); + if (installExitCode !== 0) return { exitCode: EXIT_GENERIC }; + const proxy = startAppDevProxy(execution); + try { + const environment = { + ...proxy.environment, + ...(file ? { ALTERTABLE_APP_FILE: file } : {}), + ...(port ? { PORT: port } : {}), + }; + const exitCode = await runAppCommand("dev", directory, environment); + return { exitCode: exitCode === 0 ? 0 : EXIT_GENERIC }; + } finally { + await proxy.stop(); + } }, }); -async function previewAppDirectory( - directory: string, - port: string | undefined, - execution: ExecutionContext, -): Promise { - requireAppScripts(directory, ["dev"]); - await checkAppScope(directory, execution.profile); - const installExitCode = await runAppCommand("install", directory); - if (installExitCode !== 0) return EXIT_GENERIC; - const proxy = startAppDevProxy(execution); - try { - const environment = { - ...proxy.environment, - ...(port ? { PORT: port } : {}), - }; - const exitCode = await runAppCommand("dev", directory, environment); - return exitCode === 0 ? 0 : EXIT_GENERIC; - } finally { - await proxy.stop(); - } -} - function appFile(file: unknown, dir: unknown): string { if (dir !== undefined) throw new CliError("Pass either --dir or --file."); if (typeof file !== "string" || file.length === 0) { diff --git a/cli/src/commands/app/lib/declared-app.test.ts b/cli/src/commands/app/lib/declared-app.test.ts deleted file mode 100644 index 92de07a5..00000000 --- a/cli/src/commands/app/lib/declared-app.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, test } from "bun:test"; -import { readDeclaredAppScope } from "@/commands/app/lib/declared-app.ts"; - -const factoryImport = 'import { defineDataApp as define } from "@altertable/data-app";'; - -function app(config: string): string { - return `${factoryImport}\nconst app = define(${config});`; -} - -test("reads scope through the published extractor, including import aliases and TSX", () => { - expect( - readDeclaredAppScope(`${app(`{ - scope: { organization: "acme", environment: "production" }, - queries: { counts: { statement: \`SELECT '{' AS brace\`, params: {} } }, - } as const`)} - function View() { return

Don't panic

; }`), - ).toEqual({ organization: "acme", environment: "production" }); -}); - -test("validates scope shape after extraction", () => { - for (const config of [ - "{}", - "{ scope: null }", - "{ scope: [] }", - '{ scope: { organization: 1, environment: "production" } }', - '{ scope: { organization: "acme" } }', - '{ scope: { organization: "acme", environment: " " } }', - ]) { - expect(() => readDeclaredAppScope(app(config))).toThrow("scope.organization"); - } -}); - -test("reports extractor diagnostics for invalid declarations without executing source", () => { - for (const source of [ - "export const app = 1;", - "function defineDataApp(value) { return value; } defineDataApp({ scope: {} });", - app("config"), - app("{ scope }"), - app('{ scope: { organization: "acme" + suffix, environment: "production" } }'), - app("{ scope: {"), - ]) { - expect(() => readDeclaredAppScope(source)).toThrow("Could not extract the data app manifest."); - } - expect( - readDeclaredAppScope( - `throw new Error("must not execute");\n${app('{ scope: { organization: "acme", environment: "production" } }')}`, - ), - ).toEqual({ organization: "acme", environment: "production" }); - expect(() => readDeclaredAppScope(`${factoryImport}\nconst app = define(config);`)).toThrow("2:"); -}); diff --git a/cli/src/commands/app/lib/declared-app.ts b/cli/src/commands/app/lib/declared-app.ts deleted file mode 100644 index 85015e67..00000000 --- a/cli/src/commands/app/lib/declared-app.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { extractDataAppManifest } from "@altertable/data-app-manifest"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { isRecord } from "@/lib/object.ts"; - -export type DeclaredAppScope = { organization: string; environment: string }; - -/** Read the authored scope without executing application code. */ -export function readDeclaredAppScope(source: string): DeclaredAppScope { - const { manifest, diagnostics } = extractDataAppManifest(source); - if (!manifest) { - const details = diagnostics - .slice(0, 3) - .map(({ message, start }) => `${start.line}:${start.column}: ${message}`) - .join("\n"); - throw new ConfigurationError(`Could not extract the data app manifest.\n${details}`); - } - const scope = manifest.scope; - if ( - !isRecord(scope) || - typeof scope.organization !== "string" || - !scope.organization.trim() || - typeof scope.environment !== "string" || - !scope.environment.trim() - ) { - throw new ConfigurationError( - "The data app file must declare scope.organization and scope.environment as string literals.", - ); - } - return { organization: scope.organization, environment: scope.environment }; -} diff --git a/cli/src/commands/app/lib/file-dev.test.ts b/cli/src/commands/app/lib/file-dev.test.ts index 0fbbc4d0..d709ec40 100644 --- a/cli/src/commands/app/lib/file-dev.test.ts +++ b/cli/src/commands/app/lib/file-dev.test.ts @@ -1,299 +1,64 @@ -import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { rejects } from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, beforeEach, expect, test } from "bun:test"; -import { - fileDevRuntimeDirectory, - prepareFileDevRuntime, - previewAppFile, - writeFileDevRuntime, -} from "@/commands/app/lib/file-dev.ts"; -import { recommendedDataAppVersion } from "@/commands/app/lib/package.ts"; -import { dataAppDirectory } from "@/commands/app/lib/distribution.ts"; -import { startAppDevProxy } from "@/commands/app/lib/dev-proxy.ts"; -import { EXIT_GENERIC } from "@/lib/errors.ts"; -import type { ExecutionContext } from "@/lib/execution-context.ts"; +import { prepareFilePreview } from "@/commands/app/lib/file-dev.ts"; +import { dataAppPayload } from "@/commands/app/lib/payload.ts"; import { FROM_ENV_PSEUDOPROFILE_NAME } from "@/lib/profile-store.ts"; +import { createLakehouseTestWorkspace } from "@/test-utils/lakehouse.ts"; -type ProcessCall = { args: readonly string[]; directory: string; env?: Record }; - -const directories: string[] = []; -const processes: Bun.Subprocess[] = []; -const savedEnvironment: Record = {}; - +let workspace: ReturnType; +let savedEnvironment: Record; beforeEach(() => { - for (const key of ["ALTERTABLE_CONFIG_HOME", "ALTERTABLE_ENV"]) { - savedEnvironment[key] = process.env[key]; - } + savedEnvironment = { + ALTERTABLE_CONFIG_HOME: process.env.ALTERTABLE_CONFIG_HOME, + ALTERTABLE_ENV: process.env.ALTERTABLE_ENV, + }; + workspace = createLakehouseTestWorkspace("file-dev"); + process.env.ALTERTABLE_CONFIG_HOME = workspace.home; + process.env.ALTERTABLE_ENV = "production"; }); - -afterEach(async () => { +afterEach(() => { + workspace.cleanup(); for (const [key, value] of Object.entries(savedEnvironment)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } - for (const child of processes.splice(0)) { - child.kill(); - await child.exited; - } - await Promise.all( - directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), - ); }); -async function expectFailure(run: () => Promise, message: string): Promise { - let failure: unknown; - try { - await run(); - } catch (error) { - failure = error; - } - expect(failure).toMatchObject({ message: expect.stringContaining(message) }); -} - -async function tempDir(): Promise { - const directory = await mkdtemp(join(tmpdir(), "file-dev-")); - directories.push(directory); - return directory; +function app(scope: string): string { + return `import { defineDataApp as define } from "@altertable/data-app"; +const app = define({ scope: ${scope}, queries: {} });`; } -function recordProcesses(exitCodes: number[] = []) { - const calls: ProcessCall[] = []; - async function run( - args: readonly string[], - directory: string, - env?: Record, - ): Promise { - calls.push({ args, directory, env }); - return exitCodes[calls.length - 1] ?? 0; - } - return { calls, run }; +async function prepare(source: string): Promise { + const file = workspace.writeFile("index.tsx", source); + return prepareFilePreview(file, FROM_ENV_PSEUDOPROFILE_NAME); } -test("writes and installs the pinned preview runtime", async () => { - const directory = await tempDir(); - const { calls, run } = recordProcesses(); - - await prepareFileDevRuntime(directory, run); - expect(calls).toEqual([ - { args: ["install", "--frozen-lockfile", "--production"], directory, env: undefined }, - ]); - const manifest = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); - expect(manifest.dependencies["@altertable/data-app"]).toBe(recommendedDataAppVersion()); - expect(manifest.name).toBe("altertable-file-dev"); - expect(await readFile(join(directory, "server.ts"), "utf8")).toContain("localLakehouse"); - - await expectFailure( - () => prepareFileDevRuntime(directory, recordProcesses([1]).run), - "Could not install", +test("prepares a reusable dev project from the released manifest without executing the app", async () => { + const directory = await prepare( + `${app('{ organization: "acme", environment: "production" }')} +throw new Error("must not execute");`, ); -}); - -test("previews a file through the loopback proxy without profile credentials", async () => { - const home = await tempDir(); - process.env.ALTERTABLE_CONFIG_HOME = home; - process.env.ALTERTABLE_ENV = "production"; - const file = join(home, "index.tsx"); - await writeFile(file, scopedApp("production")); - const execution = { profile: FROM_ENV_PSEUDOPROFILE_NAME } as ExecutionContext; - const { calls, run } = recordProcesses([0, 3]); - - expect(await previewAppFile(file, "3022", execution, run)).toBe(EXIT_GENERIC); - const runtime = fileDevRuntimeDirectory(); - expect(calls.map((call) => [call.args, call.directory])).toEqual([ - [["install", "--frozen-lockfile", "--production"], runtime], - [["server.ts"], runtime], - ]); - const env = calls[1]!.env!; - expect(Object.keys(env).sort()).toEqual([ - "ALTERTABLE_APP_FILE", - "ALTERTABLE_DATA_PROXY_TOKEN", - "ALTERTABLE_DATA_PROXY_URL", - "PORT", - ]); - expect(env.ALTERTABLE_APP_FILE).toBe(file); - expect(env.PORT).toBe("3022"); - expect(env.ALTERTABLE_DATA_PROXY_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); - expect(env.ALTERTABLE_DATA_PROXY_TOKEN).toMatch(/^[a-f0-9]{64}$/); -}); - -test("refuses a file whose scope does not match the profile before installing", async () => { - const home = await tempDir(); - process.env.ALTERTABLE_CONFIG_HOME = home; - process.env.ALTERTABLE_ENV = "production"; - const file = join(home, "index.tsx"); - await writeFile(file, scopedApp("staging")); - const { calls, run } = recordProcesses(); - - await expectFailure( - () => - previewAppFile( - file, - "3022", - { profile: FROM_ENV_PSEUDOPROFILE_NAME } as ExecutionContext, - run, - ), - "data app file scope", + const manifest = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); + const starter = JSON.parse(dataAppPayload.starter["package.json"]!); + expect(manifest.dependencies).toEqual(starter.dependencies); + expect(manifest.scripts).toEqual({ dev: "bun server.ts" }); + expect(await readFile(join(directory, "bun.lock"), "utf8")).toBe( + dataAppPayload.starter["bun.lock"]!, ); - await expectFailure( - () => - previewAppFile( - home, - "3022", - { profile: FROM_ENV_PSEUDOPROFILE_NAME } as ExecutionContext, - run, - ), - "Could not read source", + expect(await readFile(join(directory, "server.ts"), "utf8")).toContain( + "@altertable/data-app-bun/preview", ); - expect(calls).toEqual([]); + expect(await prepare(app('{ organization: "acme", environment: "production" }'))).toBe(directory); }); -test("serves a single file and forwards same-origin SQL from the preview server", async () => { - const root = await tempDir(); - const runtime = join(root, "runtime"); - const appFile = join(root, "index.tsx"); - await writeFileDevRuntime(runtime); - await symlink(join(dataAppDirectory, "starter/node_modules"), join(runtime, "node_modules")); - const queries: unknown[] = []; - const proxy = startAppDevProxy({} as ExecutionContext, async (request) => { - queries.push(JSON.parse(request.body as string)); - return new Response('{"query_id":"q1"}\n[{"name":"value","type":"INTEGER"}]\n[1]\n').body!; - }); - await writeFile(appFile, appSource("Preview")); - - const stderr: string[] = []; - const child = Bun.spawn([process.execPath, "server.ts"], { - cwd: runtime, - env: { - ...process.env, - ALTERTABLE_APP_FILE: appFile, - ...proxy.environment, - PORT: "0", - }, - stdout: "pipe", - stderr: "pipe", - }); - processes.push(child); - void child.exited.finally(() => proxy.stop()); - drain(child.stderr, stderr); - const url = await readServerUrl(child, stderr); - const origin = url.slice(0, -1); - function query(body: string, headers: Record = { origin }) { - return fetch(`${url}api/sql`, { method: "POST", headers, body }); +test("rejects invalid declarations and mismatched scopes before creating the runtime", async () => { + await rejects(prepare("export const app = 1;"), /Could not extract the data app manifest\.\n1:/); + for (const scope of ["null", "{}", '{ organization: "acme", environment: "staging" }']) { + await rejects(prepare(app(scope)), /data app file scope does not match/); } - - const page = await fetch(url); - expect(page.status).toBe(200); - expect(page.headers.get("content-type")).toContain("text/html"); - - const bundle = await (await fetch(`${url}bundle.js`)).text(); - expect(bundle).toContain("Preview"); - expect(bundle).not.toContain(proxy.environment.ALTERTABLE_DATA_PROXY_TOKEN); - - const accepted = await query( - JSON.stringify({ statement: "select 1", limit: 1, params: { groupName: "Alpha" } }), - { origin, "content-type": "application/json", "sec-fetch-site": "same-origin" }, - ); - expect(accepted.status).toBe(200); - expect(await accepted.json()).toEqual({ - columns: [{ name: "value", type: "INTEGER" }], - rows: [[1]], - queryId: "q1", - }); - - expect(queries).toEqual([ - expect.objectContaining({ - statement: "select 1", - params: { groupName: "Alpha" }, - }), - ]); - expect((await query("{")).status).toBe(400); - expect((await query(JSON.stringify({ statement: "select 1", limit: 0 }))).status).toBe(400); - expect((await fetch(`${url}missing`)).status).toBe(404); - - const valid = JSON.stringify({ statement: "select 1", limit: 1 }); - expect((await query(valid, {})).status).toBe(403); - expect((await query(valid, { origin: "https://example.com" })).status).toBe(403); - expect((await query(valid, { origin, "sec-fetch-site": "cross-site" })).status).toBe(403); - const rebound = await fetch(`${url}bundle.js`, { headers: { host: "attacker.example" } }); - expect(rebound.status).toBe(403); - - await writeFile(appFile, appSource("Updated")); - await Bun.sleep(500); - expect(await (await fetch(`${url}bundle.js`)).text()).toContain("Updated"); - - const reload = await fetch(`${url}reload`); - const reader = reload.body?.getReader(); - expect(reader).toBeDefined(); - expect(await readUntil(reader!, "event: ready")).toContain("event: ready"); - await writeFile(appFile, appSource("Reloaded")); - const events = await readUntil(reader!, "data:"); - expect(events).toContain("data:"); - expect(await (await fetch(`${url}bundle.js`)).text()).toContain("Reloaded"); - await reader?.cancel(); -}, 15_000); - -function scopedApp(environment: string): string { - return `import { defineDataApp } from "@altertable/data-app"; -const app = defineDataApp({ scope: { organization: "acme", environment: "${environment}" } }); -`; -} - -function appSource(title: string): string { - return `import { defineDataApp } from "@altertable/data-app"; -const app = defineDataApp({ title: ${JSON.stringify(title)} }); -export function View() { - return

{app.title}

; -} -`; -} - -function drain(stream: ReadableStream, chunks: string[]): void { - const reader = stream.getReader(); - const decoder = new TextDecoder(); - void (async () => { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) chunks.push(decoder.decode(value)); - } - })(); -} - -async function readServerUrl(child: Bun.Subprocess, stderr: string[]): Promise { - const stdout = child.stdout; - if (!(stdout instanceof ReadableStream)) throw new Error("Missing preview server output."); - const reader = stdout.getReader(); - const decoder = new TextDecoder(); - let text = ""; - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - const next = await Promise.race([ - reader.read(), - child.exited.then(() => ({ done: true as const, value: undefined })), - ]); - if (next.done) break; - text += decoder.decode(next.value); - const match = text.match(/http:\/\/127\.0\.0\.1:\d+\//); - if (match) return match[0]; - } - throw new Error(`Preview server did not start.\n${text}\n${stderr.join("")}`); -} - -async function readUntil( - reader: { read(): Promise<{ done: boolean; value?: Uint8Array }> }, - needle: string, -): Promise { - const decoder = new TextDecoder(); - let text = ""; - const deadline = Date.now() + 2_000; - while (Date.now() < deadline && !text.includes(needle)) { - const next = await Promise.race([ - reader.read(), - Bun.sleep(200).then(() => ({ done: false as const, value: undefined })), - ]); - if (next.done) break; - if (next.value) text += decoder.decode(next.value); - } - return text; -} + expect(existsSync(join(workspace.home, "data-app-dev"))).toBe(false); +}); diff --git a/cli/src/commands/app/lib/file-dev.ts b/cli/src/commands/app/lib/file-dev.ts index b941745f..56612bb3 100644 --- a/cli/src/commands/app/lib/file-dev.ts +++ b/cli/src/commands/app/lib/file-dev.ts @@ -1,77 +1,40 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { extractDataAppManifest } from "@altertable/data-app-manifest"; import serverScript from "@/commands/app/lib/file-dev-server.ts.txt"; -import { readDeclaredAppScope } from "@/commands/app/lib/declared-app.ts"; -import { startAppDevProxy } from "@/commands/app/lib/dev-proxy.ts"; -import { createAppFiles } from "@/commands/app/lib/distribution.ts"; import { recommendedDataAppVersion } from "@/commands/app/lib/package.ts"; import { dataAppPayload } from "@/commands/app/lib/payload.ts"; import { readRequiredFile } from "@/commands/app/lib/remote.ts"; -import { runAppProcess } from "@/commands/app/lib/run.ts"; import { assertAppScope, profileAppScope } from "@/commands/app/lib/scope.ts"; import { configDir } from "@/lib/config-files.ts"; -import { CliError, EXIT_GENERIC } from "@/lib/errors.ts"; -import type { ExecutionContext } from "@/lib/execution-context.ts"; +import { ConfigurationError } from "@/lib/errors.ts"; -export const fileDevPort = "25837"; - -export function fileDevRuntimeDirectory(): string { - return join(configDir(), "data-app-dev", recommendedDataAppVersion()); -} - -export async function writeFileDevRuntime(directory: string): Promise { - await mkdir(directory, { recursive: true }); - await Promise.all( - Object.entries(runtimeFiles()).map(([name, content]) => - writeFile(join(directory, name), content), - ), - ); -} - -/** The frozen lockfile pins the package version; an installed runtime makes this an offline no-op. */ -export async function prepareFileDevRuntime( - directory: string, - run: typeof runAppProcess = runAppProcess, -): Promise { - await writeFileDevRuntime(directory); - if ((await run(["install", "--frozen-lockfile", "--production"], directory)) !== 0) { - throw new CliError("Could not install the data app package used to preview a single file."); - } -} - -export async function previewAppFile( - file: string, - port: string, - execution: ExecutionContext, - run: typeof runAppProcess = runAppProcess, -): Promise { +/** Prepare a regular dev project backed by the starter's pinned runtime. */ +export async function prepareFilePreview(file: string, profile: string): Promise { const source = await readRequiredFile(file, "source"); - assertAppScope(readDeclaredAppScope(source), profileAppScope(execution.profile), "data app file"); - const directory = fileDevRuntimeDirectory(); - await prepareFileDevRuntime(directory, run); - const proxy = startAppDevProxy(execution); - try { - const exitCode = await run(["server.ts"], directory, { - ...proxy.environment, - ALTERTABLE_APP_FILE: file, - PORT: port, - }); - return exitCode === 0 ? 0 : EXIT_GENERIC; - } finally { - await proxy.stop(); + const { manifest, diagnostics } = extractDataAppManifest(source); + if (!manifest) { + const details = diagnostics + .slice(0, 3) + .map(({ message, start }) => `${start.line}:${start.column}: ${message}`) + .join("\n"); + throw new ConfigurationError(`Could not extract the data app manifest.\n${details}`); } -} + assertAppScope(manifest.scope, profileAppScope(profile), "data app file"); -function runtimeFiles(): Record { - const starter = createAppFiles(dataAppPayload, { - name: "altertable-file-dev", - title: "File dev", - scope: { organization: "local", environment: "local" }, - }); - return { - "package.json": starter["package.json"]!, + const directory = join(configDir(), "data-app-dev", recommendedDataAppVersion()); + const starter = dataAppPayload.starter; + const packageJson = JSON.parse(starter["package.json"]!); + packageJson.scripts = { dev: "bun server.ts" }; + await mkdir(directory, { recursive: true }); + const files = { + "package.json": `${JSON.stringify(packageJson, null, 2)}\n`, "bun.lock": starter["bun.lock"]!, "bunfig.toml": starter["bunfig.toml"]!, "server.ts": serverScript, }; + await Promise.all( + Object.entries(files).map(([name, content]) => writeFile(join(directory, name), content)), + ); + return directory; } diff --git a/cli/src/commands/app/lib/run.ts b/cli/src/commands/app/lib/run.ts index 5226cfc5..95993505 100644 --- a/cli/src/commands/app/lib/run.ts +++ b/cli/src/commands/app/lib/run.ts @@ -47,21 +47,6 @@ export async function runAppCommand( directory: string, appEnvironment?: Record, signal?: AbortSignal, -): Promise { - const arguments_ = - command === "install" - ? ["install"] - : command === "install-frozen" - ? ["install", "--frozen-lockfile"] - : ["run", command]; - return runAppProcess(arguments_, directory, appEnvironment, signal); -} - -export async function runAppProcess( - args: readonly string[], - directory: string, - appEnvironment?: Record, - signal?: AbortSignal, ): Promise { const env = copyProcessEnv(); for (const key of Object.keys(env)) { @@ -72,7 +57,13 @@ export async function runAppProcess( // command and supports `bun` calls inside the project's scripts. env.BUN_BE_BUN = "1"; - const child = Bun.spawn([process.execPath, ...args], { + const arguments_ = + command === "install" + ? ["install"] + : command === "install-frozen" + ? ["install", "--frozen-lockfile"] + : ["run", command]; + const child = Bun.spawn([process.execPath, ...arguments_], { cwd: directory, env, stdin: "inherit", diff --git a/cli/src/commands/app/lib/scope.ts b/cli/src/commands/app/lib/scope.ts index 2e2d7da7..9e3fa154 100644 --- a/cli/src/commands/app/lib/scope.ts +++ b/cli/src/commands/app/lib/scope.ts @@ -5,15 +5,9 @@ import { readEnv } from "@/lib/env.ts"; import { isFromEnvProfile } from "@/lib/profile-store.ts"; import { ConfigurationError } from "@/lib/errors.ts"; -export type ProfileAppScope = { - organization?: string; - organizationName?: string; - environment?: string; -}; - export function assertAppScope( scope: unknown, - profile: ProfileAppScope, + profile: { organization?: string; organizationName?: string; environment?: string }, source = "app.json", ): void { const value = scope as { organization?: unknown; environment?: unknown } | null; @@ -33,7 +27,7 @@ export function assertAppScope( } } -export function profileAppScope(profile: string): ProfileAppScope { +export function profileAppScope(profile: string) { return { organization: configGet("organization_slug", profile).trim(), organizationName: configGet("organization_name", profile).trim(), From 3ff1d7693dd40ff99e2b8da2ebfd929df54601c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 20 Oct 2026 11:18:22 +0200 Subject: [PATCH 4/4] fix(app): load manifest tooling only for file previews The manifest package imports the TypeScript compiler. Eagerly loading it delayed every CLI invocation, pushing the multi-command lakehouse integration flow past its five-second CI deadline. Import the tooling inside file-preview preparation instead. Local integration time drops from 2.44s to 1.07s without raising the timeout; the full integration gate, built preview smoke, and Bun 1.1 compatibility smoke pass. --- cli/src/commands/app/lib/file-dev.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/app/lib/file-dev.ts b/cli/src/commands/app/lib/file-dev.ts index 56612bb3..d6d71e63 100644 --- a/cli/src/commands/app/lib/file-dev.ts +++ b/cli/src/commands/app/lib/file-dev.ts @@ -1,6 +1,5 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { extractDataAppManifest } from "@altertable/data-app-manifest"; import serverScript from "@/commands/app/lib/file-dev-server.ts.txt"; import { recommendedDataAppVersion } from "@/commands/app/lib/package.ts"; import { dataAppPayload } from "@/commands/app/lib/payload.ts"; @@ -12,6 +11,8 @@ import { ConfigurationError } from "@/lib/errors.ts"; /** Prepare a regular dev project backed by the starter's pinned runtime. */ export async function prepareFilePreview(file: string, profile: string): Promise { const source = await readRequiredFile(file, "source"); + // Keep the TypeScript compiler off the startup path of other CLI commands. + const { extractDataAppManifest } = await import("@altertable/data-app-manifest"); const { manifest, diagnostics } = extractDataAppManifest(source); if (!manifest) { const details = diagnostics