diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 5a9cc84410..5c17ac54b5 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -16,6 +16,7 @@ import zlib from 'node:zlib'; // `native` becomes the default loader (objectui#3384). import { viteCryptoStub } from '../../scripts/vite-crypto-stub.ts'; import { viteMaplibreWorker } from '../../scripts/vite-maplibre-worker.ts'; +import { resolveClientDistInjection } from '../../scripts/vite-objectstack-client-dist.ts'; import { resolveSpecDistInjection } from '../../scripts/vite-objectstack-spec-dist.ts'; import { viteIneffectiveDynamicImports } from '../../scripts/vite-ineffective-dynamic-imports.ts'; import { compression } from 'vite-plugin-compression2'; @@ -470,16 +471,24 @@ const workspaceAliases: Record = { // console before that client ships, point OBJECTSTACK_CLIENT_DIST at a locally // built client (its dist entry or package dir). Inert when unset — production // and CI builds use the installed client unchanged. -const clientDistOverride = process.env.OBJECTSTACK_CLIENT_DIST; +// +// No longer a bare string alias: the value is VALIDATED before it is aliased — +// the path must exist, it must sit inside a `@objectstack/client` package +// (directory, `dist/`, or an entry file all resolve to the same package), and +// that package's own declared `dependencies` must resolve from where it lives. +// Without that last check an out-of-tree override produced a fully written +// bundle whose bare specifiers no browser can load, and nothing in the build +// output named this variable — objectui#6094, the client twin of the spec +// hook's objectui#5391. See the module for the measurement and for why the +// check is re-stated there rather than shared with the spec hook. +const clientDistInjection = resolveClientDistInjection(process.env.OBJECTSTACK_CLIENT_DIST); +if (clientDistInjection) workspaceAliases['@objectstack/client'] = clientDistInjection.aliasTarget; + // Extra dirs the dev server may read the override from — it lives outside the // workspace root, so Vite's default `server.fs.allow` would 403 it (blank page). -const clientFsAllow: string[] = []; -if (clientDistOverride) { - const resolved = path.resolve(clientDistOverride); - workspaceAliases['@objectstack/client'] = resolved; - // Allow the containing package (…/dist/index.mjs → …/) so Vite can serve it. - clientFsAllow.push(path.dirname(resolved), path.resolve(path.dirname(resolved), '..')); -} +// Unchanged values: the containing package (…/dist/index.mjs → …/) and its +// parent, now computed beside the validation that vouches for the path. +const clientFsAllow: string[] = clientDistInjection ? clientDistInjection.fsAllow : []; // 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`. diff --git a/scripts/__tests__/vite-objectstack-client-dist.test.ts b/scripts/__tests__/vite-objectstack-client-dist.test.ts new file mode 100644 index 0000000000..91d27e7c80 --- /dev/null +++ b/scripts/__tests__/vite-objectstack-client-dist.test.ts @@ -0,0 +1,349 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { CLIENT_PACKAGE_NAME, resolveClientDistInjection } from '../vite-objectstack-client-dist'; + +/** + * objectui#6094 — `OBJECTSTACK_CLIENT_DIST` validates the override it aliases. + * + * The hook lets a developer point the console at a locally built + * `@objectstack/client`. Before this card it was a bare string alias: no + * existence check, no manifest read, no dependency check. Measured on + * `a100f77d3` with the override aimed at a copy of the installed client placed + * outside the workspace, `vite build` printed `✓ 8615 modules transformed`, + * wrote every chunk, and left the client's three own bare specifiers + * (`@objectstack/core/logger`, `@objectstack/spec/api`, `@objectstack/spec/data`) + * in `assets/framework-*.js` as calls to rolldown's `require` shim — a bundle no + * browser can load, produced by a build whose output never named the variable. + * + * Three facts are pinned here, and they pull against each other on purpose: + * + * 1. **Set and broken → refused, by NAME.** The card's complaint is not that + * the build survived; it is that nothing named `OBJECTSTACK_CLIENT_DIST`. + * So the cases below assert the variable appears in the message, not + * merely that something threw. + * 2. **Set and valid → still injected.** A check that refused every override + * would pass (1) while destroying the hook's only purpose. Both valid + * spellings are exercised against the REAL installed client. + * 3. **Unset → nothing moves.** The alias table and `server.fs.allow` stay at + * their baseline values, read off the real console config, so a check that + * stopped being conditional turns red here rather than shipping a + * different production bundle. + * + * The shape axis cuts across all three: a directory, its `dist/`, and an entry + * file inside it are all legal, and a dependency check is parameterised over a + * DIRECTORY. A check that quietly did nothing for the entry-file spelling would + * be the same defect wearing a green build, so every refusal case is asserted + * for both spellings. + * + * Reverse verification — direction predicted BEFORE each run, and one + * prediction was wrong in a way worth keeping: + * + * - **Start the walk at `resolved` even for a file** (drop the + * `shape === 'directory'` branch in `findClientPackageDir`). Predicted RED + * on the entry-file cases. Measured **GREEN, 19/19** — and that is a fact + * about the code, not a vacuous suite. The walk probes + * `/package.json` and climbs; handed `…/dist/index.mjs` its first + * probe simply misses (`…/index.mjs/package.json`) and the next iteration + * lands on `…/dist` anyway. The branch is explicitness about where the walk + * starts, not the mechanism that makes the file spelling work — the walk + * itself is indifferent. Recorded rather than deleted: a green ablation + * left unexplained reads as a test that pins nothing. + * - **Skip validation entirely for the entry-file spelling** — the naive port + * of a directory-parameterised check, and the exact defect class the card + * names ("a check that silently does nothing when pointed at a + * `dist/index.js`"). Predicted RED on the entry-file cases; measured 3 red, + * including *refuses an out-of-tree copy given as an entry file*. That is + * the pin the first leg was reaching for. + * - **Drop the `realpathSync`** in `findClientPackageDir`. Predicted RED on + * the valid installed-client cases, because pnpm reaches the client through + * a symlink whose ancestors never include the store directory holding its + * dependencies. Measured 5 red, every one of them "a VALID override is + * rejected" — the direction that matters, since a suite blind to it would + * have shipped a hook that refuses everything and still passed the + * reproduction. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** + * The installed client, as the console resolves it — under pnpm a SYMLINK into + * the store, which is the whole point of the realpath cases below. + */ +const installedClientDir = path.join(repoRoot, 'apps/console/node_modules/@objectstack/client'); + +/** Fixture roots created here, removed in `afterAll`. */ +const fixtureRoots: string[] = []; + +function makeFixtureRoot(): string { + // Each fixture gets its OWN `mkdtempSync` root rather than sharing + // `os.tmpdir()`, which every other suite and every parallel agent also writes + // to — and, more to the point here, the walk under test climbs its ancestors. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'client-dist-6094-')); + fixtureRoots.push(root); + return root; +} + +/** + * A copy of the installed client placed outside the workspace — manifest and + * `dist/` intact, no reachable `node_modules` for its two dependencies. + * + * This is the card's reproduction, reduced to a fixture. + */ +function outOfTreeClientCopy(): string { + const dest = path.join(makeFixtureRoot(), 'client'); + fs.cpSync(fs.realpathSync(installedClientDir), dest, { recursive: true, dereference: true }); + return dest; +} + +/** + * The walk `packageResolvesFrom` performs, re-stated here as a MEASUREMENT + * rather than an import: these cases exist to show what the production walk + * would answer if it ran from the un-realpath'd path, which is precisely what + * the module refuses to do. + */ +function walkResolves(startDir: string, name: string): boolean { + let dir = startDir; + for (;;) { + if (fs.existsSync(path.join(dir, 'node_modules', name))) return true; + const parent = path.dirname(dir); + if (parent === dir) return false; + dir = parent; + } +} + +/** + * 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 `tsconfig.scripts.json` + * (no `allowImportingTsExtensions`) turns its own `.ts` imports into TS5097. + * Same reasoning, same spelling as the spec-dist suite next door. + */ +async function loadConsoleConfig(query = ''): Promise { + const specifier = `../../apps/console/vite.config.ts${query}`; + return (await import(/* @vite-ignore */ specifier)).default; +} + +beforeAll(() => { + // Every case below reads the installed client. If the workspace is not + // installed, say so here rather than letting each case invent its own + // explanation for a missing directory. + expect(fs.existsSync(path.join(installedClientDir, 'package.json'))).toBe(true); +}); + +afterAll(() => { + for (const root of fixtureRoots) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('objectui#6094: unset leaves the hook inert', () => { + it.each([undefined, '', ' '])('returns null for %j', (raw) => { + expect(resolveClientDistInjection(raw)).toBeNull(); + }); + + it('leaves the console config at its baseline alias and fs.allow', async () => { + const config = await loadConsoleConfig(); + expect(config.resolve.alias[CLIENT_PACKAGE_NAME]).toBeUndefined(); + // `server.fs` is absent entirely when neither override is set, so Vite + // keeps its own default allow-list. + expect(config.server.fs).toBeUndefined(); + }); +}); + +describe('objectui#6094: a VALID override is still injected — all three spellings', () => { + const realClientDir = () => fs.realpathSync(installedClientDir); + + it('accepts the package directory', () => { + const injection = resolveClientDistInjection(installedClientDir); + expect(injection).not.toBeNull(); + expect(injection!.aliasTarget).toBe(installedClientDir); + expect(injection!.packageDir).toBe(realClientDir()); + }); + + it('accepts the `dist/` directory inside the package', () => { + const dist = path.join(installedClientDir, 'dist'); + const injection = resolveClientDistInjection(dist); + expect(injection!.aliasTarget).toBe(dist); + // The walk climbed out of `dist/` to the package that owns it. + expect(injection!.packageDir).toBe(realClientDir()); + }); + + it('accepts a built entry FILE inside the package', () => { + const entry = path.join(installedClientDir, 'dist/index.mjs'); + expect(fs.statSync(entry).isFile()).toBe(true); + const injection = resolveClientDistInjection(entry); + expect(injection!.aliasTarget).toBe(entry); + expect(injection!.packageDir).toBe(realClientDir()); + // The alias target is the file, unchanged from the pre-check config; only + // the dependency walk uses the package directory. + expect(injection!.fsAllow).toEqual([ + path.join(installedClientDir, 'dist'), + installedClientDir, + ]); + }); + + it('resolves the dependency walk through the pnpm symlink, not around it', () => { + // Skipped only where the installed client is NOT a symlink (a hoisted, + // non-pnpm install), because then there is no symlink for the realpath to + // make a difference to and the counter-probe would be measuring nothing. + if (fs.realpathSync(installedClientDir) === installedClientDir) return; + + // The measurement that makes `realpathSync` load-bearing rather than + // tidy: from the symlink path, the client's own dependency is NOT found — + // the walk climbs `apps/console/node_modules` → `apps/console` → the repo + // root, none of which carry `node_modules/@objectstack/core`. From the + // store path it is found, beside the store copy of the client. + expect(walkResolves(installedClientDir, '@objectstack/core')).toBe(false); + expect(walkResolves(fs.realpathSync(installedClientDir), '@objectstack/core')).toBe(true); + // …so the injection, which walks from `packageDir`, must be reading the + // store path. Without this the check would reject the installed client — + // the one input it must accept. + expect(resolveClientDistInjection(installedClientDir)!.packageDir).not.toBe(installedClientDir); + }); + + it('does not demand `peerDependencies` — they come from the consuming app', () => { + const dir = path.join(makeFixtureRoot(), 'client'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: CLIENT_PACKAGE_NAME, peerDependencies: { react: '^19' } }) + ); + expect(resolveClientDistInjection(dir)).not.toBeNull(); + }); + + it('tolerates a manifest with no `dependencies` at all', () => { + const dir = path.join(makeFixtureRoot(), 'client'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: CLIENT_PACKAGE_NAME })); + expect(resolveClientDistInjection(dir)).not.toBeNull(); + }); + + it('injects into the real console config: alias plus a widened fs.allow', async () => { + const baseline = await loadConsoleConfig(); + process.env.OBJECTSTACK_CLIENT_DIST = installedClientDir; + let injected: any; + try { + // A distinct module id (the query suffix) so the baseline evaluation + // above stays intact and the two can be compared. `vi.resetModules()` is + // not an option: the `unit` project runs `isolate: false`. + injected = await loadConsoleConfig('?objectstack-client-dist=6094'); + } finally { + delete process.env.OBJECTSTACK_CLIENT_DIST; + } + + expect(injected.resolve.alias[CLIENT_PACKAGE_NAME]).toBe(installedClientDir); + expect(injected.server.fs.allow).toEqual([ + repoRoot, + path.dirname(installedClientDir), + path.resolve(path.dirname(installedClientDir), '..'), + ]); + // …and the baseline evaluation is untouched by the second one, which is + // what makes the inert case above meaningful. + expect(baseline.resolve.alias[CLIENT_PACKAGE_NAME]).toBeUndefined(); + }); +}); + +describe('objectui#6094: a BROKEN override is refused, naming the variable', () => { + it('refuses a path that does not exist', () => { + const missing = path.join(makeFixtureRoot(), 'no-such-client'); + expect(() => resolveClientDistInjection(missing)).toThrow(/OBJECTSTACK_CLIENT_DIST/); + expect(() => resolveClientDistInjection(missing)).toThrow(/does not exist/); + }); + + // The card's reproduction, both spellings. The entry-file row is the one that + // matters most: a check parameterised over a package directory, handed a + // file, is exactly where "silently does nothing" hides. + const brokenShapes = [ + ['directory', (root: string) => root], + ['`dist/` directory', (root: string) => path.join(root, 'dist')], + ['entry file', (root: string) => path.join(root, 'dist/index.mjs')], + ] as const; + + it.each(brokenShapes)( + 'refuses an out-of-tree copy given as a %s, naming every unresolved dependency', + (_label, pick) => { + const copy = outOfTreeClientCopy(); + const value = pick(copy); + expect(fs.existsSync(value)).toBe(true); + + let thrown: Error | undefined; + try { + resolveClientDistInjection(value); + } catch (error) { + thrown = error as Error; + } + expect(thrown).toBeDefined(); + const message = thrown!.message; + + // 1. It names the variable — the card's actual complaint. + expect(message).toContain('OBJECTSTACK_CLIENT_DIST'); + // 2. It names the dependencies that do not resolve, both of them: these + // are the packages owning the three bare specifiers that survived into + // the bundle (`@objectstack/core/logger`, `@objectstack/spec/api`, + // `@objectstack/spec/data`). + expect(message).toContain('@objectstack/core'); + expect(message).toContain('@objectstack/spec'); + // 3. It names the package directory the walk judged, so a reader can see + // WHICH tree was searched — the fact that decides whether the fix is + // "install deps there" or "point somewhere else". + expect(message).toContain(fs.realpathSync(copy)); + // 4. It is the dependency verdict, not the shape verdict: a check that + // failed the entry-file row with "not inside a package" would satisfy + // (1) while telling the reader nothing true. + expect(message).toMatch(/do(es)? not resolve from/); + } + ); + + it('refuses a bare `dist/` copied out with no manifest anywhere above it', () => { + // The other half of the card's reproduction: copy only `dist/`, and there + // is no `package.json` in any ancestor — so there is no manifest to read + // dependencies from, and the refusal has to say THAT rather than invent a + // dependency verdict. + const root = makeFixtureRoot(); + fs.cpSync(path.join(fs.realpathSync(installedClientDir), 'dist'), path.join(root, 'dist'), { + recursive: true, + dereference: true, + }); + for (const value of [path.join(root, 'dist'), path.join(root, 'dist/index.mjs')]) { + expect(() => resolveClientDistInjection(value)).toThrow(/OBJECTSTACK_CLIENT_DIST/); + expect(() => resolveClientDistInjection(value)).toThrow(/is not inside a/); + expect(() => resolveClientDistInjection(value)).toThrow(/@objectstack\/client/); + } + }); + + it('refuses a path whose nearest manifest is some OTHER package', () => { + // Without the name match the walk would stop at the first `package.json` + // above the value and validate THAT package's dependencies — a check that + // always passes, on the wrong subject. `apps/console` is a real package + // with real, resolvable dependencies, so it would pass exactly that way. + const consoleDir = path.join(repoRoot, 'apps/console'); + expect(() => resolveClientDistInjection(consoleDir)).toThrow(/is not inside a/); + expect(() => resolveClientDistInjection(consoleDir)).toThrow(/OBJECTSTACK_CLIENT_DIST/); + }); + + it('refuses an unreadable manifest instead of skipping past it', () => { + const dir = path.join(makeFixtureRoot(), 'client'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), '{ not json'); + expect(() => resolveClientDistInjection(dir)).toThrow(/is not readable JSON/); + expect(() => resolveClientDistInjection(dir)).toThrow(/OBJECTSTACK_CLIENT_DIST/); + }); + + it('fails the console config LOAD, before any module is bundled', async () => { + // Where the refusal lands is the whole value of the card: at config + // evaluation, with the variable named — not a build later, in a resolve + // error naming a specifier the reader never typed. + const copy = outOfTreeClientCopy(); + process.env.OBJECTSTACK_CLIENT_DIST = copy; + try { + await expect(loadConsoleConfig('?objectstack-client-dist=6094-broken')).rejects.toThrow( + /OBJECTSTACK_CLIENT_DIST/ + ); + } finally { + delete process.env.OBJECTSTACK_CLIENT_DIST; + } + }); +}); diff --git a/scripts/vite-objectstack-client-dist.ts b/scripts/vite-objectstack-client-dist.ts new file mode 100644 index 0000000000..8c1756ebf0 --- /dev/null +++ b/scripts/vite-objectstack-client-dist.ts @@ -0,0 +1,276 @@ +// `OBJECTSTACK_CLIENT_DIST` — resolve `@objectstack/client` at a locally built +// client instead of the one the lockfile installed, and REFUSE the build when +// that override cannot work. +// +// ## What was missing (objectui#6094) +// +// The hook itself is old: `apps/console/vite.config.ts` has aliased +// `@objectstack/client` at `path.resolve(process.env.OBJECTSTACK_CLIENT_DIST)` +// for as long as a developer has needed to exercise a client before it ships. +// Nothing validated that value — no existence check, no manifest read, no +// dependency check — and the hook is aimed, BY DESIGN, at a freshly built, +// not-yet-installed tree, which is exactly the situation where a reachable +// `node_modules` is absent or incomplete. +// +// Measured on `a100f77d3` (vite 8.2.1 + rolldown 1.2.3) with the override +// pointed at a copy of the installed `@objectstack/client@17.2.0` placed +// outside the workspace: `vite build` prints `✓ 8615 modules transformed`, +// renders every chunk, writes `dist/` and lists its gzip sizes. The client's +// three own bare specifiers — `@objectstack/core/logger`, `@objectstack/spec/api`, +// `@objectstack/spec/data` — survive into `assets/framework-*.js` as calls to +// rolldown's `require` shim, which throws in a browser. The build then dies for +// an unrelated reason (`ineffective-dynamic-import-ledger`, objectui#6093); +// with that plugin out of the array, rolldown's own resolve error surfaces +// instead. NEITHER failure names `OBJECTSTACK_CLIENT_DIST`, and both arrive a +// whole build after the value that caused them was read. +// +// That is the same failure mode objectui#5391 was filed about on the SPEC hook, +// where PR #5995 answered it with a fail-fast dependency check. +// +// ## Why this is a sibling module and not a shared helper +// +// Ruled on objectui#6094: the two hooks are not symmetrical. The spec hook +// derives 18 aliases from the override's `exports` map, because a bare prefix +// alias cannot express a map that redirects every subpath into `dist/`. The +// client hook is ONE alias — `@objectstack/client` exports a single entry — +// and its accepted input is wider: a package directory, its `dist/`, or a +// built entry file inside it. Only the dependency check transfers, so it is +// re-stated here rather than extracted; a helper bent across both shapes would +// impose the spec hook's assumptions on this one. If a third hook ever appears, +// that is the moment to abstract. +// +// ## The shape ambiguity is the substance +// +// A dependency check is parameterised over a package DIRECTORY, and this hook +// may be handed a FILE. Detecting that wrongly is not a milder bug than the one +// being fixed — a check that silently does nothing when pointed at +// `dist/index.mjs` is the same defect class, with a green build to hide behind. +// So the resolution is one upward walk (below) that is indifferent to which of +// the three spellings arrived, and the shape is carried into every message so +// the reader can see which one this hook believed it got. + +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 CLIENT_PACKAGE_NAME = '@objectstack/client'; + +/** What the caller wires into its Vite config when the override is set. */ +export interface ClientDistInjection { + /** + * The `resolve.alias` target for `@objectstack/client`. + * + * `path.resolve(raw)` — the value the config has always used, deliberately + * NOT the realpath below. This card adds validation; it does not move the + * alias, the module ids that follow from it, or the chunk membership those + * ids decide. + */ + aliasTarget: string; + /** + * Realpath'd directory of the overriding client package — the root the + * dependency walk runs from, and the only place a realpath is required. + * + * Not cosmetic. Under pnpm the installed client is reached through + * `apps/console/node_modules/@objectstack/client`, a symlink into the store, + * and its dependencies live beside the store copy + * (`.pnpm/@objectstack+client@17.2.0_…/node_modules/@objectstack/{core,spec}`). + * Walking up from the SYMLINK path never passes that directory: it climbs + * `apps/console/node_modules` → `apps/console` → the repo root, where neither + * `@objectstack/core` nor a nested `node_modules` for it exists. Measured on + * this repo: a valid, installed-package override is REJECTED without the + * realpath — the check would fail the one input it must accept. + */ + packageDir: string; + /** Directories the dev server must be allowed to read (out-of-workspace). */ + fsAllow: string[]; +} + +/** + * How the raw value pointed at the package — carried into messages so a refusal + * says which spelling this hook resolved, not just that it refused. + */ +type OverrideShape = 'directory' | 'entry file'; + +// A function DECLARATION, not a `const` arrow: TypeScript only narrows on a +// never-returning call when the callee is declared this way, and the callers +// below rely on that narrowing to keep their own return types honest. +function fail(message: string): never { + throw new Error(`OBJECTSTACK_CLIENT_DIST: ${message}`); +} + +/** + * Whether an ancestor `node_modules` of `startDir` contains a directory named + * `name` — an upward directory walk, aimed at `node_modules/`. + * + * Deliberately NOT `require.resolve(name, { paths: [startDir] })`, though that + * reads as the obvious tool. `Module.globalPaths` is consulted REGARDLESS of an + * explicit `paths` list, and this repo's own `node_modules/.bin/vite` shim + * exports `NODE_PATH` at pnpm's flat hoist directory — so `require.resolve` + * reports an override's MISSING dependency as resolved, silently, every time + * the hook runs through the real `vite` CLI rather than a bare `node`. The same + * trap is documented at length on the spec hook's copy of this walk + * (`scripts/vite-objectstack-spec-dist.ts`), which is where it was measured. + * A plain directory walk consults nothing global. + */ +function packageResolvesFrom(startDir: string, name: string): boolean { + let dir = startDir; + for (;;) { + if (fs.existsSync(path.join(dir, 'node_modules', name))) return true; + const parent = path.dirname(dir); + if (parent === dir) return false; + dir = parent; + } +} + +/** + * The `@objectstack/client` package directory a raw override value names. + * + * One walk covers all three accepted spellings, which is the point: a directory + * starts the walk at itself, a file starts it at its own directory, and the + * walk stops at the nearest ancestor whose `package.json` IS the client. So + * `…/client`, `…/client/dist` and `…/client/dist/index.mjs` all resolve to + * `…/client`, and none of them can quietly resolve to "nothing to check". + * + * The `shape` branch below states where the walk starts; it is not what makes + * the file spelling work. Measured by ablation (see the suite's header): with + * the branch removed the walk still resolves an entry file correctly, because + * its first probe — `…/index.mjs/package.json` — simply misses and the next + * iteration lands on the containing directory. Kept because a reader should not + * have to derive that, and because the walk's shape is not a contract. + * + * The name match is load-bearing rather than decorative. Without it the walk + * would stop at whatever manifest happens to sit above the value — a framework + * monorepo root, or this repo — and then dutifully validate THAT package's + * dependencies: a check that always passes, on the wrong subject, which is the + * failure this hook exists to end. + */ +function findClientPackageDir( + raw: string, + resolved: string, + shape: OverrideShape +): string { + let dir = shape === 'directory' ? resolved : path.dirname(resolved); + 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 === CLIENT_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 \`${CLIENT_PACKAGE_NAME}\` package — resolved to the ${shape} ` + + `\`${resolved}\` and walked up from there, finding ` + + `${inspected.length ? inspected.join(', ') : 'no package.json'}. Point the variable at a ` + + `built \`${CLIENT_PACKAGE_NAME}\` (its directory, its \`dist/\`, or an entry file inside it)` + ); +} + +/** + * Confirms the override's own `dependencies` resolve from `packageDir`. + * + * The check that makes the difference between failing HERE, naming the + * variable, and failing a whole build later naming a specifier the reader has + * no reason to connect to an override they set in their shell. + * + * Deliberately narrow, matching the spec hook's precedent: only the manifest's + * own `dependencies`. Not transitive ones — this is not a dependency-graph + * validator, and a broken transitive package fails the same way one frame + * further in, still at build time. Not `peerDependencies` either: a peer is BY + * DESIGN supplied by the consuming app rather than living in the override's own + * tree, so demanding it resolve from `packageDir` would fail a correctly built + * override and report a non-problem. + */ +function assertClientDependenciesResolve(packageDir: string, shape: OverrideShape): void { + const manifestPath = path.join(packageDir, 'package.json'); + // Already proven to be valid JSON at this exact path by `findClientPackageDir`, + // which is the only caller's only route here, so this re-read does not repeat + // that try/catch. + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { dependencies?: unknown }; + const dependencies = manifest.dependencies; + if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) return; + + const unresolved = Object.keys(dependencies as Record).filter( + (name) => !packageResolvesFrom(packageDir, name) + ); + if (unresolved.length === 0) return; + + const isSingular = unresolved.length === 1; + fail( + `\`${manifestPath}\` declares ${isSingular ? 'a dependency' : 'dependencies'} that ` + + `${isSingular ? 'does' : 'do'} not resolve from \`${packageDir}\`: ${unresolved.join(', ')} — ` + + `the override (given as ${a(shape)}) points at a client build with no reachable ` + + `\`node_modules\` for ${isSingular ? 'it' : 'them'}. Every specifier the client imports from ` + + `${isSingular ? 'that package' : 'those packages'} would be left unresolved in a browser ` + + `bundle, which this build reports — when it reports it at all — as a failure naming the ` + + `specifier and never this variable. Install the override's own dependencies (or point at a ` + + `build where they are reachable) before setting \`OBJECTSTACK_CLIENT_DIST\`` + ); +} + +/** `'directory'` → `'a directory'`, `'entry file'` → `'an entry file'`. */ +function a(shape: OverrideShape): string { + return shape === 'entry file' ? `an ${shape}` : `a ${shape}`; +} + +/** + * Resolve the override, or `null` when it is unset. + * + * Inert when unset, exactly as the plain string alias it replaces was: `null` + * leaves the caller's alias table and `server.fs.allow` at their baseline + * values, so a production or CI build is byte-identical to one made before this + * check existed. + * + * Loud when set and wrong. Every way the override can fail — path absent, not a + * `@objectstack/client` package, an unreadable manifest, a declared dependency + * that cannot resolve from where the package sits — throws with the offending + * value named and `OBJECTSTACK_CLIENT_DIST` in the message. There is no + * tolerant fallback to the installed client on purpose: falling back would + * rebuild the exact silent skew the hook exists to expose, and the developer + * who set the variable could not tell the difference. + * + * @param raw the `OBJECTSTACK_CLIENT_DIST` value, unset or empty for none + */ +export function resolveClientDistInjection(raw: string | undefined): ClientDistInjection | null { + if (!raw || !raw.trim()) return null; + + const trimmed = raw.trim(); + const resolved = path.resolve(trimmed); + if (!fs.existsSync(resolved)) { + fail( + `\`${trimmed}\` does not exist (resolved to \`${resolved}\`) — nothing would be aliased at ` + + `\`${CLIENT_PACKAGE_NAME}\` and the build would fail later, naming the specifier instead ` + + `of this variable` + ); + } + + const shape: OverrideShape = fs.statSync(resolved).isDirectory() ? 'directory' : 'entry file'; + const packageDir = findClientPackageDir(trimmed, resolved, shape); + assertClientDependenciesResolve(packageDir, shape); + + return { + aliasTarget: resolved, + packageDir, + // Unchanged from the pre-check config, deliberately: `path.dirname` of the + // resolved value plus its parent, which covers `…//dist/index.mjs` → + // `…/` for the entry-file spelling. The dev server serves the override + // from outside the workspace root, which Vite's default `fs.allow` answers + // with a 403 and a blank page rather than an error. + fsAllow: [path.dirname(resolved), path.resolve(path.dirname(resolved), '..')], + }; +}