diff --git a/.changeset/console-objectstack-spec-dist-hook.md b/.changeset/console-objectstack-spec-dist-hook.md new file mode 100644 index 0000000000..7191fe0138 --- /dev/null +++ b/.changeset/console-objectstack-spec-dist-hook.md @@ -0,0 +1,24 @@ +--- +--- + +Build tooling only (objectui#4854). `apps/console/vite.config.ts` now honours +`OBJECTSTACK_SPEC_DIST`, the spec twin of the existing `OBJECTSTACK_CLIENT_DIST` +hook, so a framework build can bundle the console against its own +`@objectstack/spec` instead of the last published one. Mechanism ruled on +objectstack#8134; the framework half (`scripts/build-console.sh`) lands after an +objectui SHA carrying this hook is pinned. + +No release: nothing under a published package's `src/` changed, and with the +variable unset the console's config is byte-for-byte the build it was before — +the alias table, the pre-bundle list, the `vendor-objectstack` chunk test and the +dev server's `fs.allow` all keep their baseline values. + +The client hook is one prefix alias, which is only safe because +`@objectstack/client` exports a single entry. `@objectstack/spec` publishes an +18-entry exports map that redirects every subpath into `dist/`, so the injection +is derived from the override's own map — one alias per entry, subpaths ahead of +the bare specifier — rather than from a hand-written rule. Every failure mode +(path absent, not the spec package, an exports entry naming a file the built +package does not contain, a wildcard pattern) throws with the offending value +named: a lenient fallback to the installed spec would silently rebuild the exact +skew the hook exists to end. diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 8dafb12990..93ff044986 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -15,6 +15,7 @@ import fs from 'fs'; // `native` becomes the default loader (objectui#3384). import { viteCryptoStub } from '../../scripts/vite-crypto-stub.ts'; import { viteMaplibreWorker } from '../../scripts/vite-maplibre-worker.ts'; +import { resolveSpecDistInjection } from '../../scripts/vite-objectstack-spec-dist.ts'; import { compression } from 'vite-plugin-compression2'; import { visualizer } from 'rollup-plugin-visualizer'; @@ -169,6 +170,65 @@ if (clientDistOverride) { clientFsAllow.push(path.dirname(resolved), path.resolve(path.dirname(resolved), '..')); } +// Deps pre-bundled for the dev server. Build-time pre-bundling was removed in +// Vite 5.1, so this list is read by `pnpm dev` only, never by `vite build`. +const OPTIMIZE_DEPS_INCLUDE = [ + '@objectstack/spec', + '@objectstack/spec/data', + '@objectstack/spec/system', + '@objectstack/spec/ui', + 'react-map-gl', + 'react-map-gl/maplibre', + 'maplibre-gl' +]; + +// Baseline `vendor-objectstack` grouping: the installed spec/client, reached +// either through `node_modules/@objectstack/` or through pnpm's flattened +// `@objectstack+` store path. +const VENDOR_OBJECTSTACK_TEST = /([\\/]node_modules[\\/]@objectstack[\\/]|[\\/]@objectstack\+)/; + +// Opt-in override of the installed `@objectstack/spec` — the spec twin of +// OBJECTSTACK_CLIENT_DIST above, so a framework build can bundle the console +// against its OWN spec instead of the last published one (objectui#4854, ruled +// on objectstack#8134). Point it at a built spec package (its directory, its +// `dist/`, or an entry file inside it). +// +// It is NOT a copy of the client line: `@objectstack/spec` publishes an 18-entry +// exports map that redirects every subpath into `dist/`, and a Vite string alias +// does not consult exports maps. So the injection is derived from the OVERRIDE's +// own exports map, one alias per entry — see the module for the derivation and +// for why every failure mode throws instead of falling back. +// +// Inert when unset: `null` here leaves the alias table, the pre-bundle list, the +// vendor chunk test and the dev server's fs allow-list at their baseline values. +const specDistInjection = resolveSpecDistInjection(process.env.OBJECTSTACK_SPEC_DIST, { + vendorChunkTest: VENDOR_OBJECTSTACK_TEST, +}); +if (specDistInjection) Object.assign(workspaceAliases, specDistInjection.aliases); + +const specFsAllow: string[] = specDistInjection ? specDistInjection.fsAllow : []; + +// Pre-bundling an ALIASED, out-of-workspace dep is opt-in through this list and +// nothing else: Vite's pre-alias plugin only registers such a resolution as a +// dep when `optimizeDeps.include` names the specifier. Keeping the four spec +// entries while the override is live would therefore park the injected spec in +// `node_modules/.vite`, whose cache key does not move when the framework +// rebuilds its spec in place — a stale pre-bundle serving yesterday's schema is +// the same silent skew this hook exists to end. Dropping them costs a colder +// dev start and nothing else; `vite build` never reads this list. +const optimizeDepsInclude = specDistInjection + ? OPTIMIZE_DEPS_INCLUDE.filter((specifier) => !Object.hasOwn(specDistInjection.aliases, specifier)) + : OPTIMIZE_DEPS_INCLUDE; + +// An injected spec resolves OUTSIDE node_modules, so the baseline test above +// stops matching it and the biggest vendor surface in the bundle (spec is +// imported by 29 packages here) would scatter into its importers' chunks. The +// injected build should differ from a released one in spec CONTENT, not in +// chunk layout, so the override's location joins the group's test. +const vendorObjectstackTest = specDistInjection + ? specDistInjection.vendorChunkTest + : VENDOR_OBJECTSTACK_TEST; + // https://vitejs.dev/config/ export default defineConfig({ base: basePath, @@ -233,15 +293,7 @@ export default defineConfig({ dedupe: ['react', 'react-dom', 'sonner'], }, optimizeDeps: { - include: [ - '@objectstack/spec', - '@objectstack/spec/data', - '@objectstack/spec/system', - '@objectstack/spec/ui', - 'react-map-gl', - 'react-map-gl/maplibre', - 'maplibre-gl' - ] + include: optimizeDepsInclude }, build: { target: 'esnext', @@ -266,7 +318,7 @@ export default defineConfig({ groups: [ { name: 'vendor-react', test: /[\\/]node_modules[\\/](react|react-dom|react-router|scheduler)[\\/]/, priority: 100 }, { name: 'vendor-radix', test: /[\\/]node_modules[\\/]@radix-ui[\\/]/, priority: 95 }, - { name: 'vendor-objectstack', test: /([\\/]node_modules[\\/]@objectstack[\\/]|[\\/]@objectstack\+)/, priority: 95 }, + { name: 'vendor-objectstack', test: vendorObjectstackTest, priority: 95 }, { name: 'vendor-icons-core', test: /[\\/]node_modules[\\/]lucide-react[\\/]dist[\\/](lucide-react|esm[\\/](Icon|createLucideIcon|defaultAttributes|shared))/, priority: 90 }, { name: 'vendor-ui-utils', test: /[\\/]node_modules[\\/](class-variance-authority|clsx|tailwind-merge|sonner)[\\/]/, priority: 90 }, { name: 'vendor-zod', test: /[\\/]node_modules[\\/]zod[\\/]/, priority: 90 }, @@ -318,9 +370,13 @@ export default defineConfig({ }, server: { port: 5180, - // Widen the fs allow-list only when an out-of-tree client override is set - // (see OBJECTSTACK_CLIENT_DIST above); otherwise keep Vite's defaults. - ...(clientFsAllow.length ? { fs: { allow: [path.resolve(import.meta.dirname, '../..'), ...clientFsAllow] } } : {}), + // Widen the fs allow-list only when an out-of-tree override is set (see + // OBJECTSTACK_CLIENT_DIST / OBJECTSTACK_SPEC_DIST above); otherwise keep + // Vite's defaults. Both overrides live outside the workspace root, which + // Vite's default `fs.allow` serves as a 403 (blank page, no build error). + ...(clientFsAllow.length || specFsAllow.length + ? { fs: { allow: [path.resolve(import.meta.dirname, '../..'), ...clientFsAllow, ...specFsAllow] } } + : {}), proxy: { '/api': { target: process.env.DEV_PROXY_TARGET || 'http://localhost:3000', changeOrigin: true }, }, diff --git a/scripts/__tests__/vite-objectstack-spec-dist.test.ts b/scripts/__tests__/vite-objectstack-spec-dist.test.ts new file mode 100644 index 0000000000..65cca4db73 --- /dev/null +++ b/scripts/__tests__/vite-objectstack-spec-dist.test.ts @@ -0,0 +1,465 @@ +import { describe, expect, it } from 'vitest'; +import { build } from 'vite'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +import { + SPEC_PACKAGE_NAME, + readSpecExportTargets, + resolveSpecDistInjection, +} from '../vite-objectstack-spec-dist'; + +/** + * objectui#4854 — `apps/console/vite.config.ts` honours `OBJECTSTACK_SPEC_DIST`, + * so a framework build can bundle the console against ITS OWN `@objectstack/spec` + * instead of the last published one (mechanism ruled on objectstack#8134). + * + * Two facts are pinned here, and they pull in opposite directions on purpose: + * + * 1. **Set → every subpath is mapped.** The client hook this mirrors is one + * prefix alias, which is safe only because `@objectstack/client` exports a + * single entry. The spec's map has 18 and redirects each into `dist/`, so a + * copied client line rewrites `@objectstack/spec/ui` to a path that does not + * exist — measured as 214 broken import sites for `/ui` alone. The + * reconciliation case below therefore checks the derivation against Node's + * OWN resolver, entry by entry, and separately sweeps every spec specifier + * this repository actually imports. + * 2. **Unset → nothing moves.** Each of the four surfaces the hook can touch + * (`resolve.alias`, `optimizeDeps.include`, the `vendor-objectstack` chunk + * test, `server.fs.allow`) is pinned at its baseline value, read off the + * REAL console config rather than off the helper, so a hook that stopped + * being conditional turns these red rather than shipping a silently + * different production bundle. + * + * Reverse verification, direction predicted BEFORE running. Both plain RED — the + * derivation is the sole input to case 1 and the `null` branch the sole input to + * case 2, so a mutation in either can only ADD findings; neither has the + * count-shaped or inverted direction some pins do. Predicted, then measured: + * + * - **Drop one subpath** from the derived table (`… && s !== + * '@objectstack/spec/ui'`) → 5 red. Worth recording precisely, because the + * obvious expectation is wrong: the dropped specifier does NOT show up as + * "no alias". It falls through to the bare entry and is reported as + * `@objectstack/spec/ui -> …/dist/index.mjs/ui` — a path that cannot exist + * — so the finding lands in the repo sweep's `unresolved` list, while + * `missing` stays empty. The reconciliation case fails one step earlier, on + * 18-vs-17. + * - **Make the hook unconditional** (default the env read to the installed + * spec dir) → 2 red, both in the console-config block: the alias table gains + * 18 `@objectstack` keys, and `optimizeDeps.include` drops from 7 to 3. + * + * The real-build cases at the bottom carry their own control rather than a + * mutation: the same bundle is built a second time through the literal + * client-hook alias, and a green build there would mean this whole derivation is + * unnecessary. Measured: it fails with 2 resolve errors. + */ + +const require_ = createRequire(import.meta.url); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** The baseline `vendor-objectstack` group test, as the console config spells it. */ +const BASE_VENDOR_TEST = /([\\/]node_modules[\\/]@objectstack[\\/]|[\\/]@objectstack\+)/; + +/** The installed spec package — a real, fully built override target. */ +const installedSpecDir = path.dirname(require_.resolve('@objectstack/spec/package.json')); + +const inject = (raw: string | undefined) => + resolveSpecDistInjection(raw, { vendorChunkTest: BASE_VENDOR_TEST }); + +/** + * A fresh evaluation of the console's Vite config, keyed by `query`. + * + * The specifier is assembled at runtime on purpose. A literal one would pull + * `apps/console/vite.config.ts` into THIS program, where it does not belong — + * `tsconfig.scripts.json` leaves `allowImportingTsExtensions` off, so its own + * `.ts` imports become TS5097 in a project that never compiled them before. + * Vitest resolves the runtime specifier relative to this file identically. + */ +async function loadConsoleConfig(query = ''): Promise { + const specifier = `../../apps/console/vite.config.ts${query}`; + return (await import(/* @vite-ignore */ specifier)).default; +} + +/** + * Vite's own alias matcher, transcribed from `node_modules/vite` (`matches()` in + * the alias plugin): exact hit, or the specifier continues past a separator. + * First match wins, which is why the table's key ORDER decides correctness. + */ +function resolveThroughAliases(aliases: Record, specifier: string): string | null { + for (const [find, replacement] of Object.entries(aliases)) { + if (specifier === find || specifier.startsWith(`${find}/`)) { + return specifier.replace(find, replacement); + } + } + return null; +} + +describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { + it('maps every exports-map entry to the file Node itself resolves', async () => { + const injection = inject(installedSpecDir); + expect(injection).not.toBeNull(); + + const manifest = JSON.parse( + fs.readFileSync(path.join(installedSpecDir, 'package.json'), 'utf8') + ) as { exports: Record }; + const declared = Object.keys(manifest.exports); + + // Anti-vacuity: the map this is reconciled against is the measured 18-entry + // one, not an empty object a silently-changed reader would also "cover". + expect(declared.length).toBe(18); + expect(Object.keys(injection!.aliases).length).toBe(declared.length); + + const missing: string[] = []; + const mismatched: string[] = []; + for (const key of declared) { + const specifier = key === '.' ? SPEC_PACKAGE_NAME : `${SPEC_PACKAGE_NAME}/${key.slice(2)}`; + const aliased = resolveThroughAliases(injection!.aliases, specifier); + if (!aliased) { + missing.push(specifier); + continue; + } + // The oracle: what Node's ESM resolver returns for the same specifier + // under the `import` condition — the algorithm, not a second reading of + // the map. + const expected = fs.realpathSync(fileURLToPath(import.meta.resolve(specifier))); + if (fs.realpathSync(aliased) !== expected) { + mismatched.push(`${specifier}: alias -> ${aliased}, node -> ${expected}`); + } + } + expect(missing, 'exports-map entries with no alias — these keep resolving to the INSTALLED spec').toEqual([]); + expect(mismatched, 'aliases disagreeing with Node').toEqual([]); + }); + + it('does not assume `dist//index.mjs` — `./openapi.json` is the counterexample', () => { + const injection = inject(installedSpecDir)!; + // The obvious hand-written rule the issue sketched would emit + // `dist/openapi.json/index.mjs` here. The map says otherwise, and the map wins. + expect(injection.aliases[`${SPEC_PACKAGE_NAME}/openapi.json`]).toBe( + path.join(installedSpecDir, 'json-schema/openapi.json') + ); + expect(injection.aliases[`${SPEC_PACKAGE_NAME}/package.json`]).toBe( + path.join(installedSpecDir, 'package.json') + ); + expect(injection.aliases[`${SPEC_PACKAGE_NAME}/ui`]).toBe( + path.join(installedSpecDir, 'dist/ui/index.mjs') + ); + }); + + it('orders the bare specifier LAST so subpaths win the prefix match', () => { + const keys = Object.keys(inject(installedSpecDir)!.aliases); + expect(keys[keys.length - 1]).toBe(SPEC_PACKAGE_NAME); + expect(keys.filter((k) => k === SPEC_PACKAGE_NAME)).toHaveLength(1); + + // The consequence, stated as behaviour: a subpath resolves to its own entry… + const injection = inject(installedSpecDir)!; + expect(resolveThroughAliases(injection.aliases, `${SPEC_PACKAGE_NAME}/ui`)).toBe( + injection.aliases[`${SPEC_PACKAGE_NAME}/ui`] + ); + // …and a subpath the override does NOT declare is rewritten to a path that + // cannot exist, i.e. a loud resolve error naming the specifier, rather than + // quietly falling through to the installed spec. + const undeclared = resolveThroughAliases(injection.aliases, `${SPEC_PACKAGE_NAME}/not-a-real-subpath`); + expect(undeclared).toBe(`${injection.aliases[SPEC_PACKAGE_NAME]}/not-a-real-subpath`); + expect(fs.existsSync(undeclared!)).toBe(false); + }); + + it('covers every spec specifier this repository imports', () => { + const injection = inject(installedSpecDir)!; + const specifiers = collectSpecSpecifiers(); + + // Anti-vacuity: the sweep found the measured surface (17 distinct + // specifiers, `/ui` and `/data` the heaviest), not an empty scan. + expect(specifiers.size).toBeGreaterThanOrEqual(17); + expect([...specifiers]).toContain(`${SPEC_PACKAGE_NAME}/ui`); + expect([...specifiers]).toContain(SPEC_PACKAGE_NAME); + + const unresolved: string[] = []; + for (const specifier of specifiers) { + const aliased = resolveThroughAliases(injection.aliases, specifier); + if (!aliased || !fs.existsSync(aliased)) unresolved.push(`${specifier} -> ${aliased ?? '(no alias)'}`); + } + expect(unresolved, 'specifiers the injected spec cannot serve').toEqual([]); + }); + + it('allows the dev server to read the injected package', () => { + const injection = inject(installedSpecDir)!; + expect(injection.fsAllow).toEqual([fs.realpathSync(installedSpecDir)]); + expect(injection.packageDir).toBe(fs.realpathSync(installedSpecDir)); + }); + + it('keeps the injected spec inside the `vendor-objectstack` chunk', () => { + const injection = inject(installedSpecDir)!; + const outOfTree = '/framework/packages/spec'; + const outOfTreeInjection = resolveSpecDistInjection(installedSpecDir, { + vendorChunkTest: BASE_VENDOR_TEST, + })!; + + // The baseline test cannot see an injected package: that is the whole + // reason the group test is widened rather than left alone. + expect(BASE_VENDOR_TEST.test(`${outOfTree}/dist/ui/index.mjs`)).toBe(false); + expect(injection.vendorChunkTest.test(`${injection.packageDir}/dist/ui/index.mjs`)).toBe(true); + // Widened, never replaced — the installed-package arms still match. + expect(outOfTreeInjection.vendorChunkTest.test('/repo/node_modules/@objectstack/client/dist/index.mjs')).toBe(true); + expect(outOfTreeInjection.vendorChunkTest.test('/repo/node_modules/.pnpm/@objectstack+spec@1/x.mjs')).toBe(true); + // And it stays a spec-shaped test, not a catch-all. + expect(injection.vendorChunkTest.test('/repo/packages/core/src/index.ts')).toBe(false); + }); + + it('accepts a `dist/` or entry-file spelling of the same package', () => { + const fromDir = inject(installedSpecDir)!; + const fromDist = inject(path.join(installedSpecDir, 'dist'))!; + const fromEntry = inject(path.join(installedSpecDir, 'dist/index.mjs'))!; + expect(fromDist.aliases).toEqual(fromDir.aliases); + expect(fromEntry.aliases).toEqual(fromDir.aliases); + }); +}); + +describe('objectui#4854: the override fails loudly, never leniently', () => { + it('is inert when unset, empty, or blank', () => { + for (const raw of [undefined, '', ' ']) { + expect(inject(raw)).toBeNull(); + } + }); + + it('throws when the path does not exist', () => { + expect(() => inject('/nope/objectstack-spec-4854')).toThrow(/does not exist/); + }); + + it('throws when the path is not the spec package', () => { + // The repo root has a `package.json`, so this fails on IDENTITY rather than + // on absence — the case a name-blind walk-up would accept. + expect(() => inject(repoRoot)).toThrow(/is not inside a `@objectstack\/spec` package/); + }); + + it('throws when the built package is missing a file its exports map names', () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-dist-4854-')); + try { + fs.writeFileSync( + path.join(fixture, 'package.json'), + JSON.stringify({ + name: SPEC_PACKAGE_NAME, + exports: { + '.': { import: { types: './dist/index.d.mts', default: './dist/index.mjs' } }, + }, + }) + ); + // A half-built override is exactly how a silent skew would return: the + // bundle would keep resolving `@objectstack/spec` from the lockfile while + // the build reported success. + expect(() => inject(fixture)).toThrow(/does not contain/); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); + + it('throws on a wildcard exports pattern rather than guessing', () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-dist-4854-')); + try { + fs.mkdirSync(path.join(fixture, 'dist')); + fs.writeFileSync(path.join(fixture, 'dist/index.mjs'), 'export {};\n'); + fs.writeFileSync( + path.join(fixture, 'package.json'), + JSON.stringify({ + name: SPEC_PACKAGE_NAME, + exports: { '.': './dist/index.mjs', './*': './dist/*/index.mjs' }, + }) + ); + expect(() => inject(fixture)).toThrow(/wildcard pattern/); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); + + it('never picks the `types` condition, which sits first in the real map', () => { + for (const target of readSpecExportTargets(installedSpecDir).values()) { + expect(target.endsWith('.d.ts') || target.endsWith('.d.mts')).toBe(false); + } + }); +}); + +describe('objectui#4854: the four flagged surfaces in the console config', () => { + // Read off the REAL config, not the helper: a correct helper wired into + // nothing is the failure this repo has paid for before. + it('leaves all four flagged surfaces at their baseline values', async () => { + expect(process.env.OBJECTSTACK_SPEC_DIST ?? '').toBe(''); + const config = await loadConsoleConfig(); + + // 1. resolve.alias — no `@objectstack` entry at all. + const aliasKeys = Object.keys(config.resolve.alias); + expect(aliasKeys.filter((k: string) => k.startsWith('@objectstack'))).toEqual([]); + + // 2. optimizeDeps.include — byte-identical to the baseline list. + expect(config.optimizeDeps.include).toEqual([ + '@objectstack/spec', + '@objectstack/spec/data', + '@objectstack/spec/system', + '@objectstack/spec/ui', + 'react-map-gl', + 'react-map-gl/maplibre', + 'maplibre-gl', + ]); + + // 3. the vendor-objectstack chunk test — the literal, unwidened. + const groups = config.build.rollupOptions.output.advancedChunks.groups as { name: string; test: RegExp }[]; + const vendor = groups.find((g) => g.name === 'vendor-objectstack'); + expect(vendor).toBeDefined(); + expect(vendor!.test.source).toBe(BASE_VENDOR_TEST.source); + + // 4. server.fs — absent, so Vite keeps its own default allow-list. + expect(config.server.fs).toBeUndefined(); + }); + + it('moves all four surfaces — and only those — once the override IS set', async () => { + // A second evaluation of the same config under a distinct module id (the + // query suffix), so the unset instance above stays intact and the two can be + // compared. `vi.resetModules()` was rejected: the `unit` project runs + // `isolate: false`, so resetting the registry reaches other files' modules. + const baseline = await loadConsoleConfig(); + process.env.OBJECTSTACK_SPEC_DIST = installedSpecDir; + let injected: any; + try { + injected = await loadConsoleConfig('?objectstack-spec-dist=4854'); + } finally { + delete process.env.OBJECTSTACK_SPEC_DIST; + } + + // 1. alias — one entry per exports-map entry, subpaths before the bare name. + const injectedSpecKeys = Object.keys(injected.resolve.alias).filter((k: string) => + k === SPEC_PACKAGE_NAME || k.startsWith(`${SPEC_PACKAGE_NAME}/`) + ); + expect(injectedSpecKeys).toHaveLength(18); + expect(injectedSpecKeys[injectedSpecKeys.length - 1]).toBe(SPEC_PACKAGE_NAME); + expect(injected.resolve.alias[`${SPEC_PACKAGE_NAME}/ui`]).toBe( + path.join(fs.realpathSync(installedSpecDir), 'dist/ui/index.mjs') + ); + // The `@object-ui/*` workspace aliases are untouched by the injection. + const objectUiKeys = (alias: Record) => + Object.keys(alias).filter((k) => k.startsWith('@object-ui/')); + expect(objectUiKeys(injected.resolve.alias)).toEqual(objectUiKeys(baseline.resolve.alias)); + + // 2. optimizeDeps.include — the four spec entries drop out, the rest stay. + expect(injected.optimizeDeps.include).toEqual(['react-map-gl', 'react-map-gl/maplibre', 'maplibre-gl']); + + // 3. the vendor chunk test — widened with the override, baseline arms kept. + const vendorOf = (config: any) => + (config.build.rollupOptions.output.advancedChunks.groups as { name: string; test: RegExp }[]).find( + (g) => g.name === 'vendor-objectstack' + )!.test; + expect(vendorOf(injected).source.startsWith(BASE_VENDOR_TEST.source)).toBe(true); + expect(vendorOf(injected).test(`${fs.realpathSync(installedSpecDir)}/dist/ui/index.mjs`)).toBe(true); + + // 4. server.fs.allow — the workspace root plus the injected package. + expect(injected.server.fs.allow).toEqual([repoRoot, fs.realpathSync(installedSpecDir)]); + + // …and nothing else moved: the unset instance is untouched by the second + // evaluation, which is what makes the laziness case above meaningful. + expect(baseline.optimizeDeps.include).toHaveLength(7); + expect(baseline.server.fs).toBeUndefined(); + }); + + it('declares the var in turbo.json, which strict env mode would otherwise strip', () => { + const turbo = JSON.parse(fs.readFileSync(path.join(repoRoot, 'turbo.json'), 'utf8')) as { + tasks: { build: { env: string[] } }; + }; + // Turbo v2 runs tasks in strict env mode: an undeclared var never reaches + // the task, so the hook would read `undefined` and stay inert while the + // caller believed it had injected a spec. + expect(turbo.tasks.build.env).toContain('OBJECTSTACK_SPEC_DIST'); + expect(turbo.tasks.build.env).toContain('OBJECTSTACK_CLIENT_DIST'); + }); +}); + +describe('objectui#4854: a real Vite build resolves the injected spec', () => { + // The transcribed matcher above agrees with Vite's source, but only Vite can + // answer whether it preserves the alias table's KEY ORDER through + // `normalizeAlias` — and the order is what makes the bare entry a backstop + // rather than a swallow-everything. So this bundles for real: ~300ms, because + // the entry is three modules and the output is never written. + const ENTRY = [ + "import * as root from '@objectstack/spec';", + "import * as ui from '@objectstack/spec/ui';", + "import * as data from '@objectstack/spec/data';", + "import pkg from '@objectstack/spec/package.json';", + "globalThis.__probe4854 = [root, ui, data, pkg.name];", + ].join('\n'); + + async function bundle( + alias: Record, + root: string + ): Promise<{ code: string; imports: string[] }> { + const result = (await build({ + root, + logLevel: 'silent', + resolve: { alias }, + build: { + write: false, + minify: false, + lib: { entry: path.join(root, 'entry.mjs'), formats: ['es'], fileName: 'probe' }, + }, + })) as { output: { code?: string; imports?: string[] }[] }[]; + const chunk = result[0].output[0]; + return { code: chunk.code ?? '', imports: chunk.imports ?? [] }; + } + + function withEntry(run: (root: string) => Promise): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-build-4854-')); + fs.writeFileSync(path.join(dir, 'entry.mjs'), ENTRY); + return run(dir).finally(() => fs.rmSync(dir, { recursive: true, force: true })); + } + + it('bundles the bare specifier and its subpaths with nothing left unresolved', async () => { + const injection = inject(installedSpecDir)!; + const chunk = await withEntry((root) => bundle(injection.aliases, root)); + + // Anti-vacuity: the injected package's own schema code is in the output… + expect(chunk.code.length).toBeGreaterThan(100_000); + expect(chunk.code).toContain(SPEC_PACKAGE_NAME); + // …and the chunk imports nothing from outside itself, so no specifier fell + // back to the installed spec or leaked out as an external. (Asserted on the + // rollup chunk's import list rather than on the code text: the spec bundles + // its own name into string literals, which a text scan reads as an import.) + expect(chunk.imports).toEqual([]); + }); + + it('is what the literal client-hook copy cannot do', async () => { + // The premise of the card, measured rather than asserted: one prefix alias + // at the package directory rewrites `@objectstack/spec/ui` to `SPEC_PKG/ui`, + // which does not exist. A green build here would mean the whole + // exports-map derivation is unnecessary. + await expect( + withEntry((root) => bundle({ [SPEC_PACKAGE_NAME]: installedSpecDir }, root)) + ).rejects.toThrow(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Repo sweep — the consumption radius, derived rather than listed. */ +/* -------------------------------------------------------------------------- */ + +const SCAN_ROOTS = ['packages', 'apps', 'examples']; +const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.jsx']); +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.turbo', '.next', 'coverage', '.git']); +const SPEC_SPECIFIER_RE = /['"](@objectstack\/spec(?:\/[a-zA-Z0-9._-]+)?)['"]/g; + +/** Every `@objectstack/spec` specifier written in the workspace's source. */ +function collectSpecSpecifiers(): Set { + const found = new Set(); + const stack = SCAN_ROOTS.map((r) => path.join(repoRoot, r)).filter((d) => fs.existsSync(d)); + while (stack.length) { + const dir = stack.pop()!; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) stack.push(path.join(dir, entry.name)); + continue; + } + if (!SCAN_EXTENSIONS.has(path.extname(entry.name))) continue; + const source = fs.readFileSync(path.join(dir, entry.name), 'utf8'); + if (!source.includes(SPEC_PACKAGE_NAME)) continue; + for (const match of source.matchAll(SPEC_SPECIFIER_RE)) found.add(match[1]); + } + } + return found; +} diff --git a/scripts/vite-objectstack-spec-dist.ts b/scripts/vite-objectstack-spec-dist.ts new file mode 100644 index 0000000000..6ed729fee4 --- /dev/null +++ b/scripts/vite-objectstack-spec-dist.ts @@ -0,0 +1,240 @@ +// `OBJECTSTACK_SPEC_DIST` — resolve `@objectstack/spec` at a locally built spec +// package instead of the one the lockfile installed. +// +// ## Why the hook exists (objectui#4854, ruled on objectstack#8134) +// +// The framework's `scripts/build-console.sh` already injects its own +// `@objectstack/client` into the console build, so a framework release never +// ships a console bundled against a stale published client. The same class of +// skew exists for `@objectstack/spec` and is currently SILENT: an authorable key +// added to the framework's `packages/spec` after the last spec publish is +// accepted and round-tripped by the server, while the Studio designer — bundled +// against the published spec — rejects it as an unrecognized key. Nothing asks +// whether the vendored spec carries the surface the framework declares. +// +// ## Why the client hook does not transfer as-is +// +// The client override is one prefix alias to a package directory, which is safe +// because `@objectstack/client`'s exports map has exactly one entry and nothing +// imports a subpath. `@objectstack/spec` is a different shape — measured on the +// installed 17.0.0-rc.6: 18 exports entries, 17 of them reached by this +// repository's own imports, across 29 packages. Every entry redirects into +// `dist/`, so a bare prefix alias rewrites `@objectstack/spec/ui` to +// `SPEC_PKG/ui`, a path that does not exist. A Vite string alias is prefix +// replacement; it never consults the target's exports map. +// +// So this module reads the exports map OF THE OVERRIDE and emits one alias per +// entry. Deriving beats prescribing here, and the map itself says why: the +// obvious rule ("subpath NAME lives at dist/NAME/index.mjs") is wrong for +// `./openapi.json`, which the map redirects to `json-schema/openapi.json` — a +// hand-written table would have shipped a hook that mis-resolves it. +// +// ## Two properties this module holds on purpose +// +// - **Loud, never lenient.** Every way the override can be wrong — path absent, +// not the spec package, an exports entry naming a file the built package does +// not contain — throws with the offending value named. A tolerant fallback to +// the installed spec would silently rebuild the exact skew the hook exists to +// kill, and the framework guard could not tell the difference. +// - **Inert when unset.** `resolveSpecDistInjection(undefined, …)` returns +// `null` and the caller's config keeps every baseline value, identity +// included. + +import fs from 'node:fs'; +import path from 'node:path'; + +/** The package this hook overrides. Also the guard against a mis-aimed path. */ +export const SPEC_PACKAGE_NAME = '@objectstack/spec'; + +/** + * Export conditions a browser/ESM bundler picks, in preference order. + * + * `types` is deliberately absent: it sits FIRST inside each condition object in + * the spec's map, and a resolver that walked object keys in declaration order + * would alias every subpath at a `.d.mts` file. + */ +const IMPORT_CONDITIONS = ['import', 'module', 'browser', 'default'] as const; + +/** Character class matching either path separator, for a generated `RegExp`. */ +const SEPARATOR_CLASS = '[\\\\/]'; + +/** What the caller wires into its Vite config when the override is set. */ +export interface SpecDistInjection { + /** Absolute (realpath'd) directory of the overriding spec package. */ + packageDir: string; + /** + * `resolve.alias` entries, SUBPATHS FIRST and the bare specifier last. + * + * The order is load-bearing. Vite matches a string `find` when the specifier + * equals it or starts with it plus a slash, first match wins, so a bare + * `@objectstack/spec` entry placed first would swallow every subpath. Last, it + * still catches specifiers the override's map does NOT declare and rewrites + * them to a path that cannot exist — a resolve error naming the specifier, + * which is the intended outcome: an undeclared subpath must not quietly fall + * through to the installed spec. + */ + aliases: Record; + /** Directories the dev server must be allowed to read (out-of-workspace). */ + fsAllow: string[]; + /** `advancedChunks` test that keeps the injected spec in the vendor chunk. */ + vendorChunkTest: RegExp; +} + +/** Escapes a literal string for embedding in a `RegExp` source. */ +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * The `import`-condition leaf of one exports-map value. + * + * Returns `null` for an entry that resolves to nothing a bundler could take + * (e.g. one exported only under `require`), so the caller can name it. + */ +function pickImportTarget(value: unknown): string | null { + if (typeof value === 'string') return value; + if (value === null || typeof value !== 'object') return null; + if (Array.isArray(value)) { + for (const candidate of value) { + const hit = pickImportTarget(candidate); + if (hit) return hit; + } + return null; + } + const conditions = value as Record; + for (const condition of IMPORT_CONDITIONS) { + if (!Object.hasOwn(conditions, condition)) continue; + const hit = pickImportTarget(conditions[condition]); + if (hit) return hit; + } + return null; +} + +// A function DECLARATION, not a `const` arrow: TypeScript only narrows on a +// never-returning call when the callee is declared this way, and every caller +// below relies on the narrowing to keep its own return type honest. +function fail(message: string): never { + throw new Error(`OBJECTSTACK_SPEC_DIST: ${message}`); +} + +/** + * The spec package directory a raw override value names. + * + * Accepts what the client hook accepts and a little more — the package dir, its + * `dist/`, or a built entry file inside it — by walking up to the nearest + * `package.json` and requiring it to BE the spec package. Realpath'd, because a + * symlinked override would otherwise produce alias targets whose module ids + * (which Vite realpaths) never match the vendor-chunk test built from them. + */ +function findSpecPackageDir(raw: string): string { + const start = path.resolve(raw); + if (!fs.existsSync(start)) { + fail(`\`${raw}\` does not exist (resolved to \`${start}\`)`); + } + let dir = fs.statSync(start).isDirectory() ? start : path.dirname(start); + const inspected: string[] = []; + for (;;) { + const manifestPath = path.join(dir, 'package.json'); + if (fs.existsSync(manifestPath)) { + inspected.push(manifestPath); + let name: unknown; + try { + name = (JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { name?: unknown }).name; + } catch (error) { + fail(`\`${manifestPath}\` is not readable JSON (${(error as Error).message})`); + } + if (name === SPEC_PACKAGE_NAME) { + try { + return fs.realpathSync(dir); + } catch { + return dir; + } + } + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return fail( + `\`${raw}\` is not inside a \`${SPEC_PACKAGE_NAME}\` package — ` + + `walked up from \`${start}\` and found ${inspected.length ? inspected.join(', ') : 'no package.json'}` + ); +} + +/** + * Every specifier the package's exports map declares, mapped to the absolute + * file a bundler resolves it to. + * + * Cross-checked in `scripts/__tests__/vite-objectstack-spec-dist.test.ts` + * against Node's own resolver (`import.meta.resolve`) for all 18 entries, so + * this is not a second opinion about the map — it agrees with the algorithm. + */ +export function readSpecExportTargets(packageDir: string): Map { + const manifestPath = path.join(packageDir, 'package.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { exports?: unknown }; + const exportsMap = manifest.exports; + if (exportsMap === null || typeof exportsMap !== 'object' || Array.isArray(exportsMap)) { + fail(`\`${manifestPath}\` declares no exports map, so no subpath can be resolved`); + } + + const targets = new Map(); + for (const [key, value] of Object.entries(exportsMap as Record)) { + if (!key.startsWith('.')) { + fail(`\`${manifestPath}\` exports key \`${key}\` is a condition, not a subpath — unsupported`); + } + if (key.includes('*')) { + // A pattern cannot be expressed as one exact alias, and guessing would + // reintroduce the silent half-injection this hook exists to prevent. + fail(`\`${manifestPath}\` exports key \`${key}\` is a wildcard pattern — unsupported by this hook`); + } + const target = pickImportTarget(value); + if (!target) { + fail(`\`${manifestPath}\` exports key \`${key}\` resolves to nothing under ${IMPORT_CONDITIONS.join('/')}`); + } + const absolute = path.resolve(packageDir, target); + if (!fs.existsSync(absolute)) { + fail( + `\`${manifestPath}\` exports key \`${key}\` names \`${target}\`, which the built package does not contain ` + + `(expected \`${absolute}\`) — build the spec package before injecting it` + ); + } + const specifier = key === '.' ? SPEC_PACKAGE_NAME : `${SPEC_PACKAGE_NAME}/${key.slice(2)}`; + targets.set(specifier, absolute); + } + if (!targets.has(SPEC_PACKAGE_NAME)) { + fail(`\`${manifestPath}\` exports map has no \`.\` entry, so the bare specifier cannot be resolved`); + } + return targets; +} + +/** + * Resolve the override, or `null` when it is unset. + * + * @param raw the `OBJECTSTACK_SPEC_DIST` value, unset or empty for none + * @param vendorChunkTest the config's baseline `vendor-objectstack` group test, + * widened (never replaced) with the override's location + */ +export function resolveSpecDistInjection( + raw: string | undefined, + { vendorChunkTest }: { vendorChunkTest: RegExp } +): SpecDistInjection | null { + if (!raw || !raw.trim()) return null; + + const packageDir = findSpecPackageDir(raw.trim()); + const targets = readSpecExportTargets(packageDir); + + // Subpaths first (sorted for a stable, reviewable table), bare specifier last + // — see `SpecDistInjection.aliases` for why the order decides correctness. + const aliases: Record = {}; + const subpaths = [...targets.keys()].filter((s) => s !== SPEC_PACKAGE_NAME).sort(); + for (const specifier of subpaths) aliases[specifier] = targets.get(specifier)!; + aliases[SPEC_PACKAGE_NAME] = targets.get(SPEC_PACKAGE_NAME)!; + + const posixDir = packageDir.split(path.sep).join('/'); + return { + packageDir, + aliases, + fsAllow: [packageDir], + vendorChunkTest: new RegExp(`${vendorChunkTest.source}|${escapeRegExp(posixDir)}${SEPARATOR_CLASS}`), + }; +} diff --git a/turbo.json b/turbo.json index e22b32e763..050f78975d 100644 --- a/turbo.json +++ b/turbo.json @@ -11,7 +11,7 @@ "**/*.tsbuildinfo", "!**/node_modules/**" ], - "env": ["NODE_ENV", "VITE_BASE_PATH", "OBJECTSTACK_CLIENT_DIST"], + "env": ["NODE_ENV", "VITE_BASE_PATH", "OBJECTSTACK_CLIENT_DIST", "OBJECTSTACK_SPEC_DIST"], "inputs": [ "$TURBO_DEFAULT$", "$TURBO_ROOT$/tsconfig.json",