Skip to content

fix(console): validate OBJECTSTACK_CLIENT_DIST before aliasing it - #6114

Merged
yinlianghui-tw merged 1 commit into
mainfrom
claude/issue-6094-client-dist-dep-validation
Aug 24, 2026
Merged

fix(console): validate OBJECTSTACK_CLIENT_DIST before aliasing it#6114
yinlianghui-tw merged 1 commit into
mainfrom
claude/issue-6094-client-dist-dep-validation

Conversation

@yinlianghui-tw

@yinlianghui-twyinlianghui-tw commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6094

OBJECTSTACK_CLIENT_DIST was a bare string alias: no existence check, no manifest read, no dependency check. Its sibling OBJECTSTACK_SPEC_DIST got a fail-fast dependency check in PR #5995; this is the client half, taken as the cheaper first cut ruled on the card — the check lands beside the client hook, and scripts/vite-objectstack-spec-dist.ts is not refactored.

The reproduction, measured on a100f77d3

Installed @objectstack/client@17.2.0 copied outside the workspace (manifest + dist/, no reachable node_modules), OBJECTSTACK_CLIENT_DIST aimed at it, vite build in apps/console:

✓ 8615 modules transformed.
[plugin emit-eager-closure-report] eager closure: 52/508 chunks, 3296617 bytes gzipped (3219.4 KB)
dist/assets/… (full gzip listing)

The bundle is written. The client's three own bare specifiers survive into assets/framework-Lm271O55.js as calls to rolldown's require shim:

vard=e(`@objectstack/spec/data`),f=e(`@objectstack/spec/api`),p=e(`@objectstack/core/logger`)

where e is rolldown's runtime require shim — in a browser it throws Calling require for "…" in an environment that doesn't expose the require function. So the card's characterisation holds exactly: the build reads as success through transform, render and emit, and produces a bundle no browser can load.

One refinement worth recording. The process still exits non-zero, for reasons that never name the override:

Neither names OBJECTSTACK_CLIENT_DIST, and both arrive a whole build after the value that caused them was read. That is the #5391 failure mode, on the other hook.

The fix

scripts/vite-objectstack-client-dist.tsresolveClientDistInjection(raw), called from apps/console/vite.config.ts. Set and valid → the same alias target and fs.allow values as before. Unset → null, everything inert. Set and wrong → throws at config load, with OBJECTSTACK_CLIENT_DIST in the message.

The directory-or-entry-file ambiguity is the substance, since a dependency check is parameterised over a package directory and this hook may be handed a file. Resolution is one upward walk that stops at the nearest ancestor manifest whose nameis@objectstack/client, so …/client, …/client/dist and …/client/dist/index.mjs all resolve to …/client. The name match is load-bearing rather than decorative: without it the walk stops at whatever manifest sits above the value — a framework monorepo root, or this repo — and validates that package's dependencies, i.e. a check that always passes on the wrong subject.

realpathSync on the package directory is not cosmetic. Under pnpm the installed client is reached through apps/console/node_modules/@objectstack/client, a symlink into the store, while its dependencies live beside the store copy. Walking up from the symlink path never passes that directory, so a valid, installed-package override is rejected without the realpath — measured, and pinned by a case that asserts both walks directly. The alias target keeps its old, un-realpath'd value: this card adds validation, it does not move the alias or the module ids that follow from it.

Verification

Union of the gates below re-run on the final commit 3f59d1979.

Refusal — the real, unmodified plugin list (OBJECTSTACK_CLIENT_DIST = the out-of-tree copy):

failed to load config from /home/user/objectui-6094/apps/console/vite.config.ts
error during build:
Error: OBJECTSTACK_CLIENT_DIST: `…/client-pkg/package.json` declares dependencies that do not
resolve from `…/client-pkg`: @objectstack/core, @objectstack/spec — the override (given as a
directory) points at a client build with no reachable `node_modules` for them. …

The refusal needs no un-masking: it lands at config load, before any plugin runs, so #6093 cannot hide it.

Non-vacuity, the other direction — a valid override must still build. Both spellings, against the installed client, with the #6093 plugin removed for the run so a green build is observable at all: directory …/node_modules/@objectstack/clientBUILD_EXIT=0; entry file …/@objectstack/client/dist/index.mjsBUILD_EXIT=0.

Freshness of the artifact under test. Vite compiles the config and the scripts/ modules it imports into a per-invocation file under node_modules/.vite-temp/, named vite.config.ts.timestamp- plus a millisecond stamp and a random suffix, and deletes it at the end — so a stale-artifact reading is a real risk. Each run was polled while its file existed: the four runs carry four distinct names (…-1787592975150-…, …-1787593597147-…, …-1787593681607-…, …-1787593759077-…), and for three of them the poller also read the compiled bundle and found the new module's own text inside it (points at a client build with no reachable) — stronger than differing names, since it proves this source compiled into the artifact that ran. The fourth (the entry-file green build) raced the delete and its marker read returned ENOENT; its evidence is the distinct filename plus the identical tree, verified by marker two runs earlier.

Unit suitescripts/__tests__/vite-objectstack-client-dist.test.ts, 19 cases: inert-when-unset (incl. the real console config's baseline alias and absent server.fs), all three valid spellings against the real installed client, the pnpm-symlink counter-probe, peerDependencies deliberately not demanded, and every refusal asserted for both shapes.

Reverse verification, direction predicted before each run, one prediction wrong and kept:

ablationpredictedmeasured
start the walk at resolved even for a fileredgreen 19/19 — the walk is indifferent: …/index.mjs/package.json simply misses and the next iteration lands on …/dist. The branch is explicitness, not the mechanism. Recorded in the suite header rather than deleted.
skip validation entirely for the entry-file spelling (the card's named defect class)red3 red, incl. refuses an out-of-tree copy given as an entry file — the pin the first leg was reaching for
drop realpathSyncred5 red, every one "a VALID override is rejected"

Each ablation restored under a trap … EXIT INT TERM, with the mutation proven on disk by counting the removed and introduced text before reading any result.

Gates (exit code captured before any pipe): vitest run scripts/__tests__/vite-objectstack-client-dist.test.ts scripts/__tests__/vite-objectstack-spec-dist.test.tsTest Files 2 passed (2) / Tests 52 passed (52) (the spec suite runs because it pins the console config's baseline surfaces) · check-control-bytes✅ OK (scanned 5059 tracked text file(s)) · check-lint-coverage✅ 46/46 · check-type-check-coverage✅ 45/46 … 0 errors outstanding · check-changeset-presence✅ No source of a released package changed in this range, so no changeset is owed · tsc -p tsconfig.scripts.json → 0 · eslint on the two new scripts/ files → 0 · eslint apps/console/vite.config.ts → 0 · apps/consoletsc -b tsconfig.node.json --force → 0.

One declared narrowing.pnpm --filter @object-ui/console type-check runs tsc --noEmit && tsc -b tsconfig.node.json --force; the first half fails in this worktree with TS2307 Cannot find module '@object-ui/…' across src/*.tsx — unbuilt workspace dependencies, which CI resolves by building through turbo's pipeline before type-checking. It cannot be this diff: that program's include is ["src", "dev"], and none of the three changed files is in it. The program that does contain them — tsconfig.node.json, whose include globs ../../scripts/vite-*.ts — was run in full and is green.

No changeset: nothing under a released package's src/ changed, and check-changeset-presence agrees.


Generated by Claude Code

The client-dist hook was a bare string alias: no existence check, no
manifest read, no dependency check. Pointed at an out-of-tree client
build — the situation the hook exists for, where a reachable
node_modules is absent — `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, from a build whose output
never named the variable.
The value is now resolved and validated before it is aliased. One
upward walk handles all three legal spellings — a package directory,
its dist/, or a built entry file inside it — and stops at the nearest
ancestor manifest that IS @objectstack/client, so the entry-file
spelling cannot silently resolve to "nothing to check". That package's
own declared dependencies must then resolve from where it lives.
The check is re-stated beside the client hook rather than extracted
from the spec hook: the two are not symmetrical (18 derived aliases
from an exports map vs. one alias over a wider accepted input), and a
shared helper would impose the spec hook's assumptions on this one.
Alias target, fs.allow values and inert-when-unset behaviour are
unchanged; only the realpath'd package directory used for the
dependency walk is new, and it is load-bearing — under pnpm the
installed client is a symlink whose ancestors never include the store
directory holding its dependencies, so without it a VALID override is
rejected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
@yinlianghui-twClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM: ACCEPT

Ruled shape taken exactly: validation in a new sibling module, scripts/vite-objectstack-spec-dist.tsuntouched. No premature abstraction across two hooks that are not symmetrical.

The directory-or-entry-file ambiguity is solved by one idea, not two branches

I flagged this as the substance of the card. Your answer — one upward walk stopping at the nearest ancestor manifest whose name IS @objectstack/client — collapses all three spellings (package directory, its dist/, an entry file inside it) onto the same package without special-casing any of them. That is better than the branch I imagined, and it is why ablation (a) behaved the way it did.

⚠️ You predicted red, measured green, and reported it

Ablation (a) — start the walk at resolved even for a file — you predicted red and observed 19/19 green. Rather than quietly deleting the branch or re-running until something confirmed the prediction, you diagnosed it: the walk is indifferent, because .../index.mjs/package.json simply misses and the next iteration lands on .../dist. So that branch is explicitness, not mechanism — and you wrote that into both the module and the suite header instead of leaving a reader to assume it is load-bearing.

Then you did the thing that actually mattered: found the ablation that does target the defect class — skip validation entirely for the entry-file spelling → 3 red, including "refuses an out-of-tree copy given as an entry file".

A failed prediction reported and explained is worth more than a clean one, and this is the second time today a dev has caught a control that proved nothing. It is the difference between a suite that passes and a suite that measures.

realpathSync is load-bearing in the dangerous direction

Ablation (c) — drop it → 5 red, every one of them "a VALID override is rejected". Without the realpath'd package dir, pnpm's symlinked install fails the dependency walk, so the hook would refuse a correct override. That is the failure mode that would have made this check worse than useless: it exists so a developer can exercise a locally built client, and a check that refuses valid ones destroys the hook's purpose while looking like safety.

Proving non-vacuity in that direction — not just "it catches the bad case" — is what makes this landable.

The fix needs no un-masking, and you established that rather than assuming it

I warned that #6093's masking might hide your refusal. You showed it cannot: the throw happens at config load, before any plugin runs. Clean reasoning, and it means this PR is independently verifiable regardless of #6093's state.

Two corrections to the card, both measured

  1. The card says the build "reads as success". More precisely: the process does exit non-zero today, but for reasons that never name the override — masked, it dies in ineffective-dynamic-import-ledger's closeBundle counter-probe replaces the real error whenever an earlier console plugin fails the build #6093's closeBundle listing packages/fields widgets; unmasked, vite's own "Rolldown failed to resolve import" surfaces. The card's written-bundle half is exactly right either way, and that is the half that matters: framework-Lm271O55.js carrying e(\@objectstack/spec/data`)` is a browser bundle no browser can load.
  2. On option 3 you filed nothing, and I agree. You measured that the console build already fails on unresolved bare imports (vite/plugin-react promotes the resolve warning to an error) — the only reason nobody sees it is ineffective-dynamic-import-ledger's closeBundle counter-probe replaces the real error whenever an earlier console plugin fails the build #6093's closeBundle error arriving first, which is already dispatched and now accepted as PR fix(tooling): stand the ineffective-dynamic-import counter-probe down on a build that never finished #6113. Filing option 3 would duplicate a card already in flight. Correctly routed to me rather than decided silently; no card needed.

Freshness, handled honestly

Four runs, four distinct .vite-temp names, and three of the four additionally had the compiled bundle grepped for the new module's own text (points at a client build with no reachable) found inside it — proving the config under test embedded your change. The fourth raced vite's delete (ENOENT on the marker read) and you said so, resting it on the distinct name plus the identical tree rather than claiming the stronger check you did not get.

The type-check narrowing is argued precisely: the red program's include is [src, dev] and none of the three changed files is in it; the program that does contain them (tsconfig.node.json, globbing ../../scripts/vite-*.ts) was run in full and is green. That is a real argument, not a shrug at a red command.

Landing

⏳ CI converging on 3f59d1979. The self check-in verifies every-check-green and lands it.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3221.0 KB3990.2 KB
Main entry chunk (gzip)153.7 KB350 KB
Entry fileindex-C9Ui0Fkk.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.15KB114.53KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)166.86KB46.08KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.08KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui-tw
yinlianghui-tw marked this pull request as ready for review August 24, 2026 18:17
@yinlianghui-tw
yinlianghui-tw added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit bfd0229Aug 24, 2026
22 checks passed
@yinlianghui-tw
yinlianghui-tw deleted the claude/issue-6094-client-dist-dep-validation branch August 24, 2026 18:29
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OBJECTSTACK_CLIENT_DIST has no dependency-resolution validation, so an out-of-tree client override silently produces a broken bundle

2 participants

@yinlianghui-tw@claude