Skip to content
Draft
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
10 changes: 7 additions & 3 deletions packages/base/realm-config.gts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
field,
contains,
containsMany,
linksTo,
} from './card-api';
import BooleanField from './boolean';
import StringField from './string';
Expand All @@ -19,9 +18,14 @@ export class RoutingRuleField extends FieldDef {
description: 'Static path within the realm, e.g. "/" or "/pricing"',
});

@field instance = linksTo(CardDef, {
// Card URL of the instance to render when the realm is navigated at
// `path`. Stored as a string (rather than a `linksTo`) so the rule
// serializes flat alongside `path` inside `attributes` — no JSON:API
// relationships split. Relative URLs (e.g. `./whitepaper`) are
// resolved against the realm root by the routing-map reader.
@field instance = contains(StringField, {
description:
'Card instance to render when the realm is navigated at the given path',
'Card URL to render at this path. Relative URLs are resolved against the realm root.',
});
}

Expand Down
10 changes: 9 additions & 1 deletion packages/host/app/routes/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,15 @@ export default class Card extends Route {
}) {
if (this.hostModeService.isActive) {
let normalizedPath = params.path ?? '';
let cardUrl = `${this.hostModeService.hostModeOrigin}/${normalizedPath}`;
// CS-10055: a routing rule in the realm config can map a bare path
// to a target card. When the path matches a rule, use the rule's
// target id directly; otherwise resolve the path as a card URL
// under the host-mode origin.
let routedId = this.hostModeService.resolveRoutedPath(
normalizedPath || '/',
);
let cardUrl =
routedId ?? `${this.hostModeService.hostModeOrigin}/${normalizedPath}`;

return this.store.get(cardUrl);
}
Expand Down
17 changes: 17 additions & 0 deletions packages/host/app/services/host-mode-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ export default class HostModeService extends Service {
return this.operatorModeStateService.realmURL;
}

// CS-10055: routing rules from the realm config card. The realm-server
// injects this map as `window.__hostRoutingMap` in the SPA shell so the
// first-render decision in the index route is synchronous.
get hostRoutingMap(): { path: string; id: string }[] {
let map = (window as { __hostRoutingMap?: unknown }).__hostRoutingMap;
return Array.isArray(map) ? (map as { path: string; id: string }[]) : [];
}

// Returns the target card id if `path` matches a routing rule, else null.
// `path` is the path within the realm; a leading slash is added if absent
// so the index path is matchable as either '' or '/'.
resolveRoutedPath(path: string): string | null {
let normalized = path.startsWith('/') ? path : `/${path}`;
let rule = this.hostRoutingMap.find((r) => r.path === normalized);
return rule ? rule.id : null;
}

get currentCardId() {
if (this.isActive) {
let stack = this.hostModeStateService.stackItems;
Expand Down
95 changes: 94 additions & 1 deletion packages/matrix/tests/host-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, test } from './fixtures';
import {
createRealm,
createSubscribedUserAndLogin,
login,
logout,
postCardSource,
waitUntil,
Expand All @@ -10,6 +11,7 @@ import { appURL } from '../helpers/isolated-realm-server';
import { randomUUID } from 'crypto';

test.describe('Host mode', () => {
let realmURL: string;
let publishedRealmURL: string;
let publishedCardURL: string;
let publishedWhitePaperCardURL: string;
Expand All @@ -31,7 +33,7 @@ test.describe('Host mode', () => {
const realmName = `host-mode-${randomUUID()}`;

await createRealm(page, realmName);
const realmURL = new URL(`${username}/${realmName}/`, serverIndexUrl).href;
realmURL = new URL(`${username}/${realmName}/`, serverIndexUrl).href;

await page.goto(realmURL);
await page.locator('[data-test-stack-item-content]').first().waitFor();
Expand Down Expand Up @@ -340,4 +342,95 @@ test.describe('Host mode', () => {
const pageTitle = await page.title();
expect(pageTitle).toBe('My Custom Title From Head Template');
});

// CS-10054 + CS-10055: routing rules in the realm config card resolve a
// bare path (no .json extension) to a target card and render it in host
// mode. This test fails until the host-mode request handler reads the
// routing map from the indexed RealmConfig card and applies it.
test('routing rule resolves a bare path to its target card', async ({
page,
}) => {
// beforeEach logged out — re-login so we can write to the source realm.
await login(page, username, password);
await page.goto(realmURL);
await page.locator('[data-test-stack-item-content]').first().waitFor();

// Overwrite realm.json with a routing rule mapping /whitepaper to the
// existing white-paper card. The auto-generated realm.json from
// createRealm has no rules; we replace it before re-publishing.
await postCardSource(
page,
realmURL,
'realm.json',
JSON.stringify({
data: {
type: 'card',
attributes: {
cardInfo: { name: `Routed Realm ${randomUUID()}` },
hostRoutingRules: [
{ path: '/whitepaper', instance: './white-paper' },
],
},
meta: {
adoptsFrom: {
module: 'https://cardstack.com/base/realm-config',
name: 'RealmConfig',
},
},
},
}),
);

// Re-publish so the routing rule lands in the published realm.
await page.evaluate(
async ({ realmURL, publishedRealmURL }) => {
let sessions = JSON.parse(
window.localStorage.getItem('boxel-session') ?? '{}',
);
let token = sessions[realmURL];
if (!token) {
throw new Error(`No session token found for ${realmURL}`);
}
let response = await fetch('https://localhost:4205/_publish-realm', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: token,
},
body: JSON.stringify({
sourceRealmURL: realmURL,
publishedRealmURL,
}),
});
if (!response.ok) {
throw new Error(await response.text());
}
},
{ realmURL, publishedRealmURL },
);

await logout(page);

// The _publish-realm POST returns 202 before the published realm has
// finished re-indexing the new realm.json. Poll the bare URL until the
// server-rendered HTML contains the target card's marker — that
// confirms the routing rule is indexed AND the server cardURL rewrite
// is applying it. Mirrors the waitUntil pattern in the
// `published card response` test above.
let routedURL = `${publishedRealmURL}whitepaper`;
await waitUntil(async () => {
let response = await page.request.get(routedURL, {
headers: { Accept: 'text/html' },
});
if (!response.ok()) {
return false;
}
let text = await response.text();
return text.includes('data-test-white-paper');
});

await page.goto(routedURL);
await expect(page.locator('[data-test-white-paper]')).toBeVisible();
});
});
46 changes: 45 additions & 1 deletion packages/realm-server/handlers/serve-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
logger,
param,
query,
RealmPaths,
sanitizeHeadHTMLToString,
} from '@cardstack/runtime-common';
import type { MatrixClient } from '@cardstack/runtime-common/matrix-client';
Expand All @@ -19,6 +20,7 @@ import {
} from '../lib/index-html-injection';
import { retrieveScopedCSS } from '../lib/retrieve-scoped-css';
import {
findOrMountRealm,
getPublishedRealmInfo,
hasPublicPermissions,
isIndexedCardInstance,
Expand Down Expand Up @@ -96,7 +98,9 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {

let work = (async () => {
let indexHTML = (await getIndexHTML()).replace(
/(<meta name="@cardstack\/host\/config\/environment" content=")([^"].*)(">)/,
// Closing matches both HTML5-style `">` and Vite's XHTML-style `" />`
// so the rewrite runs against both production build and Vite dev HTML.
/(<meta name="@cardstack\/host\/config\/environment" content=")([^"].*?)("\s*\/?>)/,
(_match, g1, g2, g3) => {
let config = JSON.parse(decodeURIComponent(g2));

Expand Down Expand Up @@ -330,6 +334,27 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {
return;
}

// CS-10055: host routing rules in the realm config can map a bare path
// (e.g. /whitepaper) to a target card. When the requested path matches
// a rule, rewrite cardURL so the head/isolated/scoped CSS fetched
// below render the routed target. The same map is also injected as
// a <script> further down so the SPA can resolve the path post-hydration.
let routingMap: { path: string; id: string }[] = [];
let routedRealm = await findOrMountRealm(requestURL, routingDeps);
if (routedRealm) {
routingMap = await routedRealm.getHostRoutingMap();
if (routingMap.length > 0) {
let realmURL = new URL(routedRealm.url);
realmURL.protocol = requestURL.protocol;
let realmPaths = new RealmPaths(realmURL);
let pathInRealm = '/' + realmPaths.local(requestURL);
let rule = routingMap.find((r) => r.path === pathInRealm);
if (rule) {
cardURL = new URL(rule.id);
}
}
}

headLog.debug(`Fetching head HTML for ${cardURL.href}`);
isolatedLog.debug(`Fetching isolated HTML for ${cardURL.href}`);
scopedCSSLog.debug(`Fetching scoped CSS for ${cardURL.href}`);
Expand Down Expand Up @@ -419,6 +444,25 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {
);
}

if (routingMap.length > 0 && routedRealm) {
// Rules are stored realm-relative ('/whitepaper'). The client sees URL
// paths that include the realm's mount segment ('/routing/whitepaper'
// when the realm is mounted at '/routing/' on the published host). For
// the SPA's path lookup to be a direct equality match, prefix each
// rule path with the realm's pathname before serializing.
let realmPathname = new URL(routedRealm.url).pathname;
let hostScopedMap = routingMap.map((rule) => ({
path: realmPathname + rule.path.replace(/^\//, ''),
id: rule.id,
}));
// Escape `<` so any embedded `</script>` or `<!--` in the JSON can't
// break out of the script context.
let safeMap = JSON.stringify(hostScopedMap).replace(/</g, '\\u003c');
headFragments.push(
`<script>window.__hostRoutingMap = ${safeMap};</script>`,
);
}

if (headFragments.length > 0) {
responseHTML = injectHeadHTML(responseHTML, headFragments.join('\n'));
}
Expand Down
1 change: 1 addition & 0 deletions packages/realm-server/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ const ALL_TEST_FILES: string[] = [
'./realm-registry-writes-test',
'./realm-file-changes-listener-test',
'./realm-index-updated-listener-test',
'./realm-routing-test',
'./module-cache-invalidation-listener-test',
'./pg-adapter-subscribe-test',
'./module-cache-coordination-test',
Expand Down
83 changes: 83 additions & 0 deletions packages/realm-server/tests/realm-routing-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { module, test } from 'qunit';
import { basename } from 'path';
import { rri } from '@cardstack/runtime-common';
import type { LooseSingleCardDocument, Realm } from '@cardstack/runtime-common';
import { setupPermissionedRealmCached } from './helpers';

// CS-10054 TDD: pin the desired behavior of Realm.getHostRoutingMap before
// the method exists. The fixture is a realm.json RealmConfig card with one
// routing rule mapping `/whitepaper` to a white-paper card in the same realm.
function makeRoutingFixture(): Record<
string,
string | LooseSingleCardDocument
> {
return {
'white-paper.gts': `
import { CardDef, Component } from "https://cardstack.com/base/card-api";
export class WhitePaper extends CardDef {
static displayName = 'White Paper';
static isolated = class Isolated extends Component<typeof this> {
<template>
<article data-test-white-paper>White paper content</article>
</template>
}
}
`,
'white-paper.json': {
data: {
type: 'card',
attributes: {},
meta: {
adoptsFrom: { module: rri('./white-paper'), name: 'WhitePaper' },
},
},
},
'realm.json': {
data: {
type: 'card',
attributes: {
cardInfo: { name: 'Routing Test Realm' },
hostRoutingRules: [
{ path: '/whitepaper', instance: './white-paper' },
],
},
meta: {
adoptsFrom: {
module: rri('https://cardstack.com/base/realm-config'),
name: 'RealmConfig',
},
},
},
},
};
}

module(basename(__filename), function () {
module('Realm.getHostRoutingMap', function (hooks) {
let realmURL = new URL('http://127.0.0.1:4444/routing-unit/');
let testRealm: Realm;

setupPermissionedRealmCached(hooks, {
realmURL,
permissions: { '*': ['read'] },
fileSystem: makeRoutingFixture(),
onRealmSetup({ testRealm: realm }) {
testRealm = realm;
},
});

hooks.beforeEach(async function () {
await testRealm.indexing();
});

test('reads routing rules from the indexed RealmConfig card', async function (assert) {
let map = await testRealm.getHostRoutingMap();

assert.deepEqual(
map,
[{ path: '/whitepaper', id: `${realmURL.href}white-paper` }],
'returns one rule mapping /whitepaper to the absolute white-paper URL',
);
});
});
});
Loading
Loading