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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions packages/host/app/services/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
VirtualNetwork,
authorizationMiddleware,
baseRealm,
registerCardReferencePrefix,
fetcher,
} from '@cardstack/runtime-common';

Expand Down Expand Up @@ -54,33 +53,33 @@ export default class NetworkService extends Service {
let resolvedBaseRealmURL = new URL(
withTrailingSlash(config.resolvedBaseRealmURL),
);
// URL mapping kept for the fake https://cardstack.com/base/ → real URL.
// addRealmMapping registers the @cardstack/base/ scoped prefix.
virtualNetwork.addURLMapping(new URL(baseRealm.url), resolvedBaseRealmURL);
virtualNetwork.addRealmMapping(
'@cardstack/base/',
resolvedBaseRealmURL.href,
);
shimExternals(virtualNetwork);
virtualNetwork.addImportMap('@cardstack/boxel-icons/', (rest) => {
return `${config.iconsURL}/@cardstack/boxel-icons/v1/icons/${rest}.js`;
});
if (config.resolvedCatalogRealmURL) {
let catalogURL = withTrailingSlash(config.resolvedCatalogRealmURL);
registerCardReferencePrefix('@cardstack/catalog/', catalogURL);
virtualNetwork.addImportMap(
virtualNetwork.addRealmMapping(
'@cardstack/catalog/',
(rest) => new URL(rest, catalogURL).href,
withTrailingSlash(config.resolvedCatalogRealmURL),
);
}
if (config.resolvedSkillsRealmURL) {
let skillsURL = withTrailingSlash(config.resolvedSkillsRealmURL);
registerCardReferencePrefix('@cardstack/skills/', skillsURL);
virtualNetwork.addImportMap(
virtualNetwork.addRealmMapping(
'@cardstack/skills/',
(rest) => new URL(rest, skillsURL).href,
withTrailingSlash(config.resolvedSkillsRealmURL),
);
}
if (config.resolvedOpenRouterRealmURL) {
let openRouterURL = withTrailingSlash(config.resolvedOpenRouterRealmURL);
registerCardReferencePrefix('@cardstack/openrouter/', openRouterURL);
virtualNetwork.addImportMap(
virtualNetwork.addRealmMapping(
'@cardstack/openrouter/',
(rest) => new URL(rest, openRouterURL).href,
withTrailingSlash(config.resolvedOpenRouterRealmURL),
);
}
return virtualNetwork;
Expand Down
5 changes: 1 addition & 4 deletions packages/realm-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
CachingDefinitionLookup,
DEFAULT_CARD_SIZE_LIMIT_BYTES,
DEFAULT_FILE_SIZE_LIMIT_BYTES,
registerCardReferencePrefix,
} from '@cardstack/runtime-common';
import { NodeAdapter } from './node-realm';
import yargs from 'yargs';
Expand Down Expand Up @@ -237,9 +236,7 @@ for (let i = 0; i < fromUrls.length; i++) {
virtualNetwork.addURLMapping(fromURL, to);
urlMappings.push([fromURL, to]);
} else {
// Non-URL prefix like @cardstack/catalog/
registerCardReferencePrefix(from, to.href);
virtualNetwork.addImportMap(from, (rest) => new URL(rest, to).href);
virtualNetwork.addRealmMapping(from, to.href);
urlMappings.push([to, to]); // use toUrl for both in hrefs
}
}
Expand Down
130 changes: 130 additions & 0 deletions packages/realm-server/tests/card-reference-resolver-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
resolveCardReference,
resolveRRI,
RealmPaths,
VirtualNetwork,
} from '@cardstack/runtime-common';
import type {
SingleCardDocument,
Expand Down Expand Up @@ -618,4 +619,133 @@ module(basename(__filename), function () {
});
});
});

module('VirtualNetwork.addRealmMapping', function (hooks) {
let vn: VirtualNetwork;
let prefix = '@test/realm/';
let target = 'http://localhost:9000/realm/';

hooks.beforeEach(function () {
vn = new VirtualNetwork();
vn.addRealmMapping(prefix, target);
});

hooks.afterEach(function () {
unregisterCardReferencePrefix(prefix);
});

test('populates importMap so resolveImport works', function (assert) {
let result = vn.resolveImport('@test/realm/card-api');
assert.strictEqual(result, 'http://localhost:9000/realm/card-api');
});

test('populates global prefixMappings so resolveCardReference works', function (assert) {
let result = resolveCardReference('@test/realm/Foo', undefined);
assert.strictEqual(result, 'http://localhost:9000/realm/Foo');
});

test('normalizes trailing slashes', function (assert) {
unregisterCardReferencePrefix(prefix);
let vn2 = new VirtualNetwork();
// No trailing slashes
vn2.addRealmMapping('@test/other', 'http://localhost:9000/other');
let result = vn2.resolveImport('@test/other/card');
assert.strictEqual(result, 'http://localhost:9000/other/card');
unregisterCardReferencePrefix('@test/other/');
});

test('overwrites cleanly when called twice with same prefix', function (assert) {
let newTarget = 'http://localhost:8000/realm/';
vn.addRealmMapping(prefix, newTarget);
let result = vn.resolveImport('@test/realm/card-api');
assert.strictEqual(result, 'http://localhost:8000/realm/card-api');
});

test('knownRealms returns registered realm identifiers', function (assert) {
let realms = vn.knownRealms();
assert.true(
realms.includes('@test/realm/' as any),
'contains the registered realm',
);
Comment on lines +666 to +669
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions use as any to satisfy the RealmIdentifier[] type, which disables type checking in the test. Since RealmIdentifier is already imported in this file, prefer casting the string literal to RealmIdentifier (or otherwise comparing as string) so the test stays type-safe.

Copilot uses AI. Check for mistakes.
});

test('knownRealms reflects multiple registrations', function (assert) {
vn.addRealmMapping('@test/other/', 'http://localhost:9000/other/');
let realms = vn.knownRealms();
assert.strictEqual(realms.length, 2);
assert.true(realms.includes('@test/realm/' as any));
assert.true(realms.includes('@test/other/' as any));
unregisterCardReferencePrefix('@test/other/');
Comment on lines +675 to +678
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue here: using as any defeats the purpose of the branded RealmIdentifier type and can hide mistakes. Prefer as RealmIdentifier (already imported) or cast the array to string[] before calling includes.

Copilot uses AI. Check for mistakes.
});
});

module('VirtualNetwork.fetch with RRI', function (hooks) {
let vn: VirtualNetwork;
let prefix = '@test/fetch-realm/';
let target = 'http://localhost:9000/fetch-realm/';

hooks.beforeEach(function () {
vn = new VirtualNetwork();
vn.addRealmMapping(prefix, target);
});

hooks.afterEach(function () {
unregisterCardReferencePrefix(prefix);
});

test('resolves scoped RRI to real URL and fetches', async function (assert) {
let interceptedUrl: string | undefined;
vn.mount(async (req: Request) => {
interceptedUrl = req.url;
return new Response('ok', { status: 200 });
});

let response = await vn.fetch('@test/fetch-realm/card-api');
assert.strictEqual(response.status, 200);
assert.strictEqual(
interceptedUrl,
'http://localhost:9000/fetch-realm/card-api',
);
});

test('passes through normal URLs unchanged', async function (assert) {
let interceptedUrl: string | undefined;
vn.mount(async (req: Request) => {
interceptedUrl = req.url;
return new Response('ok', { status: 200 });
});

await vn.fetch('http://localhost:9000/fetch-realm/card-api');
assert.strictEqual(
interceptedUrl,
'http://localhost:9000/fetch-realm/card-api',
);
});

test('passes RequestInit through when fetching with RRI', async function (assert) {
let interceptedMethod: string | undefined;
vn.mount(async (req: Request) => {
interceptedMethod = req.method;
return new Response('ok', { status: 200 });
});

await vn.fetch('@test/fetch-realm/card-api', { method: 'POST' });
assert.strictEqual(interceptedMethod, 'POST');
});

test('@cardstack/base/card-api resolves through full fetch chain', async function (assert) {
let baseVN = new VirtualNetwork();
baseVN.addRealmMapping('@cardstack/base/', 'http://localhost:4201/base/');
let interceptedUrl: string | undefined;
baseVN.mount(async (req: Request) => {
interceptedUrl = req.url;
return new Response('ok', { status: 200 });
});

let response = await baseVN.fetch('@cardstack/base/card-api');
assert.strictEqual(response.status, 200);
assert.strictEqual(interceptedUrl, 'http://localhost:4201/base/card-api');
unregisterCardReferencePrefix('@cardstack/base/');
});
});
});
4 changes: 1 addition & 3 deletions packages/realm-server/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
isUrlLike,
logger,
IndexWriter,
registerCardReferencePrefix,
type StatusArgs,
type IndexingProgressEvent,
} from '@cardstack/runtime-common';
Expand Down Expand Up @@ -107,8 +106,7 @@ for (let i = 0; i < fromUrls.length; i++) {
if (isUrlLike(from)) {
virtualNetwork.addURLMapping(new URL(from), to);
} else {
registerCardReferencePrefix(from, to.href);
virtualNetwork.addImportMap(from, (rest) => new URL(rest, to).href);
virtualNetwork.addRealmMapping(from, to.href);
}
}
let autoMigrate = migrateDB || undefined;
Expand Down
43 changes: 42 additions & 1 deletion packages/runtime-common/virtual-network.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { RealmPaths } from './paths';
import { RealmPaths, ensureTrailingSlash } from './paths';
import { baseRealm, isNode } from './index';
import {
registerCardReferencePrefix,
type RealmIdentifier,
} from './card-reference-resolver';
import type { ModuleDescriptor } from './package-shim-handler';
import {
PackageShimHandler,
Expand All @@ -19,6 +23,7 @@ export class VirtualNetwork {
private handlers: Handler[] = [];
private urlMappings: [string, string][] = [];
private importMap: Map<string, (rest: string) => string> = new Map();
private realmMappings = new Map<string, string>();

constructor(nativeFetch = createEnvironmentAwareFetch()) {
this.nativeFetch = nativeFetch;
Expand Down Expand Up @@ -66,6 +71,24 @@ export class VirtualNetwork {
this.importMap.set(prefix, handler);
}

addRealmMapping(realmIdentifier: string, targetURL: string): void {
let normalizedId = ensureTrailingSlash(realmIdentifier);
let normalizedTarget = ensureTrailingSlash(targetURL);
this.realmMappings.set(normalizedId, normalizedTarget);

// Backward compat bridge: populate both existing registration systems
// so that resolveImport and resolveCardReference continue to work
this.addImportMap(
normalizedId,
(rest) => new URL(rest, normalizedTarget).href,
);
registerCardReferencePrefix(normalizedId, normalizedTarget);
}
Comment on lines +74 to +86
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addRealmMapping currently only registers the scoped prefix into importMap and the global prefixMappings, but it doesn't interact with urlMappings (so it doesn't affect mapURL()/handle() remapping). The name makes it easy to assume it replaces addURLMapping for URL-based realm mapping too; consider either documenting this limitation explicitly on the method or extending it to handle URL-like realmIdentifier inputs by delegating to addURLMapping where appropriate.

Copilot uses AI. Check for mistakes.

knownRealms(): RealmIdentifier[] {
return [...this.realmMappings.keys()] as RealmIdentifier[];
}

private nativeFetch: typeof globalThis.fetch;

private resolveURLMapping(
Expand Down Expand Up @@ -118,6 +141,15 @@ export class VirtualNetwork {
urlOrRequest: string | URL | Request,
init?: RequestInit,
) => {
// Resolve RRI strings to real URLs before creating the Request,
// since new Request('@cardstack/base/...') would throw (not a valid URL).
if (typeof urlOrRequest === 'string') {
let resolved = this.resolveRRIToURL(urlOrRequest);
if (resolved) {
urlOrRequest = resolved;
}
}

let request =
urlOrRequest instanceof Request
? urlOrRequest
Expand All @@ -135,6 +167,15 @@ export class VirtualNetwork {
return response;
};

private resolveRRIToURL(rri: string): string | undefined {
for (let [prefix, target] of this.realmMappings) {
if (rri.startsWith(prefix)) {
return new URL(rri.slice(prefix.length), target).href;
}
}
return undefined;
}

private async runFetch(request: Request, init?: RequestInit) {
let handlers: FetcherMiddlewareHandler[] = this.handlers.map((h) => {
return async (request, next) => {
Expand Down
Loading