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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/11403-console-sdui-manifest.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions apps/console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 5 additions & 30 deletions apps/console/dev/manifest-dump.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
38 changes: 38 additions & 0 deletions apps/console/dev/manifest-registry.ts
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 2 additions & 1 deletion apps/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
49 changes: 49 additions & 0 deletions apps/console/scripts/build-sdui-manifest.ts
Original file line number Diff line number Diff line change
@@ -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`);
34 changes: 34 additions & 0 deletions apps/console/scripts/emit-sdui-manifest.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
98 changes: 98 additions & 0 deletions apps/console/src/__tests__/sdui-manifest-build-11403.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> => !!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<string, string>;
};
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);
});
});
8 changes: 5 additions & 3 deletions apps/console/src/register-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/console/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }]
}
2 changes: 2 additions & 0 deletions content/docs/guide/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions scripts/__tests__/helpers/build-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script> 2 packages the script and what it imports
* tsx <script> (as node) the script and what it imports
* pnpm <script> 1 app the same package's other script
*
* So a segment is classified by the tool it runs, and each tool contributes
Expand Down Expand Up @@ -276,10 +277,13 @@ function collectSegment(pkg: WorkspacePackage, command: string, acc: Accumulator
return;
}

if (tool === 'node') {
// `tsx SCRIPT` is node with a TypeScript loader: it reads the script and
// whatever the script imports, exactly as `node SCRIPT` does, so it takes the
// same branch rather than a copy of it (objectui#11403).
if (tool === 'node' || tool === 'tsx') {
const script = tokens.slice(1).find((token) => !token.startsWith('-'));
if (script === undefined) {
throw new Error(`${rel(pkg.dir)}: \`${command}\` runs node with no script to walk.`);
throw new Error(`${rel(pkg.dir)}: \`${command}\` runs ${tool} with no script to walk.`);
}
const resolved = path.resolve(pkg.dir, script);
if (!fs.existsSync(resolved)) {
Expand Down
Loading
Loading