diff --git a/.changeset/11403-console-sdui-manifest.md b/.changeset/11403-console-sdui-manifest.md new file mode 100644 index 0000000000..7a01a792aa --- /dev/null +++ b/.changeset/11403-console-sdui-manifest.md @@ -0,0 +1,11 @@ +--- +'@object-ui/console': minor +--- + +The console build now writes `dist/sdui.manifest.json`, the SDUI component manifest of the Console it built (objectui#11403). + +A host serving this Console registers that file under `@objectstack/metadata-protocol`'s `SDUI_MANIFEST_SERVICE`, and its metadata save door judges page sources against it (ADR-0080 §5). Until now the build wrote no such file, so a host serving `dist` as-is had no manifest of its own: it printed one boot line and judged no page save. + +`pnpm build` runs a new `build:sdui-manifest` step after `vite build`. It loads the console's full registry eagerly under Node (`dev/manifest-registry.ts`, the same module the `dev/manifest-dump.html` page loads) and calls the existing generator, `buildArtifacts` in `packages/sdui-parser/scripts/gen-manifest.ts`. The shipped file is that generator's `sdui.manifest.json`, byte for byte, for the console's own registry. The published `dist` gains this one file only: the generator's other two outputs (`sdui-intrinsics.d.ts`, `sdui-blocks.md`) are written to a scratch directory and discarded. + +The step reads the workspace packages' built `dist`, which the console's `tsc` step already needs and which turbo builds first. Callers that run `vite build` directly (CI's E2E builds, `vercel.json`) do not run it. diff --git a/apps/console/README.md b/apps/console/README.md index 15fc23c727..a605aacd09 100644 --- a/apps/console/README.md +++ b/apps/console/README.md @@ -31,6 +31,14 @@ pnpm build pnpm test ``` +`pnpm build` also writes `dist/sdui.manifest.json`, the SDUI component manifest +of the registry this build ships. A host serving `dist` registers it as its +deployment manifest, and the metadata save door judges page sources against it +(ADR-0080 §5). The step (`pnpm build:sdui-manifest`) runs after `vite build`, +calls `buildArtifacts` from `packages/sdui-parser/scripts/gen-manifest.ts` over +the registry `dev/manifest-registry.ts` loads, and needs the workspace packages +built first (turbo's `^build` does that). + The console is a **pure SPA** and requires an external ObjectStack backend. `VITE_SERVER_URL` in `apps/console/.env.development` ships empty (same-origin); the Vite dev server proxies `/api/*` to `http://localhost:3000` by default, or diff --git a/apps/console/dev/manifest-dump.tsx b/apps/console/dev/manifest-dump.tsx index 757713158f..90a8c4fe8c 100644 --- a/apps/console/dev/manifest-dump.tsx +++ b/apps/console/dev/manifest-dump.tsx @@ -1,36 +1,11 @@ /* ADR-0080: headless dump of the public-tier component manifest. * Registers everything the console does, then serializes getPublicConfigs(). * - * Two lists have to agree here, and nothing used to check them: - * - `src/register-plugins.ts` — what the console actually ships, most of it - * registered LAZILY (the chunk loads on first use). - * - the eager imports below — what this dump loads so the manifest carries - * each block's real `inputs`. - * - * Importing the former means a plugin the console lazy-registers but this file - * forgets to import eagerly survives as a `lazy: true` stub and - * `assertFullyLoaded` fails the build — instead of that block landing in the - * manifest with no props, which would make every prop an author writes on it - * an `unknown-prop` diagnostic downstream. Eager registration wins over a stub, - * so the manifest itself is unchanged; this only makes the drift detectable. */ -import '../src/register-plugins'; - -import '@object-ui/components'; -import '@object-ui/plugin-grid'; -import '@object-ui/plugin-form'; -import '@object-ui/plugin-view'; -import '@object-ui/plugin-list'; -import '@object-ui/plugin-detail'; -import '@object-ui/plugin-dashboard'; -import '@object-ui/plugin-charts'; -import '@object-ui/plugin-kanban'; -import '@object-ui/plugin-calendar'; -import '@object-ui/plugin-gantt'; -import '@object-ui/plugin-timeline'; -import '@object-ui/plugin-map'; -import '@object-ui/plugin-markdown'; -import '@object-ui/plugin-report'; -import '@object-ui/plugin-tree'; + * The registration graph — `src/register-plugins.ts` plus every plugin loaded + * eagerly, and why the two lists must agree — is `./manifest-registry.ts`. The + * console build reads the same module when it writes `dist/sdui.manifest.json` + * (objectui#11403), so this page and the shipped manifest load one list. */ +import './manifest-registry'; import { ComponentRegistry } from '@object-ui/core'; import { assertFullyLoaded, manifestFromConfigs } from '@object-ui/sdui-parser'; diff --git a/apps/console/dev/manifest-registry.ts b/apps/console/dev/manifest-registry.ts new file mode 100644 index 0000000000..7369e1ae57 --- /dev/null +++ b/apps/console/dev/manifest-registry.ts @@ -0,0 +1,38 @@ +/* ADR-0080: the registration graph every SDUI manifest this app emits is read + * from. Importing this module registers everything the console does, EAGERLY, + * which is the PREREQUISITE `packages/sdui-parser/scripts/gen-manifest.ts` + * states. It has two importers, so they read one list: + * - `manifest-dump.tsx` — the browser dump page (`dev/manifest-dump.html`); + * - `../scripts/build-sdui-manifest.ts` — the console build step that ships + * `dist/sdui.manifest.json` (objectui#11403), under Node. + * + * Two lists have to agree here, and nothing used to check them: + * - `src/register-plugins.ts` — what the console actually ships, most of it + * registered LAZILY (the chunk loads on first use). + * - the eager imports below — what this graph loads so the manifest carries + * each block's real `inputs`. + * + * Importing the former means a plugin the console lazy-registers but this file + * forgets to import eagerly survives as a `lazy: true` stub and + * `assertFullyLoaded` fails the build — instead of that block landing in the + * manifest with no props, which would make every prop an author writes on it + * an `unknown-prop` diagnostic downstream. Eager registration wins over a stub, + * so the manifest itself is unchanged; this only makes the drift detectable. */ +import '../src/register-plugins'; + +import '@object-ui/components'; +import '@object-ui/plugin-grid'; +import '@object-ui/plugin-form'; +import '@object-ui/plugin-view'; +import '@object-ui/plugin-list'; +import '@object-ui/plugin-detail'; +import '@object-ui/plugin-dashboard'; +import '@object-ui/plugin-charts'; +import '@object-ui/plugin-kanban'; +import '@object-ui/plugin-calendar'; +import '@object-ui/plugin-gantt'; +import '@object-ui/plugin-timeline'; +import '@object-ui/plugin-map'; +import '@object-ui/plugin-markdown'; +import '@object-ui/plugin-report'; +import '@object-ui/plugin-tree'; diff --git a/apps/console/package.json b/apps/console/package.json index ab23b4d2e2..29c5f5238c 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -44,7 +44,8 @@ "scripts": { "dev": "vite", "build:plugin": "tsc -p tsconfig.plugin.json", - "build": "tsc && vite build && pnpm build:plugin", + "build:sdui-manifest": "tsx scripts/build-sdui-manifest.ts", + "build": "tsc && vite build && pnpm build:sdui-manifest && pnpm build:plugin", "type-check": "tsc --noEmit && tsc -b tsconfig.node.json --force", "lint": "eslint .", "build:analyze": "pnpm build && echo 'Bundle analysis available at dist/stats.html'", diff --git a/apps/console/scripts/build-sdui-manifest.ts b/apps/console/scripts/build-sdui-manifest.ts new file mode 100644 index 0000000000..4128bd21a7 --- /dev/null +++ b/apps/console/scripts/build-sdui-manifest.ts @@ -0,0 +1,49 @@ +/** + * Console build step (objectui#11403): ship `dist/sdui.manifest.json`, the SDUI + * component manifest of the Console this build produced. + * + * pnpm --filter @object-ui/console build # runs this after `vite build` + * + * A host serving this Console registers that file under + * `@objectstack/metadata-protocol`'s `SDUI_MANIFEST_SERVICE`, and its metadata + * save door judges page sources against it (ADR-0080 §5). So it has to describe + * THIS build's registry — not the framework's tracked repo-root copy, which + * describes objectui at the framework's pin. + * + * Runs under Node through tsx and resolves every workspace package through its + * `exports`, i.e. its built `dist` — the same built packages this package's + * `tsc` step already type-checks against, and that turbo's `^build` (and every + * CI and release caller of this script) builds from the same tree first. That + * is why the step lives in the `build` script rather than in `vite.config.ts`: + * the callers that run `vite build` directly (CI's E2E builds, `vercel.json`) + * need no built packages, because the bundle aliases each one to its `src`, + * and they never run this step. + * + * Plain Node has no loader for a `.css` side-effect import (plugin-dashboard, + * plugin-map); each resolves to an empty module, the one accommodation needed. + * The hook is registered before the registry is imported, which is why the + * imports below are dynamic. + */ +import { existsSync } from 'node:fs'; +import { register } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CSS_AS_EMPTY_MODULE = + 'export async function resolve(specifier, context, next) {\n' + + ' if (specifier.endsWith(".css")) return { url: "data:text/javascript,", shortCircuit: true };\n' + + ' return next(specifier, context);\n' + + '}\n'; + +const distDir = join(resolve(dirname(fileURLToPath(import.meta.url)), '..'), 'dist'); +if (!existsSync(join(distDir, 'index.html'))) { + console.error(`✗ build-sdui-manifest: ${distDir} holds no index.html — run \`vite build\` first.`); + process.exit(1); +} + +register(`data:text/javascript,${encodeURIComponent(CSS_AS_EMPTY_MODULE)}`); +await import('../dev/manifest-registry.ts'); +const { emitSduiManifest } = await import('./emit-sdui-manifest.ts'); + +const written = emitSduiManifest(distDir); +process.stdout.write(`✓ build-sdui-manifest: wrote ${written}\n`); diff --git a/apps/console/scripts/emit-sdui-manifest.ts b/apps/console/scripts/emit-sdui-manifest.ts new file mode 100644 index 0000000000..44b98ceaf2 --- /dev/null +++ b/apps/console/scripts/emit-sdui-manifest.ts @@ -0,0 +1,34 @@ +/** + * Write the console's SDUI component manifest into a build output directory as + * `sdui.manifest.json` (objectui#11403). + * + * The one generator is `packages/sdui-parser/scripts/gen-manifest.ts`: this + * calls its `buildArtifacts` and serializes nothing itself, so the shipped file + * is byte for byte what that generator writes for the registry loaded in this + * process. The caller registers that registry first — the generator's + * PREREQUISITE — by importing `../dev/manifest-registry.ts`. + * + * `buildArtifacts` writes two more files, `sdui-intrinsics.d.ts` and + * `sdui-blocks.md`. Neither is asked of the Console's `dist` — objectui#11403 + * names the manifest alone — and `dist` is published, so the generator writes + * into a scratch directory and only the manifest is copied out. + */ +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildArtifacts } from '../../../packages/sdui-parser/scripts/gen-manifest.ts'; + +export const SDUI_MANIFEST_FILE = 'sdui.manifest.json'; + +/** Returns the path written. */ +export function emitSduiManifest(outDir: string): string { + const scratch = mkdtempSync(join(tmpdir(), 'console-sdui-manifest-')); + try { + buildArtifacts(scratch); + const target = join(outDir, SDUI_MANIFEST_FILE); + copyFileSync(join(scratch, SDUI_MANIFEST_FILE), target); + return target; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} diff --git a/apps/console/src/__tests__/sdui-manifest-build-11403.test.ts b/apps/console/src/__tests__/sdui-manifest-build-11403.test.ts new file mode 100644 index 0000000000..667d52ea1f --- /dev/null +++ b/apps/console/src/__tests__/sdui-manifest-build-11403.test.ts @@ -0,0 +1,98 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11403 — the console build ships `dist/sdui.manifest.json`, and it is + * the one generator's output for the console's own registry. + * + * A host serving this Console registers that file under + * `@objectstack/metadata-protocol`'s `SDUI_MANIFEST_SERVICE`; with no file it + * prints one boot line and judges no page save. Four facts: + * + * 1. WIRED — the package's `build` script runs the manifest step after + * `vite build` (which empties `dist`), so the step's output survives. + * 2. ONE FILE — the step writes `sdui.manifest.json` and nothing else into + * the published `dist`, while `buildArtifacts` itself writes three files + * (the control that makes "nothing else" a reading). + * 3. ONE GENERATOR — its bytes are the bytes `buildArtifacts` in + * `packages/sdui-parser/scripts/gen-manifest.ts` writes for the same + * registry. + * 4. THE SHAPE THE HOST READS — `isUsableSduiManifest` accepts a value only + * when it and its `components` are each a non-array object; the record is + * populated, including a block the console registers only lazily. + * + * The registry is the module the step itself imports + * (`dev/manifest-registry.ts`), RUN here rather than mirrored. + */ + +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; +// Module scope, not a hook: the whole registration graph loads at import time. +import '../../dev/manifest-registry'; +import { buildArtifacts } from '../../../../packages/sdui-parser/scripts/gen-manifest'; +import { SDUI_MANIFEST_FILE, emitSduiManifest } from '../../scripts/emit-sdui-manifest'; + +const CONSOLE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +const scratchDirs: string[] = []; +function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'sdui-manifest-11403-')); + scratchDirs.push(dir); + return dir; +} +afterAll(() => { + for (const dir of scratchDirs) rmSync(dir, { recursive: true, force: true }); +}); + +/** The predicate `isUsableSduiManifest` applies (`isRec`), restated: the package is not installed here. */ +const isRec = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); + +const emitted = scratch(); +emitSduiManifest(emitted); +const generator = scratch(); +buildArtifacts(generator); + +describe('the console build ships dist/sdui.manifest.json (objectui#11403)', () => { + it('runs the manifest step after `vite build` in the package build script', () => { + const pkg = JSON.parse(readFileSync(join(CONSOLE_ROOT, 'package.json'), 'utf8')) as { + scripts: Record; + }; + const steps = pkg.scripts.build.split('&&').map((step) => step.trim()); + const viteBuild = steps.indexOf('vite build'); + const manifestStep = steps.indexOf('pnpm build:sdui-manifest'); + expect(viteBuild).toBeGreaterThanOrEqual(0); + expect(manifestStep).toBeGreaterThan(viteBuild); + expect(pkg.scripts['build:sdui-manifest']).toContain('scripts/build-sdui-manifest.ts'); + }); + + it('writes the manifest and nothing else, where the generator writes three files', () => { + expect(readdirSync(emitted)).toEqual([SDUI_MANIFEST_FILE]); + expect(readdirSync(generator).sort()).toEqual(['sdui-blocks.md', 'sdui-intrinsics.d.ts', SDUI_MANIFEST_FILE].sort()); + }); + + it('is byte for byte what buildArtifacts writes for the same registry', () => { + expect(readFileSync(join(emitted, SDUI_MANIFEST_FILE), 'utf8')).toBe( + readFileSync(join(generator, SDUI_MANIFEST_FILE), 'utf8'), + ); + }); + + it('carries the populated components record the host reads', () => { + const manifest: unknown = JSON.parse(readFileSync(join(emitted, SDUI_MANIFEST_FILE), 'utf8')); + expect(isRec(manifest)).toBe(true); + const components = isRec(manifest) ? manifest.components : undefined; + expect(isRec(components)).toBe(true); + if (!isRec(components)) return; + // `object-map` is registered only lazily in `src/register-plugins.ts`; its + // declared inputs reach the manifest only through the eager registry. + const map = components['object-map']; + expect(isRec(map) && Array.isArray(map.inputs) && map.inputs.length > 0).toBe(true); + }); +}); diff --git a/apps/console/src/register-plugins.ts b/apps/console/src/register-plugins.ts index 8a323f9f9d..c44e447e89 100644 --- a/apps/console/src/register-plugins.ts +++ b/apps/console/src/register-plugins.ts @@ -10,9 +10,11 @@ * only meaningful if it reads the REAL registration list rather than a * hand-copied one (objectui#2953 was a public block silently missing from * the contract; a duplicated list would have hidden it just as well). - * - `dev/manifest-dump.tsx` — imports this alongside its own eager plugin - * imports, so a plugin the console lazy-registers but the dump forgets to - * load eagerly shows up as a `lazy: true` stub and fails the build. + * - `dev/manifest-registry.ts` — imports this alongside its own eager plugin + * imports, so a plugin the console lazy-registers but that module forgets + * to load eagerly shows up as a `lazy: true` stub and fails the build. It + * is the registry both `dev/manifest-dump.tsx` and the console build's + * `dist/sdui.manifest.json` step read. * * Import order note: this module's own imports are hoisted, so the eager * plugins below register during import and the `registerLazy` calls run in this diff --git a/apps/console/tsconfig.json b/apps/console/tsconfig.json index 6cefd07275..d61c8cdcb4 100644 --- a/apps/console/tsconfig.json +++ b/apps/console/tsconfig.json @@ -33,6 +33,6 @@ // it declares globals, it suppresses no check. "types": ["vitest/globals", "@testing-library/jest-dom", "node"] }, - "include": ["src", "dev"], + "include": ["src", "dev", "scripts"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/content/docs/guide/deployment.md b/content/docs/guide/deployment.md index 8dfd9b81d3..901d75c88b 100644 --- a/content/docs/guide/deployment.md +++ b/content/docs/guide/deployment.md @@ -13,6 +13,8 @@ ObjectUI apps are standard Vite + React applications, so they can be deployed an - The build output lives in `apps/console/dist/` (or your app's `dist/` folder) - Environment variables configured for your target environment +`pnpm build` also writes `apps/console/dist/sdui.manifest.json`, the SDUI component manifest of the console it built. An ObjectStack host serving this `dist` registers that file under `@objectstack/metadata-protocol`'s `SDUI_MANIFEST_SERVICE`, and its metadata save door validates page sources against it. The console package's `build` script writes it after `vite build`, so a caller that runs `vite build` directly gets the bundle without the manifest. + ## Docker Create a multi-stage `Dockerfile` at the project root: diff --git a/scripts/__tests__/helpers/build-program.ts b/scripts/__tests__/helpers/build-program.ts index b74e7f74d2..dbda2f5bff 100644 --- a/scripts/__tests__/helpers/build-program.ts +++ b/scripts/__tests__/helpers/build-program.ts @@ -30,6 +30,7 @@ import { rel, repoRoot, type WorkspacePackage } from './turbo-inputs'; * tsup 4 packages the tsup config program + its tsconfig * next build 1 app the Next config program + its tsconfig * node