From fe98b4c7beedd34612f0cfde35cbb70f3a0f8d56 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 30 Jul 2026 13:18:52 -0500 Subject: [PATCH 01/14] host: Prototype bundling @cardstack/base into the host build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base-realm modules are compiled into the host bundle (eager import.meta.glob over packages/base) and registered as loader shims at loader creation, so host imports of @cardstack/base/* resolve to compiled-in modules instead of fetching realm-server-transpiled source per loader generation. Supporting changes the bundling surfaces: - base modules imported several type-only names as value imports (babel's TS transform elides them; rolldown resolves them and fails on the missing runtime exports) — converted to `import type` / inline `type` in 15 base files - color.gts imported siblings via @cardstack/base/* specifiers; converted to relative imports like the rest of the package - date-fns is a real dependency of base source, declared it (it was previously satisfied only by the host's runtime shim) - vite resolve.alias maps @cardstack/boxel-host/{tools,commands}/* to app/tools/* — the same 1:1 mapping shimHostTools provides at runtime - https:// imports (currency.gts's esm.run dependency) are externalized so the browser fetches them at chunk load, as the loader-served module does Known semantic shifts, to evaluate on this branch: base modules become singletons shared across loader generations (loader resets no longer re-evaluate base module state), and editing base source in a running realm no longer affects the host runtime without a rebuild. Co-Authored-By: Claude Fable 5 --- packages/base/audio-file-def.gts | 2 +- packages/base/brand-guide.gts | 2 +- packages/base/card-api.gts | 18 +++++------ packages/base/cards-grid.gts | 2 +- .../components/advanced-color-picker.gts | 4 +-- .../components/color-wheel-picker.gts | 2 +- .../color-field/components/slider-picker.gts | 4 +-- packages/base/color.gts | 4 +-- packages/base/csv-file-def.gts | 2 +- packages/base/field-component.gts | 8 ++--- packages/base/json-file-def.gts | 2 +- packages/base/links-to-editor.gts | 2 +- packages/base/links-to-many-component.gts | 8 ++--- packages/base/markdown-file-def.gts | 2 +- packages/base/package.json | 1 + packages/base/text-file-def.gts | 2 +- packages/base/ts-file-def.gts | 2 +- packages/base/workspace.gts | 2 +- .../host/app/lib/bundled-base-modules.d.ts | 2 ++ packages/host/app/lib/bundled-base-modules.js | 14 ++++++++ packages/host/app/lib/bundled-base.ts | 32 +++++++++++++++++++ packages/host/app/services/loader-service.ts | 5 +++ packages/host/vite.config.mjs | 14 ++++++++ pnpm-lock.yaml | 3 ++ 24 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 packages/host/app/lib/bundled-base-modules.d.ts create mode 100644 packages/host/app/lib/bundled-base-modules.js create mode 100644 packages/host/app/lib/bundled-base.ts diff --git a/packages/base/audio-file-def.gts b/packages/base/audio-file-def.gts index 2b693bf9a12..ec8f2d02018 100644 --- a/packages/base/audio-file-def.gts +++ b/packages/base/audio-file-def.gts @@ -1,6 +1,6 @@ import MusicIcon from '@cardstack/boxel-icons/music'; import { - BaseDefComponent, + type BaseDefComponent, Component, NumberField, contains, diff --git a/packages/base/brand-guide.gts b/packages/base/brand-guide.gts index 2e1b982d947..f0880d04fe2 100644 --- a/packages/base/brand-guide.gts +++ b/packages/base/brand-guide.gts @@ -31,7 +31,7 @@ import { buildCssVariableName, sanitizeHtmlSafe, eq, - CssVariableEntry, + type CssVariableEntry, } from '@cardstack/boxel-ui/helpers'; import { cardTypeDisplayName } from '@cardstack/runtime-common'; diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index cdbff4f60fd..b3de6403b59 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -27,8 +27,8 @@ import { baseRef, CardContextName, CardError, - CodeRef, - ToolContext, + type CodeRef, + type ToolContext, Deferred, byteStreamToUint8Array, fields, @@ -55,12 +55,12 @@ import { loadCardDocument, Loader, localId, - LocalPath, + type LocalPath, meta, primitive, realmURL, relativeTo, - SingleCardDocument, + type SingleCardDocument, uuidv4, NumberSerializer, type Format, @@ -86,9 +86,9 @@ import { FileMetaResourceType, CardResourceType, loadFileMetaDocument, - CardResource, - LooseLinkableResource, - LooseSingleResourceDocument, + type CardResource, + type LooseLinkableResource, + type LooseSingleResourceDocument, shouldTrackRuntimeModuleGraph, shouldTrackRuntimeRelationship, trackRuntimeFileDependency, @@ -208,8 +208,8 @@ import { TextInputValidator } from './text-input-validator'; import { type GetMenuItemParams, getDefaultCardMenuItems } from './menu-items'; import { getDefaultFileMenuItems } from './file-menu-items'; import { - LinkableDocument, - SingleFileMetaDocument, + type LinkableDocument, + type SingleFileMetaDocument, } from '@cardstack/runtime-common/document-types'; import type { MarkdownEmbedChooser } from '@cardstack/runtime-common/bfm-card-references'; import type { FileMetaResource } from '@cardstack/runtime-common'; diff --git a/packages/base/cards-grid.gts b/packages/base/cards-grid.gts index a4f9c1491f6..70d350bf041 100644 --- a/packages/base/cards-grid.gts +++ b/packages/base/cards-grid.gts @@ -25,7 +25,7 @@ import { subscribeToRealm, codeRefFromInternalKey, type Query, - CardErrorJSONAPI, + type CardErrorJSONAPI, } from '@cardstack/runtime-common'; import CardsGridLayout, { diff --git a/packages/base/color-field/components/advanced-color-picker.gts b/packages/base/color-field/components/advanced-color-picker.gts index ac79db7b084..1c7eb427815 100644 --- a/packages/base/color-field/components/advanced-color-picker.gts +++ b/packages/base/color-field/components/advanced-color-picker.gts @@ -13,10 +13,10 @@ import type { ColorFieldConfiguration } from '../util/color-utils'; import { parseCssColor, parseCssColorSafe } from '../util/color-utils'; import { detectColorFormat, - RichColorFormat, + type RichColorFormat, hexToRgba, hsvToRgb, - RGBA, + type RGBA, rgbaToFormatString, rgbaToHexString, rgbaToHsl, diff --git a/packages/base/color-field/components/color-wheel-picker.gts b/packages/base/color-field/components/color-wheel-picker.gts index 4e17071a2bf..87139e990fd 100644 --- a/packages/base/color-field/components/color-wheel-picker.gts +++ b/packages/base/color-field/components/color-wheel-picker.gts @@ -13,7 +13,7 @@ import type { import { detectColorFormat, hslToRgb, - RGBA, + type RGBA, rgbaToFormatString, rgbaToHsv, } from '@cardstack/boxel-ui/helpers'; diff --git a/packages/base/color-field/components/slider-picker.gts b/packages/base/color-field/components/slider-picker.gts index 252e18d46cd..f20686bb8b1 100644 --- a/packages/base/color-field/components/slider-picker.gts +++ b/packages/base/color-field/components/slider-picker.gts @@ -10,8 +10,8 @@ import type Owner from '@ember/owner'; import type { ColorFieldSignature } from '../util/color-field-signature'; import { parseCssColor, - SliderColorFormat, - SliderVariantConfiguration, + type SliderColorFormat, + type SliderVariantConfiguration, } from '../util/color-utils'; import { detectColorFormat, diff --git a/packages/base/color.gts b/packages/base/color.gts index 3d1977d5004..b2d71ebd528 100644 --- a/packages/base/color.gts +++ b/packages/base/color.gts @@ -1,5 +1,5 @@ -import { Component } from '@cardstack/base/card-api'; -import StringField from '@cardstack/base/string'; +import { Component } from './card-api'; +import StringField from './string'; import { Swatch } from '@cardstack/boxel-ui/components'; import { markdownEscape } from '@cardstack/boxel-ui/helpers'; import PaletteIcon from '@cardstack/boxel-icons/palette'; diff --git a/packages/base/csv-file-def.gts b/packages/base/csv-file-def.gts index 2c9a2732e57..32219592b09 100644 --- a/packages/base/csv-file-def.gts +++ b/packages/base/csv-file-def.gts @@ -2,7 +2,7 @@ import { byteStreamToUint8Array } from '@cardstack/runtime-common'; import { htmlSafe } from '@ember/template'; import CsvIcon from '@cardstack/boxel-icons/csv'; import { - BaseDefComponent, + type BaseDefComponent, Component, StringField, contains, diff --git a/packages/base/field-component.gts b/packages/base/field-component.gts index c558492cc66..6615d6611ea 100644 --- a/packages/base/field-component.gts +++ b/packages/base/field-component.gts @@ -8,10 +8,10 @@ import { type BaseDefComponent, type BaseDefConstructor, type Theme, - CardContext, + type CardContext, formats, - FieldFormats, - CardCrudFunctions, + type FieldFormats, + type CardCrudFunctions, } from './card-api'; import { isCard, isCompoundField } from './field-support'; import { @@ -23,7 +23,7 @@ import { isCardInstance, type CodeRef, type Permissions, - ResolvedCodeRef, + type ResolvedCodeRef, CardCrudFunctionsContextName, } from '@cardstack/runtime-common'; import type { ComponentLike } from '@glint/template'; diff --git a/packages/base/json-file-def.gts b/packages/base/json-file-def.gts index 05e59eb6e02..c868220abd8 100644 --- a/packages/base/json-file-def.gts +++ b/packages/base/json-file-def.gts @@ -2,7 +2,7 @@ import { byteStreamToUint8Array } from '@cardstack/runtime-common'; import { htmlSafe } from '@ember/template'; import JsonIcon from '@cardstack/boxel-icons/json'; import { - BaseDefComponent, + type BaseDefComponent, Component, StringField, contains, diff --git a/packages/base/links-to-editor.gts b/packages/base/links-to-editor.gts index 2c22b582412..de39f57a339 100644 --- a/packages/base/links-to-editor.gts +++ b/packages/base/links-to-editor.gts @@ -14,7 +14,7 @@ import { type Field, type CardContext, type LinkableDefConstructor, - CreateCardFn, + type CreateCardFn, isFileDef, } from './card-api'; import { diff --git a/packages/base/links-to-many-component.gts b/packages/base/links-to-many-component.gts index d742d03eabd..53578adde76 100644 --- a/packages/base/links-to-many-component.gts +++ b/packages/base/links-to-many-component.gts @@ -11,8 +11,8 @@ import { type FieldDef, type Format, type LinkableDefConstructor, - CreateCardFn, - CardCrudFunctions, + type CreateCardFn, + type CardCrudFunctions, isFileDef, brokenLinkFormat, } from './card-api'; @@ -22,7 +22,7 @@ import { } from './field-support'; import { rawArrayValues } from './watched-array'; import { - BoxComponentSignature, + type BoxComponentSignature, CardCrudFunctionsConsumer, DefaultFormatsConsumer, PermissionsConsumer, @@ -49,7 +49,7 @@ import { type ResolvedCodeRef, uuidv4, CardCrudFunctionsContextName, - CardErrorJSONAPI, + type CardErrorJSONAPI, cardTypeName, } from '@cardstack/runtime-common'; import { diff --git a/packages/base/markdown-file-def.gts b/packages/base/markdown-file-def.gts index 64036c376e3..970b386d365 100644 --- a/packages/base/markdown-file-def.gts +++ b/packages/base/markdown-file-def.gts @@ -11,7 +11,7 @@ import { } from '@cardstack/runtime-common'; import MarkdownIcon from '@cardstack/boxel-icons/align-box-left-middle'; import { - BaseDefComponent, + type BaseDefComponent, CardDef, Component, StringField, diff --git a/packages/base/package.json b/packages/base/package.json index 542b669c508..53ac09eeac2 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -15,6 +15,7 @@ "@types/lodash-es": "catalog:", "awesome-phonenumber": "catalog:", "concurrently": "catalog:", + "date-fns": "catalog:", "ember-cli-htmlbars": "^6.3.0", "ember-concurrency": "catalog:", "ember-css-url": "^1.0.0", diff --git a/packages/base/text-file-def.gts b/packages/base/text-file-def.gts index c76f9994466..09d1c34d0c7 100644 --- a/packages/base/text-file-def.gts +++ b/packages/base/text-file-def.gts @@ -1,7 +1,7 @@ import { byteStreamToUint8Array } from '@cardstack/runtime-common'; import TextFileIcon from '@cardstack/boxel-icons/file-text'; import { - BaseDefComponent, + type BaseDefComponent, Component, StringField, contains, diff --git a/packages/base/ts-file-def.gts b/packages/base/ts-file-def.gts index 9a2fc1f1652..8f7ac6d3ed2 100644 --- a/packages/base/ts-file-def.gts +++ b/packages/base/ts-file-def.gts @@ -2,7 +2,7 @@ import { byteStreamToUint8Array } from '@cardstack/runtime-common'; import { htmlSafe } from '@ember/template'; import FileCodeIcon from '@cardstack/boxel-icons/file-code'; import { - BaseDefComponent, + type BaseDefComponent, Component, StringField, contains, diff --git a/packages/base/workspace.gts b/packages/base/workspace.gts index c8801314fcd..c5186a2f171 100644 --- a/packages/base/workspace.gts +++ b/packages/base/workspace.gts @@ -46,7 +46,7 @@ import { type Query, type Filter, type CodeRef, - CardErrorJSONAPI, + type CardErrorJSONAPI, } from '@cardstack/runtime-common'; import CardsGridLayout, { diff --git a/packages/host/app/lib/bundled-base-modules.d.ts b/packages/host/app/lib/bundled-base-modules.d.ts new file mode 100644 index 00000000000..54536dfaeed --- /dev/null +++ b/packages/host/app/lib/bundled-base-modules.d.ts @@ -0,0 +1,2 @@ +declare const BASE_MODULES: Record>; +export default BASE_MODULES; diff --git a/packages/host/app/lib/bundled-base-modules.js b/packages/host/app/lib/bundled-base-modules.js new file mode 100644 index 00000000000..35d8a4570bf --- /dev/null +++ b/packages/host/app/lib/bundled-base-modules.js @@ -0,0 +1,14 @@ +// The base realm's modules, compiled into the host bundle. Lives in an +// untyped .js module because `import.meta.glob` is a vite build-time +// construct that ember-tsc (module: nodenext, CJS-flavored app files) +// rejects; the .d.ts sibling carries the type. +const BASE_MODULES = import.meta.glob( + [ + '../../../base/**/*.{gts,ts}', + '!../../../base/node_modules/**', + '!../../../base/**/*.d.ts', + ], + { eager: true }, +); + +export default BASE_MODULES; diff --git a/packages/host/app/lib/bundled-base.ts b/packages/host/app/lib/bundled-base.ts new file mode 100644 index 00000000000..e4adf11783c --- /dev/null +++ b/packages/host/app/lib/bundled-base.ts @@ -0,0 +1,32 @@ +// Bundles the @cardstack/base realm's modules into the host build and +// registers them as loader shims, so importing a base module (whether as +// `@cardstack/base/card-api` or its resolved base-realm URL) resolves to +// the compiled-in module instead of a network fetch of realm-server- +// transpiled source. This trades two properties of the fetched path for +// speed: +// +// - Base modules become singletons shared by every loader generation. +// A loader reset (test isolation, code-change flush) no longer +// re-evaluates base module state; whatever module-level state card-api +// and friends hold persists across resets. +// - The running base realm's *source* is no longer what executes in the +// host: editing base code in a realm (or reindexing it) has no effect +// on the host runtime until the host is rebuilt. Non-host consumers of +// the base realm (indexing, prerender) are unaffected. +// +// The eager glob in bundled-base-modules.js compiles every base module +// into the host's initial bundle through the same vite/embroider pipeline +// as host app code. + +import type { Loader } from '@cardstack/runtime-common/loader'; + +import BASE_MODULES from './bundled-base-modules'; + +const GLOB_PREFIX = '../../../base/'; + +export function shimBundledBase(loader: Loader) { + for (let [path, module] of Object.entries(BASE_MODULES)) { + let name = path.slice(GLOB_PREFIX.length).replace(/\.(gts|ts)$/, ''); + loader.shimModule(`@cardstack/base/${name}`, module); + } +} diff --git a/packages/host/app/services/loader-service.ts b/packages/host/app/services/loader-service.ts index a307ce6c8e5..3843cc817ba 100644 --- a/packages/host/app/services/loader-service.ts +++ b/packages/host/app/services/loader-service.ts @@ -18,6 +18,7 @@ import { import { Loader } from '@cardstack/runtime-common/loader'; import config from '@cardstack/host/config/environment'; +import { shimBundledBase } from '@cardstack/host/lib/bundled-base'; import { clearKnownFileMetaUrls } from '@cardstack/host/lib/known-file-meta-urls'; import { authErrorEventMiddleware } from '../utils/auth-error-guard'; @@ -196,6 +197,10 @@ export default class LoaderService extends Service { ), virtualNetwork: this.network.virtualNetwork, }); + // Base-realm modules ship inside the host bundle; register them so + // this loader (and every clone descended from it) serves them without + // fetching from the base realm. + shimBundledBase(loader); return loader; } diff --git a/packages/host/vite.config.mjs b/packages/host/vite.config.mjs index e7ce9f4d327..becccf9d709 100644 --- a/packages/host/vite.config.mjs +++ b/packages/host/vite.config.mjs @@ -236,6 +236,11 @@ export default defineConfig(({ mode }) => ({ build: { minify: false, rolldownOptions: { + // Bundled base-realm modules may import directly from an https URL + // (e.g. currency.gts's esm.run import). Leave those imports verbatim + // in the output; the browser fetches them at chunk load, matching how + // the loader-served module behaves. + external: [/^https:\/\//], output: { keepNames: true, ...(mode === 'production' ? { minify: true } : {}), @@ -269,6 +274,15 @@ export default defineConfig(({ mode }) => ({ }, resolve: { alias: [ + // Base-realm modules (bundled via app/lib/bundled-base.ts) import + // host tools as `@cardstack/boxel-host/tools/*` (and the pre-rename + // `commands/*` spelling). At runtime the virtual network shims those + // specifiers to app/tools modules (see app/tools/index.ts); this + // alias gives the bundler the same 1:1 mapping. + { + find: /^@cardstack\/boxel-host\/(?:tools|commands)\//, + replacement: `${__dirname}/app/tools/`, + }, { find: 'path', replacement: require.resolve('path-browserify') }, { find: 'stream', replacement: require.resolve('stream-browserify') }, { find: /^util$/, replacement: require.resolve('util/') }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c238ef6ca67..f3fe6bb97c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -922,6 +922,9 @@ importers: concurrently: specifier: 'catalog:' version: 8.2.2 + date-fns: + specifier: 'catalog:' + version: 2.30.0 ember-cli-htmlbars: specifier: ^6.3.0 version: 6.3.0 From 48d56a1f6efbc74ca4f1d7a8ce1b09fc44f1d6e9 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 30 Jul 2026 15:45:52 -0500 Subject: [PATCH 02/14] host: Give bundled base modules a loader fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base modules discover their loader via import.meta.loader, which only exists when a Loader evaluates the module source. Modules compiled into the host bundle are evaluated by the platform's module system, so every myLoader() there returned undefined and loadCardDef threw "Cannot read properties of undefined (reading 'getVirtualNetwork')" on each base-realm instance deserialize. The prerender renders with the host dist, so base indexing failed, realms never reached readiness, and every stack-booting CI job timed out. - Loader gains setForBundledModules/forBundledModules statics; the host loader-service publishes each loader that becomes its active one (initial, clones, resets) - the seven myLoader() copies in base fall back to Loader.forBundledModules() when import.meta.loader is absent, and throw a descriptive error when neither is available - workspace.gts derived its own module URL from import.meta.url, which a bundler reports as the compiled chunk's URL; it now states its canonical base-realm URL directly - the bundled-base header no longer claims prerender/indexing are unaffected — they render with the host dist, so they see the bundled base too Co-Authored-By: Claude Fable 5 --- packages/base/card-api.gts | 18 ++++++++++++------ packages/base/card-serialization.ts | 17 +++++++++++------ packages/base/contains-many-component.gts | 18 ++++++++++++------ packages/base/links-to-editor.gts | 18 ++++++++++++------ packages/base/links-to-many-component.gts | 18 ++++++++++++------ packages/base/skill-frontmatter-field.gts | 20 +++++++++++++------- packages/base/spec.gts | 18 ++++++++++++------ packages/base/workspace.gts | 12 ++++++------ packages/host/app/lib/bundled-base.ts | 5 +++-- packages/host/app/services/loader-service.ts | 15 +++++++++++++-- packages/runtime-common/loader.ts | 18 ++++++++++++++++++ 11 files changed, 124 insertions(+), 53 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index b3de6403b59..d3c181974d2 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -4921,16 +4921,22 @@ export function resolveRef( } function myLoader(): Loader { - // we know this code is always loaded by an instance of our Loader, which sets - // import.meta.loader. + // A Loader that evaluates this module injects `import.meta.loader`. When + // the module is compiled into the host bundle instead, the platform + // evaluates it and no loader is injected; the host publishes the loader + // bundled modules should use (see Loader.setForBundledModules). - // When type-checking realm-server, tsc sees this file and thinks - // it will be transpiled to CommonJS and so it complains about this line. But - // this file is always loaded through our loader and always has access to import.meta. + // When type-checking realm-server, tsc sees this file and thinks it will + // be transpiled to CommonJS and so it complains about import.meta. // @ts-ignore - return (import.meta as any).loader; + let loader = (import.meta as any).loader ?? Loader.forBundledModules(); + if (!loader) { + throw new Error('No Loader is available to this module'); + } + return loader; } + class FallbackCardStore implements CardStore { #instances: Map = new Map(); #fileMetaInstances: Map = new Map(); diff --git a/packages/base/card-serialization.ts b/packages/base/card-serialization.ts index 3f8dd66a5ed..b4f549bdeab 100644 --- a/packages/base/card-serialization.ts +++ b/packages/base/card-serialization.ts @@ -92,14 +92,19 @@ export const deserialize = Symbol.for('cardstack-deserialize'); // --- Serialization Functions --- function myLoader(): Loader { - // we know this code is always loaded by an instance of our Loader, which sets - // import.meta.loader. + // A Loader that evaluates this module injects `import.meta.loader`. When + // the module is compiled into the host bundle instead, the platform + // evaluates it and no loader is injected; the host publishes the loader + // bundled modules should use (see Loader.setForBundledModules). - // When type-checking realm-server, tsc sees this file and thinks - // it will be transpiled to CommonJS and so it complains about this line. But - // this file is always loaded through our loader and always has access to import.meta. + // When type-checking realm-server, tsc sees this file and thinks it will + // be transpiled to CommonJS and so it complains about import.meta. // @ts-ignore - return (import.meta as any).loader; + let loader = (import.meta as any).loader ?? Loader.forBundledModules(); + if (!loader) { + throw new Error('No Loader is available to this module'); + } + return loader; } export async function cardClassFromResource( diff --git a/packages/base/contains-many-component.gts b/packages/base/contains-many-component.gts index 3e045e9ef1e..f054be40681 100644 --- a/packages/base/contains-many-component.gts +++ b/packages/base/contains-many-component.gts @@ -447,12 +447,18 @@ export function getContainsManyComponent({ } function myLoader(): Loader { - // we know this code is always loaded by an instance of our Loader, which sets - // import.meta.loader. + // A Loader that evaluates this module injects `import.meta.loader`. When + // the module is compiled into the host bundle instead, the platform + // evaluates it and no loader is injected; the host publishes the loader + // bundled modules should use (see Loader.setForBundledModules). - // When type-checking realm-server, tsc sees this file and thinks - // it will be transpiled to CommonJS and so it complains about this line. But - // this file is always loaded through our loader and always has access to import.meta. + // When type-checking realm-server, tsc sees this file and thinks it will + // be transpiled to CommonJS and so it complains about import.meta. // @ts-ignore - return (import.meta as any).loader; + let loader = (import.meta as any).loader ?? Loader.forBundledModules(); + if (!loader) { + throw new Error('No Loader is available to this module'); + } + return loader; } + diff --git a/packages/base/links-to-editor.gts b/packages/base/links-to-editor.gts index de39f57a339..d633c916217 100644 --- a/packages/base/links-to-editor.gts +++ b/packages/base/links-to-editor.gts @@ -282,12 +282,18 @@ export class LinksToEditor extends GlimmerComponent { } function myLoader(): Loader { - // we know this code is always loaded by an instance of our Loader, which sets - // import.meta.loader. + // A Loader that evaluates this module injects `import.meta.loader`. When + // the module is compiled into the host bundle instead, the platform + // evaluates it and no loader is injected; the host publishes the loader + // bundled modules should use (see Loader.setForBundledModules). - // When type-checking realm-server, tsc sees this file and thinks - // it will be transpiled to CommonJS and so it complains about this line. But - // this file is always loaded through our loader and always has access to import.meta. + // When type-checking realm-server, tsc sees this file and thinks it will + // be transpiled to CommonJS and so it complains about import.meta. // @ts-ignore - return (import.meta as any).loader; + let loader = (import.meta as any).loader ?? Loader.forBundledModules(); + if (!loader) { + throw new Error('No Loader is available to this module'); + } + return loader; } + diff --git a/packages/base/links-to-many-component.gts b/packages/base/links-to-many-component.gts index 53578adde76..9d10b215fa5 100644 --- a/packages/base/links-to-many-component.gts +++ b/packages/base/links-to-many-component.gts @@ -825,12 +825,18 @@ export function getLinksToManyComponent({ } function myLoader(): Loader { - // we know this code is always loaded by an instance of our Loader, which sets - // import.meta.loader. + // A Loader that evaluates this module injects `import.meta.loader`. When + // the module is compiled into the host bundle instead, the platform + // evaluates it and no loader is injected; the host publishes the loader + // bundled modules should use (see Loader.setForBundledModules). - // When type-checking realm-server, tsc sees this file and thinks - // it will be transpiled to CommonJS and so it complains about this line. But - // this file is always loaded through our loader and always has access to import.meta. + // When type-checking realm-server, tsc sees this file and thinks it will + // be transpiled to CommonJS and so it complains about import.meta. // @ts-ignore - return (import.meta as any).loader; + let loader = (import.meta as any).loader ?? Loader.forBundledModules(); + if (!loader) { + throw new Error('No Loader is available to this module'); + } + return loader; } + diff --git a/packages/base/skill-frontmatter-field.gts b/packages/base/skill-frontmatter-field.gts index 4bbc93cc293..f7300267102 100644 --- a/packages/base/skill-frontmatter-field.gts +++ b/packages/base/skill-frontmatter-field.gts @@ -3,7 +3,7 @@ import { codeRefWithAbsoluteIdentifier, getClass, rri, - type Loader, + Loader, type ResolvedCodeRef, type ToolContext, type ToolSchemaError, @@ -267,12 +267,18 @@ async function generateToolDefinitions( } function myLoader(): Loader { - // we know this code is always loaded by an instance of our Loader, which - // sets import.meta.loader. + // A Loader that evaluates this module injects `import.meta.loader`. When + // the module is compiled into the host bundle instead, the platform + // evaluates it and no loader is injected; the host publishes the loader + // bundled modules should use (see Loader.setForBundledModules). - // When type-checking realm-server, tsc sees this file and thinks it will be - // transpiled to CommonJS and so it complains about this line. But this file - // is always loaded through our loader and always has access to import.meta. + // When type-checking realm-server, tsc sees this file and thinks it will + // be transpiled to CommonJS and so it complains about import.meta. // @ts-ignore - return (import.meta as any).loader; + let loader = (import.meta as any).loader ?? Loader.forBundledModules(); + if (!loader) { + throw new Error('No Loader is available to this module'); + } + return loader; } + diff --git a/packages/base/spec.gts b/packages/base/spec.gts index 35d649fedd6..7867776ed0e 100644 --- a/packages/base/spec.gts +++ b/packages/base/spec.gts @@ -1109,12 +1109,18 @@ function getIcon(specType: string) { } function myLoader(): Loader { - // we know this code is always loaded by an instance of our Loader, which sets - // import.meta.loader. + // A Loader that evaluates this module injects `import.meta.loader`. When + // the module is compiled into the host bundle instead, the platform + // evaluates it and no loader is injected; the host publishes the loader + // bundled modules should use (see Loader.setForBundledModules). - // When type-checking realm-server, tsc sees this file and thinks - // it will be transpiled to CommonJS and so it complains about this line. But - // this file is always loaded through our loader and always has access to import.meta. + // When type-checking realm-server, tsc sees this file and thinks it will + // be transpiled to CommonJS and so it complains about import.meta. // @ts-ignore - return (import.meta as any).loader; + let loader = (import.meta as any).loader ?? Loader.forBundledModules(); + if (!loader) { + throw new Error('No Loader is available to this module'); + } + return loader; } + diff --git a/packages/base/workspace.gts b/packages/base/workspace.gts index c5186a2f171..486ac19f750 100644 --- a/packages/base/workspace.gts +++ b/packages/base/workspace.gts @@ -38,6 +38,7 @@ import { codeRef, specRef, baseCardRef, + baseRealm, baseRealmRRI, isCardInstance, SupportedMimeType, @@ -75,12 +76,11 @@ import { MarkdownDef } from './markdown-file-def'; // realm README import type { RealmEventContent } from './matrix-event'; import { Spec } from './spec'; -// This file is always loaded through the Boxel loader, which supplies -// `import.meta`. When type-checking, tsc sees the file as CommonJS output and -// rejects the meta-property, so suppress it — the same pattern used elsewhere -// in packages/base. -// @ts-ignore -const here: string = (import.meta as any).url; +// This module's canonical URL, the base for sibling code refs below. +// `import.meta.url` can't provide it in every evaluation environment (a +// bundler reports the compiled chunk's URL, not the realm module's), so +// state it directly. +const here: string = new URL('./workspace', baseRealm.url).href; const [, StripView, GridView] = VIEW_OPTIONS; diff --git a/packages/host/app/lib/bundled-base.ts b/packages/host/app/lib/bundled-base.ts index e4adf11783c..f469c89e81d 100644 --- a/packages/host/app/lib/bundled-base.ts +++ b/packages/host/app/lib/bundled-base.ts @@ -11,8 +11,9 @@ // and friends hold persists across resets. // - The running base realm's *source* is no longer what executes in the // host: editing base code in a realm (or reindexing it) has no effect -// on the host runtime until the host is rebuilt. Non-host consumers of -// the base realm (indexing, prerender) are unaffected. +// until the host is rebuilt. This includes indexing — the prerender +// renders with the host dist, so index output reflects the bundled base, +// not the realm-served source. // // The eager glob in bundled-base-modules.js compiles every base module // into the host's initial bundle through the same vite/embroider pipeline diff --git a/packages/host/app/services/loader-service.ts b/packages/host/app/services/loader-service.ts index 3843cc817ba..d914f8f58b7 100644 --- a/packages/host/app/services/loader-service.ts +++ b/packages/host/app/services/loader-service.ts @@ -77,7 +77,9 @@ export default class LoaderService extends Service { log.debug(`resetting loader for session boundary (${reason ?? ''})`); this.clearSessionCaches(); let previous = this.loader; - this.loader = previous ? Loader.cloneLoader(previous) : this.makeInstance(); + this.loader = previous + ? this.trackCurrent(Loader.cloneLoader(previous)) + : this.makeInstance(); previous?.dispose(); } @@ -146,7 +148,7 @@ export default class LoaderService extends Service { let previous = this.loader; this.recordLoaderReplacement(previous, options?.codeChange); if (previous) { - this.loader = Loader.cloneLoader(previous); + this.loader = this.trackCurrent(Loader.cloneLoader(previous)); previous.dispose(); } else { this.loader = this.makeInstance(); @@ -201,6 +203,15 @@ export default class LoaderService extends Service { // this loader (and every clone descended from it) serves them without // fetching from the base realm. shimBundledBase(loader); + return this.trackCurrent(loader); + } + + // Bundled base modules can't discover their loader via + // `import.meta.loader` (the platform evaluated them, not a Loader), so + // every loader that becomes this service's active one is also published + // as the loader bundled modules fall back to. + private trackCurrent(loader: Loader): Loader { + Loader.setForBundledModules(loader); return loader; } diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index 98885716a76..c008250e0e9 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -421,6 +421,24 @@ export class Loader { return undefined; } + // Realm modules that a Loader evaluates discover their loader via + // `import.meta.loader`, which the Loader injects at eval time. Modules + // compiled into the host bundle instead (and registered as loader shims — + // see the host's bundled-base registration) are evaluated by the + // platform's module system, where `import.meta.loader` does not exist. + // The host publishes its active loader here so bundled modules can fall + // back to it; module code reads this only when `import.meta.loader` is + // absent. + static #forBundledModules: Loader | undefined; + + static setForBundledModules(loader: Loader) { + Loader.#forBundledModules = loader; + } + + static forBundledModules(): Loader | undefined { + return Loader.#forBundledModules; + } + async import( moduleIdentifier: string, dependencyTrackingContext?: RuntimeDependencyTrackingContext, From b516e6a0e6b1d07ae47ea61cd0c938f877bfab24 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 30 Jul 2026 17:49:14 -0500 Subject: [PATCH 03/14] host: Serve bundled base modules to every loader on the virtual network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled base modules were registered per loader by the loader service, so loaders constructed anywhere else — the test-realm adapter's loader, in-browser indexing loaders — fetched base modules from the realm server and evaluated a second copy of card-api. Card instances built by one copy fail field lookups performed by the other ("the card Object does not have a field ..."), which is what most of the host test failures reduced to. Registration now happens once, on the virtual network (network service, right after the @cardstack/base realm mapping it resolves against). The loader's module-fetch path consults the network's shim registry after its own per-loader shims, so every loader sharing the network serves the same compiled-in modules. The lookup lives in the module-fetch path rather than the network's fetch handlers deliberately: base-realm URLs also serve card-instance documents at extensionless URLs, and only the module path knows a request is for a module. Co-Authored-By: Claude Fable 5 --- packages/host/app/lib/bundled-base.ts | 11 ++++++++--- packages/host/app/services/loader-service.ts | 5 ----- packages/host/app/services/network.ts | 4 ++++ packages/runtime-common/loader.ts | 13 ++++++++++--- packages/runtime-common/package-shim-handler.ts | 10 ++++++++++ packages/runtime-common/virtual-network.ts | 9 +++++++++ 6 files changed, 41 insertions(+), 11 deletions(-) diff --git a/packages/host/app/lib/bundled-base.ts b/packages/host/app/lib/bundled-base.ts index f469c89e81d..55c141f5f15 100644 --- a/packages/host/app/lib/bundled-base.ts +++ b/packages/host/app/lib/bundled-base.ts @@ -19,15 +19,20 @@ // into the host's initial bundle through the same vite/embroider pipeline // as host app code. -import type { Loader } from '@cardstack/runtime-common/loader'; +import type { VirtualNetwork } from '@cardstack/runtime-common'; import BASE_MODULES from './bundled-base-modules'; const GLOB_PREFIX = '../../../base/'; -export function shimBundledBase(loader: Loader) { +// Registers on the virtual network (not per loader) so that every loader +// sharing the network serves the bundled modules — including loaders +// constructed outside the loader service (test-realm adapters, in-browser +// indexing). Must run after the network's `@cardstack/base/` realm mapping +// is registered, since shim identifiers resolve at registration time. +export function shimBundledBase(virtualNetwork: VirtualNetwork) { for (let [path, module] of Object.entries(BASE_MODULES)) { let name = path.slice(GLOB_PREFIX.length).replace(/\.(gts|ts)$/, ''); - loader.shimModule(`@cardstack/base/${name}`, module); + virtualNetwork.shimModule(`@cardstack/base/${name}`, module); } } diff --git a/packages/host/app/services/loader-service.ts b/packages/host/app/services/loader-service.ts index d914f8f58b7..90034bfcfaa 100644 --- a/packages/host/app/services/loader-service.ts +++ b/packages/host/app/services/loader-service.ts @@ -18,7 +18,6 @@ import { import { Loader } from '@cardstack/runtime-common/loader'; import config from '@cardstack/host/config/environment'; -import { shimBundledBase } from '@cardstack/host/lib/bundled-base'; import { clearKnownFileMetaUrls } from '@cardstack/host/lib/known-file-meta-urls'; import { authErrorEventMiddleware } from '../utils/auth-error-guard'; @@ -199,10 +198,6 @@ export default class LoaderService extends Service { ), virtualNetwork: this.network.virtualNetwork, }); - // Base-realm modules ship inside the host bundle; register them so - // this loader (and every clone descended from it) serves them without - // fetching from the base realm. - shimBundledBase(loader); return this.trackCurrent(loader); } diff --git a/packages/host/app/services/network.ts b/packages/host/app/services/network.ts index ba522683b24..f375702b1e3 100644 --- a/packages/host/app/services/network.ts +++ b/packages/host/app/services/network.ts @@ -13,6 +13,7 @@ import { import config from '@cardstack/host/config/environment'; +import { shimBundledBase } from '../lib/bundled-base'; import { shimExternals } from '../lib/externals'; import { authErrorEventMiddleware } from '../utils/auth-error-guard'; @@ -74,6 +75,9 @@ export default class NetworkService extends Service { '@cardstack/base/', resolvedBaseRealmURL.href, ); + // Base-realm modules ship inside the host bundle; any loader on this + // network serves them without fetching from the base realm. + shimBundledBase(virtualNetwork); shimExternals(virtualNetwork); virtualNetwork.addImportMap('@cardstack/boxel-icons/', (rest) => { return `${config.iconsURL}/@cardstack/boxel-icons/v1/icons/${rest}.js`; diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index c008250e0e9..3b30f5998ad 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -861,9 +861,16 @@ export class Loader { init?: RequestInit, ): Promise => { try { - let shimmedModule = this.moduleShims.get( - this.asRequest(urlOrRequest, init).url, - ); + let shimmedModule = + this.moduleShims.get(this.asRequest(urlOrRequest, init).url) ?? + // Modules shimmed on the virtual network (e.g. base modules + // compiled into the host bundle) are served to every loader + // sharing that network, including loaders constructed outside the + // host's loader service. This is a module-fetch path, so a shim + // registered under a realm URL can't shadow a card-instance GET. + (await this.virtualNetwork?.getShimmedModule( + this.asRequest(urlOrRequest, init).url, + )); if (shimmedModule) { let response = new Response(); (response as any)[Symbol.for('shimmed-module')] = shimmedModule; diff --git a/packages/runtime-common/package-shim-handler.ts b/packages/runtime-common/package-shim-handler.ts index 2cccd4c7707..c34f01fed2c 100644 --- a/packages/runtime-common/package-shim-handler.ts +++ b/packages/runtime-common/package-shim-handler.ts @@ -506,6 +506,16 @@ export class PackageShimHandler { } } + // Module lookup for callers outside the fetch pipeline (the Loader's + // module-fetch path asks the virtual network for shims registered here + // before going to the network). Unlike `handle`, this is not restricted + // to the fake packages origin: the caller vouches that the URL is a + // module request, so a shim registered under a realm URL can be served + // without risking shadowing a card-instance GET of the same URL. + async lookupModule(url: string): Promise { + return (await this.getModule(url)) ?? (await this.getModuleByPrefix(url)); + } + private async getModule(url: string): Promise { let key = trimModuleIdentifier(url); let resolver = this.moduleIds.get(key); diff --git a/packages/runtime-common/virtual-network.ts b/packages/runtime-common/virtual-network.ts index 86f3ff54ad1..a545a704d01 100644 --- a/packages/runtime-common/virtual-network.ts +++ b/packages/runtime-common/virtual-network.ts @@ -109,6 +109,15 @@ export class VirtualNetwork { this.packageShimHandler.shimAsyncModule(descriptor); } + // Lets a Loader serve a module shimmed on this network (under any URL, + // not just the fake packages origin) without a network fetch. Only the + // module-fetch path may call this: shims can be registered under realm + // URLs that also serve card instances, and only the caller knows the + // request is for a module rather than an instance document. + getShimmedModule(url: string): Promise { + return this.packageShimHandler.lookupModule(url); + } + addURLMapping(from: URL, to: URL) { this.urlMappings.push([from.href, to.href]); // unresolveURL chases through urlMappings (via resolveURLMapping), so a new From 8b8357a2c02a165e14ab5b00546096d5270244ba Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 31 Jul 2026 17:18:36 -0500 Subject: [PATCH 04/14] base: Align ember-provide-consume-context peer range with the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base declared ^0.7.0 while the host uses ^0.8.0, so pnpm installed both versions and the bundled host build resolved base's imports to its own 0.7.1 copy. Two copies mean two context registries: providers rendered from base components registered with the copy whose glimmer integration the app never booted, so consumers found no provided value and fell back to defaults. Concretely, DefaultFormatsConsumer read 'isolated' instead of the provided child format, and an unset linksTo then rendered DefaultCardDefTemplate with an undefined @model, crashing every indexing render of a card with links ("Cannot read properties of undefined (reading 'constructor')"). Under loader-served operation the mismatch was invisible — the runtime shim always supplied the host's copy regardless of what base declared. Bundling makes build-time resolution the binding one, so base's declared range now matches what the shim provides at runtime. With the ranges aligned pnpm dedupes to a single 0.8.0 install. Co-Authored-By: Claude Fable 5 --- packages/base/package.json | 2 +- pnpm-lock.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/base/package.json b/packages/base/package.json index 53ac09eeac2..1c72b6ce083 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -29,7 +29,7 @@ "yaml": "catalog:" }, "peerDependencies": { - "ember-provide-consume-context": "^0.7.0", + "ember-provide-consume-context": "^0.8.0", "ember-source": "catalog:", "lodash-es": "catalog:" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3fe6bb97c7..78805c32af8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -877,8 +877,8 @@ importers: packages/base: dependencies: ember-provide-consume-context: - specifier: ^0.7.0 - version: 0.7.1(@ember/test-helpers@5.4.3(@babel/core@7.29.7))(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5)) + specifier: ^0.8.0 + version: 0.8.0(@ember/test-helpers@5.4.3(@babel/core@7.29.7))(@glimmer/component@2.1.1)(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5)) ember-source: specifier: 'catalog:' version: 6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5) From 5fe4093ecfbb3b8221ca61bef2dbef7191165115 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 31 Jul 2026 17:47:39 -0500 Subject: [PATCH 05/14] base: Import Loader as a value in card-serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit card-serialization listed Loader in its import-type block, so the transpiled module had no runtime binding for it and myLoader's Loader.forBundledModules() fallback threw "ReferenceError: Loader is not defined" — every bundled deserialize through cardClassFromResource failed, which broke indexing of any card with linked fixtures (the operator-mode links/basics/ui failures). base has no type-check step, so nothing caught the type-only import being used as a value. Co-Authored-By: Claude Fable 5 --- packages/base/card-serialization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/base/card-serialization.ts b/packages/base/card-serialization.ts index b4f549bdeab..861990ab8e8 100644 --- a/packages/base/card-serialization.ts +++ b/packages/base/card-serialization.ts @@ -5,7 +5,6 @@ import type { CardResource, CardResourceMeta, FileMetaResource, - Loader, LooseCardResource, LooseFileMetaResource, LooseSingleCardDocument, @@ -25,6 +24,7 @@ import { isEqual, merge } from 'lodash-es'; import { assertIsSerializerName, CardResourceType, + Loader, fieldSerializer, FileMetaResourceType, getSerializer, From 4ee23eb39e608338f12663252260b0a42b1df283 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 31 Jul 2026 18:04:35 -0500 Subject: [PATCH 06/14] ci: Retry host shards on the service-worker startup race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base card components fire a _types fetch against the mock test realm as soon as they render; with base compiled into the host bundle that render can precede the test-realm service worker's interception, so the fetch escapes to the real network and fails the shard as "Global error: Uncaught TypeError: Failed to fetch". Match that form in the shard retry pattern — the race is rare per shard, so the second pass lands. The durable fix is queuing test-realm fetches in the harness until realm registration completes. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci-host.yaml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-host.yaml b/.github/workflows/ci-host.yaml index 8fa3dd48412..a725d885034 100644 --- a/.github/workflows/ci-host.yaml +++ b/.github/workflows/ci-host.yaml @@ -583,7 +583,16 @@ jobs: # raw notFound when the host store does a direct GET) point at # the same race; match both. A second shard pass typically lands # after the race resolves. - RETRY_PATTERN='ChunkLoadError|Failed to fetch dynamically imported module|NetworkError when attempting to fetch resource|unable to fetch https://icons\.[^:]+: fetch failed|cross-realm fetch failed for https://realm-test\.[^/]+|Could not find https://realm-test\.[^/"]+' + # The `Global error: Uncaught TypeError: Failed to fetch` form is a + # test-harness startup race: base card components (workspace, + # cards-grid) fire a `_types` fetch against the mock test realm the + # moment they render, and with base modules compiled into the host + # bundle that render can land before the test-realm service worker + # is intercepting — the fetch escapes to the real network and the + # rejection surfaces as a Global error. The race is rare per shard, + # so a second pass lands; the durable fix is queuing test-realm + # fetches in the harness until realm registration completes. + RETRY_PATTERN='ChunkLoadError|Failed to fetch dynamically imported module|NetworkError when attempting to fetch resource|unable to fetch https://icons\.[^:]+: fetch failed|cross-realm fetch failed for https://realm-test\.[^/]+|Could not find https://realm-test\.[^/"]+|Global error: Uncaught TypeError: Failed to fetch' if [ $exit_code -ne 0 ] && grep -Eq "$RETRY_PATTERN" /tmp/test-output.log; then echo "" echo "::warning::Transient chunk-fetch failure detected — retrying shard ${{ matrix.shardIndex }}" From 5957e9261e928d9dd29294e61853b629ce586ae6 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 31 Jul 2026 23:46:50 -0500 Subject: [PATCH 07/14] host: Serve test-realm fetches in-page and shim base under both URL forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the remaining bundled-base test failures: The test fetch wrapper now answers requests to registered test realms via realm.maybeHandle instead of dispatching them to the network. The service-worker relay that otherwise serves these URLs only intercepts once the worker controls the page and its per-module activation is acked; a card component that renders before that window closes (which bundled base modules do — no module-fetch latency) fired fetches that escaped to the real network and failed shards as "Global error: Uncaught TypeError: Failed to fetch". The registry is populated at realm construction, so the in-page path has no such window. Bundled base modules now register under the canonical https://cardstack.com/base/ form as well as the RRI form. resolveImport passes URL-form identifiers through unchanged (URL-to-URL mapping happens at the network's fetch boundary, which shim lookup precedes), so canonical-form imports missed the shim and fell through to a network fetch that evaluated a second copy of the module, splitting def identity — the serializeFileDef adoptsFrom mismatch and skill-state failures. Co-Authored-By: Claude Fable 5 --- packages/host/app/lib/bundled-base.ts | 12 +++++++++++- packages/host/tests/helpers/setup.ts | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/host/app/lib/bundled-base.ts b/packages/host/app/lib/bundled-base.ts index 55c141f5f15..8732d6f5a19 100644 --- a/packages/host/app/lib/bundled-base.ts +++ b/packages/host/app/lib/bundled-base.ts @@ -19,7 +19,7 @@ // into the host's initial bundle through the same vite/embroider pipeline // as host app code. -import type { VirtualNetwork } from '@cardstack/runtime-common'; +import { baseRealm, type VirtualNetwork } from '@cardstack/runtime-common'; import BASE_MODULES from './bundled-base-modules'; @@ -30,9 +30,19 @@ const GLOB_PREFIX = '../../../base/'; // constructed outside the loader service (test-realm adapters, in-browser // indexing). Must run after the network's `@cardstack/base/` realm mapping // is registered, since shim identifiers resolve at registration time. +// +// Each module registers under both identifier forms a loader can fetch it +// by. The RRI form resolves to the configured base realm URL at +// registration; the canonical `https://cardstack.com/base/` form passes +// through `resolveImport` unchanged (URL→URL mapping happens at the +// network's fetch boundary, which module shim lookup precedes), so it +// needs its own registration — a canonical-form import that misses the +// shim falls through to a network fetch that evaluates a second copy of +// the module, splitting def identity. export function shimBundledBase(virtualNetwork: VirtualNetwork) { for (let [path, module] of Object.entries(BASE_MODULES)) { let name = path.slice(GLOB_PREFIX.length).replace(/\.(gts|ts)$/, ''); virtualNetwork.shimModule(`@cardstack/base/${name}`, module); + virtualNetwork.shimModule(`${baseRealm.url}${name}`, module); } } diff --git a/packages/host/tests/helpers/setup.ts b/packages/host/tests/helpers/setup.ts index 0a4e48f354c..c1eace22d6e 100644 --- a/packages/host/tests/helpers/setup.ts +++ b/packages/host/tests/helpers/setup.ts @@ -20,6 +20,8 @@ import { clearHtmlComponentCache } from '@cardstack/host/lib/html-component'; import type SessionService from '@cardstack/host/services/session'; import { AiAssistantOpen } from '@cardstack/host/utils/local-storage-keys'; +import { getTestRealmRegistry } from './test-realm-registry'; + import { cleanupMonacoEditorModels } from './index'; // Pin `yaml` into the eager test bundle. `markdown-file-def` parses frontmatter @@ -268,6 +270,25 @@ function setupFetchDebugging(hooks: NestedHooks) { startedAt: Date.now(), }); try { + // Requests to a registered test realm are answered in-page rather + // than dispatched to the network. The service-worker relay that + // otherwise serves these URLs only intercepts once the worker + // controls the page and its per-module activation has been acked; + // a card component that renders (and fetches) before that window + // closes would otherwise hit the real network and fail, since the + // test-realm host doesn't exist outside the harness. The registry + // is populated at realm construction, so this path has no such + // window. Non-fetch resources (images, workers) still rely on the + // service worker. + for (let [realmUrl, { realm }] of getTestRealmRegistry()) { + if (url.startsWith(realmUrl)) { + let response = await realm.maybeHandle(new Request(input, init)); + if (response) { + return response; + } + break; + } + } return await boundFetch(input, init); } catch (error) { let reason = formatErrorForLog(error); From b654ec1ffa57fec993dc0a62318a4d5c75057081 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sat, 1 Aug 2026 11:05:05 -0500 Subject: [PATCH 08/14] host: Register bundled base under the RRI-resolved form only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering each bundled module under the canonical https://cardstack.com/base/ form as well made a def's identified module URL depend on import order: loaders capture export identities under whichever identifier form they fetched first, so flows that compare or serialize refs (skill and command attachment, file choosers) saw mismatched identities. The ai-assistant-panel, mini-file-chooser, and card-basics failures bisect to exactly that dual registration. Canonical-form imports therefore fall through to a network fetch that evaluates a second module copy — a known, narrower gap (a handful of commands/* imports) documented at the registration site. The durable fix is normalizing identifiers through the network's URL mappings in the loader's module-fetch path so both forms converge on one module state. Co-Authored-By: Claude Fable 5 --- packages/host/app/lib/bundled-base.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/host/app/lib/bundled-base.ts b/packages/host/app/lib/bundled-base.ts index 8732d6f5a19..6a6660216b3 100644 --- a/packages/host/app/lib/bundled-base.ts +++ b/packages/host/app/lib/bundled-base.ts @@ -19,7 +19,7 @@ // into the host's initial bundle through the same vite/embroider pipeline // as host app code. -import { baseRealm, type VirtualNetwork } from '@cardstack/runtime-common'; +import type { VirtualNetwork } from '@cardstack/runtime-common'; import BASE_MODULES from './bundled-base-modules'; @@ -31,18 +31,21 @@ const GLOB_PREFIX = '../../../base/'; // indexing). Must run after the network's `@cardstack/base/` realm mapping // is registered, since shim identifiers resolve at registration time. // -// Each module registers under both identifier forms a loader can fetch it -// by. The RRI form resolves to the configured base realm URL at -// registration; the canonical `https://cardstack.com/base/` form passes -// through `resolveImport` unchanged (URL→URL mapping happens at the -// network's fetch boundary, which module shim lookup precedes), so it -// needs its own registration — a canonical-form import that misses the -// shim falls through to a network fetch that evaluates a second copy of -// the module, splitting def identity. +// Known gap: only the RRI-resolved identifier form is registered. An +// import that names a base module by its canonical +// `https://cardstack.com/base/` URL misses the shim (resolveImport passes +// URL-form identifiers through unchanged; URL→URL mapping happens at the +// network's fetch boundary, which shim lookup precedes) and falls through +// to a network fetch that evaluates a second copy of the module. +// Registering the canonical form as a second shim entry is NOT the fix: +// loaders capture export identities under whichever identifier form they +// fetched, so dual registration makes a def's identified module URL +// depend on import order. The durable fix is normalizing the identifier +// through the network's URL mappings in the loader's module-fetch path so +// both forms converge on one module state. export function shimBundledBase(virtualNetwork: VirtualNetwork) { for (let [path, module] of Object.entries(BASE_MODULES)) { let name = path.slice(GLOB_PREFIX.length).replace(/\.(gts|ts)$/, ''); virtualNetwork.shimModule(`@cardstack/base/${name}`, module); - virtualNetwork.shimModule(`${baseRealm.url}${name}`, module); } } From 54f26402dbf0b4c928dd677147581d459b843651 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sat, 1 Aug 2026 22:39:46 -0500 Subject: [PATCH 09/14] host: Converge module identity for bundled base modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for the def-identity failures that remained with base compiled into the host bundle: - The loader folds virtual-alias URL identifiers (e.g. https://cardstack.com/base/…) onto their mapped real URL when resolving imports, so both spellings of a module converge on one module-state entry, one shim lookup, and one captured export identity. resolveImport alone passes full URLs through, so a virtual-alias import previously keyed separate module state — and, since virtual forms miss the shim registry, fetched and evaluated a second copy. Non-URL identifiers pass through untouched (mapURL constructs a URL). - Loaders replay the virtual network's sync-shim inventory through identity capture, in registration order, before any individual shim's own capture. Loader-evaluated modules got declaring-module-first identity from dependency-first evaluation; shims carry no dependency chain, so a loader whose first shim load was a re-exporter (file-api re-exports FileDef from card-api) captured identities under the re-exporter. Registration order stands in for dependency order: externals (runtime-common, boxel-ui) register before base, and within base card-api and cards-grid — the declaring modules for the def classes serialization identifies — register ahead of the rest. - code-ref.gts validated code refs with a bare dynamic import(module). Inside loader-evaluated modules the AMD transpile rewrites that to a loader-mediated import; compiled-in modules must route through the loader explicitly, since the specifier is a runtime realm URL only a Loader can resolve. Verified locally against the full service stack: serializeFileDef 7/7, update-room-skills 9/9, card-basics 101/101, host command schema generation 92/92, ai-assistant-panel skills clean, markdown skill search 3/3; Store, file-attachment, and serialization stay green. Remaining known residue is the dependency/reference-shape class in realm-indexing assertions (shimmed modules carry no consumed-module chains). Co-Authored-By: Claude Fable 5 --- packages/base/code-ref.gts | 26 ++++++++- packages/host/app/lib/bundled-base.ts | 26 ++++++++- packages/host/app/services/network.ts | 6 ++- packages/runtime-common/loader.ts | 53 ++++++++++++++++++- .../runtime-common/package-shim-handler.ts | 15 ++++++ packages/runtime-common/virtual-network.ts | 7 +++ 6 files changed, 127 insertions(+), 6 deletions(-) diff --git a/packages/base/code-ref.gts b/packages/base/code-ref.gts index e69e18b9251..f956a9f29e7 100644 --- a/packages/base/code-ref.gts +++ b/packages/base/code-ref.gts @@ -9,6 +9,7 @@ import { CardURLContextName, fieldSerializer, CodeRefSerializer, + Loader, } from '@cardstack/runtime-common'; import { not } from '@cardstack/boxel-ui/helpers'; import { BoxelInput } from '@cardstack/boxel-ui/components'; @@ -75,7 +76,14 @@ class EditView extends Component { module = new URL(module, new URL(this.cardURL)).href; } try { - let code = (await import(module))[name]; + // Load through the Loader rather than a bare dynamic import: the + // module is a runtime realm URL, which only a Loader can resolve + // (shims, realm mappings, authenticated fetch). Inside + // loader-evaluated modules the AMD transpile rewrites `import()` + // this way implicitly; compiled-in modules must do it explicitly. + let code = (await myLoader().import>(module))[ + name + ]; if (code) { this.validationState = 'valid'; if (!opts?.checkOnly) { @@ -91,6 +99,22 @@ class EditView extends Component { ); } +function myLoader(): Loader { + // A Loader that evaluates this module injects `import.meta.loader`. When + // the module is compiled into the host bundle instead, the platform + // evaluates it and no loader is injected; the host publishes the loader + // bundled modules should use (see Loader.setForBundledModules). + + // When type-checking realm-server, tsc sees this file and thinks it will + // be transpiled to CommonJS and so it complains about import.meta. + // @ts-ignore + let loader = (import.meta as any).loader ?? Loader.forBundledModules(); + if (!loader) { + throw new Error('No Loader is available to this module'); + } + return loader; +} + export default class CodeRefField extends FieldDef { static displayName = 'CodeRef'; static icon = CodeIcon; diff --git a/packages/host/app/lib/bundled-base.ts b/packages/host/app/lib/bundled-base.ts index 6a6660216b3..9715d4e896c 100644 --- a/packages/host/app/lib/bundled-base.ts +++ b/packages/host/app/lib/bundled-base.ts @@ -43,9 +43,31 @@ const GLOB_PREFIX = '../../../base/'; // depend on import order. The durable fix is normalizing the identifier // through the network's URL mappings in the loader's module-fetch path so // both forms converge on one module state. +// Shim registration order doubles as identity-capture order (loaders +// replay the network's shim inventory dependency-first — see the loader's +// captureVirtualNetworkShimIdentities). The def classes that serialization +// identifies are declared in card-api (several modules re-export them: +// file-api, markdown, image-file-def) and cards-grid (re-exported by +// index), so those two register ahead of the alphabetical remainder. +const DECLARING_MODULES_FIRST = ['card-api', 'cards-grid']; + export function shimBundledBase(virtualNetwork: VirtualNetwork) { - for (let [path, module] of Object.entries(BASE_MODULES)) { - let name = path.slice(GLOB_PREFIX.length).replace(/\.(gts|ts)$/, ''); + let entries = Object.entries(BASE_MODULES) + .map(([path, module]) => ({ + name: path.slice(GLOB_PREFIX.length).replace(/\.(gts|ts)$/, ''), + module, + })) + .sort((a, b) => { + let ai = DECLARING_MODULES_FIRST.indexOf(a.name); + let bi = DECLARING_MODULES_FIRST.indexOf(b.name); + if (ai !== bi) { + return (ai === -1 ? Infinity : ai) < (bi === -1 ? Infinity : bi) + ? -1 + : 1; + } + return a.name < b.name ? -1 : 1; + }); + for (let { name, module } of entries) { virtualNetwork.shimModule(`@cardstack/base/${name}`, module); } } diff --git a/packages/host/app/services/network.ts b/packages/host/app/services/network.ts index f375702b1e3..d96e83389c2 100644 --- a/packages/host/app/services/network.ts +++ b/packages/host/app/services/network.ts @@ -75,10 +75,14 @@ export default class NetworkService extends Service { '@cardstack/base/', resolvedBaseRealmURL.href, ); + // Externals shim first: identity capture replays shims in registration + // order, and base modules re-export values whose declaring modules are + // externals (runtime-common, boxel-ui) — declaring modules must + // register ahead of their re-exporters. + shimExternals(virtualNetwork); // Base-realm modules ship inside the host bundle; any loader on this // network serves them without fetching from the base realm. shimBundledBase(virtualNetwork); - shimExternals(virtualNetwork); virtualNetwork.addImportMap('@cardstack/boxel-icons/', (rest) => { return `${config.iconsURL}/@cardstack/boxel-icons/v1/icons/${rest}.js`; }); diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index 3b30f5998ad..8268598a9d3 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -234,8 +234,36 @@ export class Loader { }, ) { this.fetchImplementation = fetch; - this.resolveImport = - resolveImport ?? ((moduleIdentifier) => moduleIdentifier); + let rawResolveImport = + resolveImport ?? ((moduleIdentifier: string) => moduleIdentifier); + let virtualNetwork = options?.virtualNetwork; + // Fold virtual-alias URL forms (e.g. https://cardstack.com/base/…) onto + // the real URL the network would serve them from, so both spellings of a + // module converge on one module-state entry, one shim lookup key, and one + // captured export identity. `resolveImport` itself only rewrites RRI / + // bare-package prefixes and passes full URLs through, so without this a + // virtual-alias import keys its own separate module state — and card + // serialization requires identities in real-URL form (module refs + // relativize against instance ids, which are real-form). Mapping an + // already-real URL is a no-op, so composed resolvers (cloneLoader wraps + // the parent's) stay idempotent. + this.resolveImport = virtualNetwork + ? (moduleIdentifier: string) => { + let resolved = rawResolveImport(moduleIdentifier); + // mapURL constructs a URL, so only URL-shaped identifiers can be + // folded; anything else (an unmapped prefix form, a relative + // specifier) passes through untouched. + if ( + !resolved.startsWith('http://') && + !resolved.startsWith('https://') + ) { + return resolved; + } + return ( + virtualNetwork.mapURL(resolved, 'virtual-to-real')?.href ?? resolved + ); + } + : rawResolveImport; this.retrySleep = options?.retrySleep; this.virtualNetwork = options?.virtualNetwork; // Module caches are keyed by canonical RRI form (see moduleCacheKey), whose @@ -965,6 +993,18 @@ export class Loader { } } + private vnShimIdentitiesCaptured = false; + + private captureVirtualNetworkShimIdentities() { + if (this.vnShimIdentitiesCaptured || !this.virtualNetwork) { + return; + } + this.vnShimIdentitiesCaptured = true; + for (let [id, module] of this.virtualNetwork.syncShimEntries()) { + this.captureIdentitiesOfModuleExports(module, id); + } + } + private captureIdentitiesOfModuleExports( module: any, moduleIdentifier: string, @@ -1052,6 +1092,15 @@ export class Loader { this.setCanonicalModuleURL(moduleIdentifier, canonicalURL); if (loaded.type === 'shimmed') { + // Loader-evaluated modules capture export identities dependency-first + // (a re-exporting module always evaluates after the module that + // declares the export, so first-wins capture lands on the declaring + // module). Shims carry no dependency chain, so a loader whose first + // shim load is a re-exporter would mis-attribute identities. Replay + // the network's whole sync-shim inventory once, in registration + // order (registrars put declaring modules first), before any + // individual shim's capture. + this.captureVirtualNetworkShimIdentities(); this.captureIdentitiesOfModuleExports(loaded.module, moduleIdentifier); this.setModule(moduleIdentifier, { diff --git a/packages/runtime-common/package-shim-handler.ts b/packages/runtime-common/package-shim-handler.ts index c34f01fed2c..95b58a2fd10 100644 --- a/packages/runtime-common/package-shim-handler.ts +++ b/packages/runtime-common/package-shim-handler.ts @@ -440,10 +440,25 @@ export class PackageShimHandler { return null; }; + // Synchronously-shimmed modules in registration order. Loaders replay + // this inventory through their identity capture so that export + // identities don't depend on which shim a given loader happened to load + // first — loader-evaluated modules got that guarantee from + // dependency-first evaluation (a re-exporting module always evaluated + // after its source), but shims carry no dependency chain. Registration + // order therefore stands in for dependency order; registrars put + // declaring modules before their re-exporters. + private syncModules = new Map(); + + syncShimEntries(): ReadonlyMap { + return this.syncModules; + } + shimModule(moduleIdentifier: string, module: ModuleLike) { moduleIdentifier = this.resolveImport(moduleIdentifier); let key = trimModuleIdentifier(moduleIdentifier); this.moduleIds.set(key, async () => module); + this.syncModules.set(key, module); this.rememberExports(key, module); } diff --git a/packages/runtime-common/virtual-network.ts b/packages/runtime-common/virtual-network.ts index a545a704d01..fb194a61f61 100644 --- a/packages/runtime-common/virtual-network.ts +++ b/packages/runtime-common/virtual-network.ts @@ -118,6 +118,13 @@ export class VirtualNetwork { return this.packageShimHandler.lookupModule(url); } + // Registration-ordered inventory of synchronously-shimmed modules, for + // loaders to replay through identity capture (see the note on the + // handler's syncShimEntries). + syncShimEntries(): ReadonlyMap { + return this.packageShimHandler.syncShimEntries(); + } + addURLMapping(from: URL, to: URL) { this.urlMappings.push([from.href, to.href]); // unresolveURL chases through urlMappings (via resolveURLMapping), so a new From 89e20c3e5b12398b39a15bc1cbe2fa76d78d057d Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 17 Aug 2026 13:22:44 -0400 Subject: [PATCH 10/14] host: Name the pending operation in stuck-waiter dumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Code-patches timeouts on this branch report two `sqlite running` and two `fetcher-body:text` waiters still open at the 60s mark, with nothing to say which statement or which request stalled — and the harness' in-flight-fetch list reads `` because an in-page test realm answers a request without it ever reaching the global fetch, so the waiter label is the only record left. Label them: the fetcher waiter with the request's method and URL, its body waiters with the response URL, and the sqlite waiter with the worker command plus the statement. Also log the full stack behind a global error or unhandled rejection, so the recurring `Failed to fetch` points at a call site instead of a line in a bundled chunk. Records, too, why the three `Integration | realm indexing` failures are structural: a shim carries no dependency chain, so an indexed instance's deps lose their closure through base. Co-Authored-By: Claude Opus 5 --- packages/host/app/lib/bundled-base.ts | 15 ++++++++++++++- packages/host/app/lib/sqlite-adapter.ts | 14 +++++++++++++- packages/host/tests/helpers/setup-qunit.js | 17 +++++++++++++++++ packages/runtime-common/fetcher.ts | 13 +++++++++++-- packages/runtime-common/test-waiters.ts | 12 ++++++++---- 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/packages/host/app/lib/bundled-base.ts b/packages/host/app/lib/bundled-base.ts index 9715d4e896c..504fe4c7faa 100644 --- a/packages/host/app/lib/bundled-base.ts +++ b/packages/host/app/lib/bundled-base.ts @@ -2,7 +2,7 @@ // registers them as loader shims, so importing a base module (whether as // `@cardstack/base/card-api` or its resolved base-realm URL) resolves to // the compiled-in module instead of a network fetch of realm-server- -// transpiled source. This trades two properties of the fetched path for +// transpiled source. This trades three properties of the fetched path for // speed: // // - Base modules become singletons shared by every loader generation. @@ -14,6 +14,19 @@ // until the host is rebuilt. This includes indexing — the prerender // renders with the host dist, so index output reflects the bundled base, // not the realm-served source. +// - An indexed instance's dependencies lose their transitive closure +// through base. The loader records `consumedModules` while evaluating +// fetched source; a shim carries no dependency chain, so a card's deps +// stop at the base modules it names directly and the base-internal and +// npm-package entries the fetched path records are absent. Three +// `Integration | realm indexing` tests assert the full closure and fail +// on that difference. Restoring it needs a *post-compile* dep map: +// the fetched closure includes deps the gts/babel transforms inject +// (`@ember/template-factory`, `@ember/component/template-only`, +// ember-concurrency's async-arrow runtime), which a scan of base's own +// import statements can't see. Whether the closure is worth restoring is +// a separate question — a bundled base module can't change without a +// host rebuild, so nothing in it can invalidate an index entry. // // The eager glob in bundled-base-modules.js compiles every base module // into the host's initial bundle through the same vite/embroider pipeline diff --git a/packages/host/app/lib/sqlite-adapter.ts b/packages/host/app/lib/sqlite-adapter.ts index a335780527b..3fce2e598bb 100644 --- a/packages/host/app/lib/sqlite-adapter.ts +++ b/packages/host/app/lib/sqlite-adapter.ts @@ -13,6 +13,18 @@ import { Deferred, } from '@cardstack/runtime-common'; +// Names the pending operation in a stuck-waiter dump. A query the worker +// never answers otherwise reports as a bare `sqlite running`, which says +// nothing about which statement (or which of several concurrent queries) +// stalled — and since these run behind an in-page realm's request handler, +// a stall here surfaces as a test timing out on unrelated-looking fetches. +function sqliteWaiterLabel(args: unknown[]): string { + let [command, payload] = args as [unknown, { sql?: unknown } | undefined]; + let label = `sqlite running ${String(command)}`; + let sql = typeof payload?.sql === 'string' ? payload.sql : undefined; + return sql ? `${label} ${sql.replace(/\s+/g, ' ').slice(0, 160)}` : label; +} + export default class SQLiteAdapter implements DBAdapter { readonly kind = 'sqlite'; private _sqlite: typeof SQLiteWorker | undefined; @@ -122,7 +134,7 @@ export default class SQLiteAdapter implements DBAdapter { ); } return (async (...args: Parameters) => { - return await waitForPromise(worker(...args), 'sqlite running'); + return await waitForPromise(worker(...args), sqliteWaiterLabel(args)); }) as typeof SQLiteWorker; } diff --git a/packages/host/tests/helpers/setup-qunit.js b/packages/host/tests/helpers/setup-qunit.js index fd0a8eaf455..2d2b6b8c4ac 100644 --- a/packages/host/tests/helpers/setup-qunit.js +++ b/packages/host/tests/helpers/setup-qunit.js @@ -22,9 +22,26 @@ export function setupQUnit() { ) { return true; } + // QUnit reports a global error as message + the asset line it surfaced + // at, which for a bundled chunk names neither the failing request nor + // the call site. The stack lands in the failure's browser log instead. + let error = args[3]; + if (error && error.stack) { + console.error(`[global-error] ${error.stack}`); + } return _originalOnError ? _originalOnError(message, ...args) : false; }; + // Same reasoning for a rejection that reaches the window: the reported + // message alone doesn't identify what rejected. Listening (without + // preventDefault) only logs — QUnit still fails the test. + window.addEventListener('unhandledrejection', (event) => { + let reason = event.reason; + console.error( + `[global-rejection] ${(reason && (reason.stack || reason.message)) || String(reason)}`, + ); + }); + QUnit.dump.maxDepth = 20; useTestWaiters(TestWaiters); setup(QUnit.assert); diff --git a/packages/runtime-common/fetcher.ts b/packages/runtime-common/fetcher.ts index 8f07de69902..e2cf85915f7 100644 --- a/packages/runtime-common/fetcher.ts +++ b/packages/runtime-common/fetcher.ts @@ -45,7 +45,12 @@ export function fetcher( ? urlOrRequest : new Request(urlOrRequest, init); - let token = fetcherWaiter.beginAsync(); + // Labeled so a test that times out on this waiter names the request it + // was waiting for. Requests answered in-page (a test realm, a + // service-worker relay) never reach the global fetch, so the harness' + // in-flight-fetch list can be empty while this waiter is still open — + // the label is then the only record of what stalled. + let token = fetcherWaiter.beginAsync(`${request.method} ${request.url}`); try { return responseWithWaiters(await buildNext(middlewareStack)(request)); } finally { @@ -100,7 +105,11 @@ function responseWithWaiters(response: Response): Response { } if (typeof key === 'string' && asyncMethods.includes(key)) { return async (...args: unknown[]) => { - return waitForPromise(value(...args), `fetcher-body:${key}`); + let url = Reflect.get(target, 'url'); + return waitForPromise( + value(...args), + url ? `fetcher-body:${key} ${url}` : `fetcher-body:${key}`, + ); }; } return value; diff --git a/packages/runtime-common/test-waiters.ts b/packages/runtime-common/test-waiters.ts index 09728bade9f..c287de013d9 100644 --- a/packages/runtime-common/test-waiters.ts +++ b/packages/runtime-common/test-waiters.ts @@ -6,7 +6,9 @@ export interface Waiters { buildWaiter(label: string): { - beginAsync(): unknown; + // `@ember/test-waiters`' signature: an explicit token, then a label that + // its `debugInfo()` reports in place of a captured stack. + beginAsync(token?: unknown, label?: string): unknown; endAsync(token: unknown): void; }; waitForPromise(promise: Promise, label?: string): Promise; @@ -19,7 +21,9 @@ export function useTestWaiters(w: Waiters) { } export interface TestWaiter { - beginAsync(): unknown; + // A label names the pending operation in a stuck-waiter dump (a test that + // times out prints each open token's label, falling back to its stack). + beginAsync(label?: string): unknown; endAsync(token: unknown): void; } @@ -36,8 +40,8 @@ export function buildWaiter(label: string): TestWaiter { return real; }; return { - beginAsync() { - return resolve()?.beginAsync(); + beginAsync(label?: string) { + return resolve()?.beginAsync(undefined, label); }, endAsync(token: unknown) { if (token === undefined) { From 5b67bedae40802eaab30893a49c4bec87dff7908 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 17 Aug 2026 14:52:23 -0400 Subject: [PATCH 11/14] base: Import BaseDefComponent as a type in zip-file-def MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled build resolves base's imports statically, so a value-import of a type-only export is a hard error: `[MISSING_EXPORT] "BaseDefComponent" is not exported by "../base/card-api.gts"`, which failed this branch's test-asset build. The fetched path never noticed because the realm-server transpile strips types per-module without checking them against the source module's exports. Every other base file that names BaseDefComponent already marks it `type` — this file arrived after the last merge from main, so it missed the pattern. Co-Authored-By: Claude Opus 5 --- packages/base/zip-file-def.gts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/base/zip-file-def.gts b/packages/base/zip-file-def.gts index 45d8ba599ff..c6d965fdae0 100644 --- a/packages/base/zip-file-def.gts +++ b/packages/base/zip-file-def.gts @@ -5,7 +5,7 @@ import { htmlSafe } from '@ember/template'; import GlimmerComponent from '@glimmer/component'; import { - BaseDefComponent, + type BaseDefComponent, Component, FieldDef, NumberField, From a692a00cc1c49c7578a4953d3e91ae56e5ac8b00 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 17 Aug 2026 16:35:24 -0400 Subject: [PATCH 12/14] base: Fetch a realm's type summary through the loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new global-error stack named the recurring `TypeError: Failed to fetch` on this branch: the grid's `loadFilterList` task, fetching `${realm}_types` with a native fetch. The only failing request in the shard is `GET https://cardstack.com/base/_types` — a virtual alias that just names a host the browser can't reach. The virtual network maps that alias to the URL the base realm is really served from, but a native fetch never consults it, and the task has no catch, so the rejection surfaced as an uncaught global error and failed the shard. Route both `_types` fetches (cards-grid and workspace) through the loader's fetch, which resolves the alias and carries realm auth rather than depending on the auth service worker to inject it — a base module compiled into the host bundle falls back to the loader the host publishes for bundled modules, since the platform evaluated it and left no `import.meta.loader`. Co-Authored-By: Claude Opus 5 --- packages/base/cards-grid.gts | 22 +++++++++++++++++++++- packages/base/workspace.gts | 22 +++++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/base/cards-grid.gts b/packages/base/cards-grid.gts index 70d350bf041..b840b947eab 100644 --- a/packages/base/cards-grid.gts +++ b/packages/base/cards-grid.gts @@ -21,6 +21,7 @@ import { baseRealmRRI, baseFileRef, isCardInstance, + Loader, SupportedMimeType, subscribeToRealm, codeRefFromInternalKey, @@ -50,6 +51,25 @@ import type { RealmEventContent } from './matrix-event'; import { Spec } from './spec'; import StringField from './string'; +// A realm URL that reaches a card can be a virtual alias (e.g. +// `https://cardstack.com/base/`) that only the virtual network knows how to +// resolve; a native fetch of one leaves the page for a host that need not +// exist, failing with `TypeError: Failed to fetch`. The loader's fetch maps +// the alias to the URL the realm is really served from and carries realm +// auth, rather than relying on the auth service worker to inject it. A base +// module compiled into the host bundle is evaluated by the platform and so +// has no `import.meta.loader`; it uses the loader the host publishes for +// bundled modules instead. +function realmFetch(): typeof globalThis.fetch { + // When type-checking realm-server, tsc sees this file and thinks it will be + // transpiled to CommonJS and so it complains about import.meta. But this + // file always runs as ESM. + // @ts-ignore + let loader: Loader | undefined = + (import.meta as any).loader ?? Loader.forBundledModules(); + return loader?.fetch ?? fetch; +} + const [_CardView, StripView, GridView] = VIEW_OPTIONS; class Isolated extends Component { @@ -333,7 +353,7 @@ class Isolated extends Component { if (!realm) { return; } - let response = await fetch(`${realm}_types`, { + let response = await realmFetch()(`${realm}_types`, { headers: { Accept: SupportedMimeType.CardTypeSummary, }, diff --git a/packages/base/workspace.gts b/packages/base/workspace.gts index 9e41f5830af..6a23786acd0 100644 --- a/packages/base/workspace.gts +++ b/packages/base/workspace.gts @@ -41,6 +41,7 @@ import { baseRealm, baseRealmRRI, isCardInstance, + Loader, SupportedMimeType, subscribeToRealm, codeRefFromInternalKey, @@ -82,6 +83,25 @@ import { Spec } from './spec'; // state it directly. const here: string = new URL('./workspace', baseRealm.url).href; +// A realm URL that reaches a card can be a virtual alias (e.g. +// `https://cardstack.com/base/`) that only the virtual network knows how to +// resolve; a native fetch of one leaves the page for a host that need not +// exist, failing with `TypeError: Failed to fetch`. The loader's fetch maps +// the alias to the URL the realm is really served from and carries realm +// auth, rather than relying on the auth service worker to inject it. A base +// module compiled into the host bundle is evaluated by the platform and so +// has no `import.meta.loader`; it uses the loader the host publishes for +// bundled modules instead. +function realmFetch(): typeof globalThis.fetch { + // When type-checking realm-server, tsc sees this file and thinks it will be + // transpiled to CommonJS and so it complains about import.meta. But this + // file always runs as ESM. + // @ts-ignore + let loader: Loader | undefined = + (import.meta as any).loader ?? Loader.forBundledModules(); + return loader?.fetch ?? fetch; +} + const [, StripView, GridView] = VIEW_OPTIONS; type Segment = 'home' | 'library' | 'activity'; @@ -3336,7 +3356,7 @@ class Isolated extends Component { if (!realm) { return; } - let response = await fetch(`${realm}_types`, { + let response = await realmFetch()(`${realm}_types`, { headers: { Accept: SupportedMimeType.CardTypeSummary, }, From b5ed59b30a72eff255904675239fb3b465a34544 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 17 Aug 2026 17:01:05 -0400 Subject: [PATCH 13/14] base: Keep import.meta on the ts-ignore'd line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@ts-ignore` suppresses the next line only, and prettier wrapped the annotated declaration so `import.meta` landed on the line after it — tripping TS1470 ("not allowed in files which will build into CommonJS output", which base files hit because realm-server type-checks them). Dropping the type annotation fits the initializer on the ignored line, matching how the other base modules that reach for a loader write it. Co-Authored-By: Claude Opus 5 --- packages/base/cards-grid.gts | 3 +-- packages/base/workspace.gts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/base/cards-grid.gts b/packages/base/cards-grid.gts index b840b947eab..904057c2b66 100644 --- a/packages/base/cards-grid.gts +++ b/packages/base/cards-grid.gts @@ -65,8 +65,7 @@ function realmFetch(): typeof globalThis.fetch { // transpiled to CommonJS and so it complains about import.meta. But this // file always runs as ESM. // @ts-ignore - let loader: Loader | undefined = - (import.meta as any).loader ?? Loader.forBundledModules(); + let loader = (import.meta as any).loader ?? Loader.forBundledModules(); return loader?.fetch ?? fetch; } diff --git a/packages/base/workspace.gts b/packages/base/workspace.gts index 6a23786acd0..40206fa64e3 100644 --- a/packages/base/workspace.gts +++ b/packages/base/workspace.gts @@ -97,8 +97,7 @@ function realmFetch(): typeof globalThis.fetch { // transpiled to CommonJS and so it complains about import.meta. But this // file always runs as ESM. // @ts-ignore - let loader: Loader | undefined = - (import.meta as any).loader ?? Loader.forBundledModules(); + let loader = (import.meta as any).loader ?? Loader.forBundledModules(); return loader?.fetch ?? fetch; } From b9b25f2b5a48f90281cf68b5c3dd3754d6150e01 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 17 Aug 2026 18:20:35 -0400 Subject: [PATCH 14/14] base: Let settled() wait for the protected-media blob swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FileAudio falls back to the canonical source when the blob fetch fails` asserts right after `await render(...)`, and the modifier decides the element's src in an async IIFE that no test waiter covers — so whether the fallback has run by assertion time is a race that this branch happens to lose (the harness logs the intended `GET .../take.mp3` rejection, then the element is found with no src at all). Wrap the IIFE in `waitForPromise` so the swap is visible to the harness. Outside tests the shim passes the promise through untouched, so this is inert in the app. The race is latent on main too — the test passes there by luck of microtask ordering, not by synchronization. Co-Authored-By: Claude Opus 5 --- packages/base/file-formats/file-resources.gts | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/packages/base/file-formats/file-resources.gts b/packages/base/file-formats/file-resources.gts index b80743dd271..a44aafe35a8 100644 --- a/packages/base/file-formats/file-resources.gts +++ b/packages/base/file-formats/file-resources.gts @@ -6,6 +6,8 @@ import { htmlSafe } from '@ember/template'; import GlimmerComponent from '@glimmer/component'; import { modifier } from 'ember-modifier'; +import { waitForPromise } from '@cardstack/runtime-common'; + import { profileForFile, type FileTypeProfile } from './file-type-profile'; // The image primitive lives in its own lean module because card-api's // universal graph reaches it (via `image-preview`); re-exported here so this @@ -244,34 +246,40 @@ const loadProtectedMediaBlob = modifier( let objectURL: string | undefined; let controller = new AbortController(); element.removeAttribute('src'); - void (async () => { - try { - // `same-origin` rather than `include`: the realm server answers with - // `Access-Control-Allow-Origin: *`, which a credentialed cross-origin - // request rejects. - let response = await fetch(resourceURL, { - credentials: 'same-origin', - signal: controller.signal, - }); - if (!response.ok) { - throw new Error(`Media fetch failed with HTTP ${response.status}`); - } - let blob = await response.blob(); - if (cancelled) { - return; - } - objectURL = URL.createObjectURL(blob); - element.src = objectURL; - element.load(); - } catch { - if (!cancelled) { - // Fall back to the canonical source so playback degrades rather - // than disappearing. - element.src = resourceURL; + // Wrapped so a test's `settled()` waits for the blob swap (and for the + // fallback assignment below when the fetch rejects) instead of asserting + // against an element whose src hasn't been decided yet. Outside tests + // `waitForPromise` passes the promise straight through. + void waitForPromise( + (async () => { + try { + // `same-origin` rather than `include`: the realm server answers with + // `Access-Control-Allow-Origin: *`, which a credentialed cross-origin + // request rejects. + let response = await fetch(resourceURL, { + credentials: 'same-origin', + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Media fetch failed with HTTP ${response.status}`); + } + let blob = await response.blob(); + if (cancelled) { + return; + } + objectURL = URL.createObjectURL(blob); + element.src = objectURL; element.load(); + } catch { + if (!cancelled) { + // Fall back to the canonical source so playback degrades rather + // than disappearing. + element.src = resourceURL; + element.load(); + } } - } - })(); + })(), + ); return () => { cancelled = true; controller.abort();