diff --git a/packages/host/app/services/network.ts b/packages/host/app/services/network.ts index c452419d57..08fc828f7d 100644 --- a/packages/host/app/services/network.ts +++ b/packages/host/app/services/network.ts @@ -5,7 +5,6 @@ import { VirtualNetwork, authorizationMiddleware, baseRealm, - registerCardReferencePrefix, fetcher, } from '@cardstack/runtime-common'; @@ -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; diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index f065e41a2d..3aaadb6e58 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -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'; @@ -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 } } diff --git a/packages/realm-server/tests/card-reference-resolver-test.ts b/packages/realm-server/tests/card-reference-resolver-test.ts index caa6cc48f6..4a7641341e 100644 --- a/packages/realm-server/tests/card-reference-resolver-test.ts +++ b/packages/realm-server/tests/card-reference-resolver-test.ts @@ -6,6 +6,7 @@ import { resolveCardReference, resolveRRI, RealmPaths, + VirtualNetwork, } from '@cardstack/runtime-common'; import type { SingleCardDocument, @@ -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', + ); + }); + + 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/'); + }); + }); + + 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/'); + }); + }); }); diff --git a/packages/realm-server/worker.ts b/packages/realm-server/worker.ts index 86d654b046..41f7d7f60e 100644 --- a/packages/realm-server/worker.ts +++ b/packages/realm-server/worker.ts @@ -6,7 +6,6 @@ import { isUrlLike, logger, IndexWriter, - registerCardReferencePrefix, type StatusArgs, type IndexingProgressEvent, } from '@cardstack/runtime-common'; @@ -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; diff --git a/packages/runtime-common/virtual-network.ts b/packages/runtime-common/virtual-network.ts index 6254224cdb..5ba5bfd29e 100644 --- a/packages/runtime-common/virtual-network.ts +++ b/packages/runtime-common/virtual-network.ts @@ -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, @@ -19,6 +23,7 @@ export class VirtualNetwork { private handlers: Handler[] = []; private urlMappings: [string, string][] = []; private importMap: Map string> = new Map(); + private realmMappings = new Map(); constructor(nativeFetch = createEnvironmentAwareFetch()) { this.nativeFetch = nativeFetch; @@ -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); + } + + knownRealms(): RealmIdentifier[] { + return [...this.realmMappings.keys()] as RealmIdentifier[]; + } + private nativeFetch: typeof globalThis.fetch; private resolveURLMapping( @@ -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 @@ -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) => {