Add host mode routing from realm.json#4709
Conversation
CS-10054: assert getHostRoutingMap returns [{ path, id }] pairs read
from the indexed RealmConfig card. The test fails today (method does
not exist) and pins the desired shape for the implementation pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Preview deploymentsHost Test Results 1 files ±0 1 suites ±0 1h 49m 15s ⏱️ + 1m 11s Results for commit e2ac7e4. ± Comparison against earlier commit abe813f. Realm Server Test Results 1 files ± 0 1 suites ±0 8m 27s ⏱️ -23s Results for commit e2ac7e4. ± Comparison against earlier commit abe813f. |
…s-10054 # Conflicts: # packages/realm-server/tests/index.ts
…s-10054 # Conflicts: # packages/realm-server/server.ts # packages/realm-server/tests/index.ts
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8e42fa0b0c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| @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.', |
There was a problem hiding this comment.
with the way it was defined previously, the routing was defined via a relationship:
{
"data": {
"type": "card",
"attributes": {
…
"hostRoutingRules": [
{ "path": "/whitepaper" },
{ "path": "/docs" }
]
},
"relationships": {
"hostRoutingRules.0.instance": {
"links": { "self": "./white-paper" }
},
"hostRoutingRules.1.instance": {
"links": { "self": "./docs" }
}
},
…
}
}
IMO this new structure is much more readable:
{
"data": {
"type": "card",
"attributes": {
…
"hostRoutingRules": [
{ "path": "/whitepaper", "instance": "./white-paper" },
{ "path": "/docs", "instance": "./docs" }
]
},
…
}
}
and we previously agreed that the default edit template for realm config should cover validation, so losing the typing of CardDef isn’t a dealbreaker
There was a problem hiding this comment.
Pull request overview
This PR adds “host mode” routing based on hostRoutingRules stored in a realm’s realm.json (RealmConfig card), allowing published realms to serve custom bare paths (e.g. /whitepaper) that render a target card both server-side (SSR head/isolated/scoped CSS) and client-side (SPA hydration/navigation).
Changes:
- Add
Realm.getHostRoutingMap()to read routing rules from the indexed RealmConfig card and return a{path,id}map. - Update realm-server
serve-indexto (a) rewrite SSR fetch target based on a matched routing rule and (b) injecthostRoutingMapinto the host environment meta tag per request. - Add unit + e2e coverage for routing behavior and adjust host app to resolve routed paths before building the default card URL.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/runtime-common/realm.ts | Adds getHostRoutingMap() to read and validate routing rules from the indexed RealmConfig card. |
| packages/realm-server/handlers/serve-index.ts | Applies routing rules for SSR rendering and injects hostRoutingMap into the environment meta tag; tweaks meta-tag rewrite regex. |
| packages/host/config/environment.js | Adds default hostRoutingMap to the host config surface for realm-server injection. |
| packages/host/app/services/host-mode-service.ts | Exposes hostRoutingMap from config and adds resolveRoutedPath() helper. |
| packages/host/app/routes/index.gts | Uses routing resolution to pick the routed target card ID before default URL construction in host mode. |
| packages/base/realm-config.gts | Changes routing rule instance field to a flat string (instead of a JSON:API relationship) so it indexes as attributes. |
| packages/realm-server/tests/realm-routing-test.ts | Adds a QUnit test covering Realm.getHostRoutingMap() behavior. |
| packages/realm-server/tests/index.ts | Registers the new routing test in the realm-server test suite. |
| packages/matrix/tests/host-mode.spec.ts | Adds an end-to-end test that publishes routing rules and verifies bare-path resolution renders the target card in host mode. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try { | ||
| // resolveRRI accepts URL form, registered-prefix form | ||
| // (`@cardstack/foo/...`), `$REALM/...`, and relative | ||
| // references — and preserves whichever canonical form the | ||
| // input/realm are in. That's what we want here so the same- | ||
| // realm guard below stays robust to either addressing scheme. | ||
| id = resolveRRI(instance, realmRRI); | ||
| } catch { |
| // Defensive same-realm guard. The project spec restricts routing | ||
| // rules to cards within the same realm, and CS-10052 enforces | ||
| // that in the UI — but the file is hand-editable, so the read | ||
| // path must filter too. Without this guard, a realm owner could | ||
| // point `instance` at a private realm's card and the serve-index | ||
| // cardURL rewrite would surface its prerendered HTML through | ||
| // their public realm's routed path, bypassing the private | ||
| // realm's permissions. | ||
| let idCanonical = unresolveCardReference(id); | ||
| if (!idCanonical.startsWith(realmCanonical)) { | ||
| this.#log.warn( |
| let publicPermissions = await hasPublicPermissions(cardURL, routingDeps); | ||
|
|
||
| if (!publicPermissions) { | ||
| ctxt.body = injectHeadHTML( | ||
| indexHTML, | ||
| `<title>Boxel</title>\n${defaultIconLinks().join('\n')}`, | ||
| ); | ||
| 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(); |
| // 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. |
| // 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; |
| // 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. |
When a realm config has
hostRoutingRules:The published realm resolves custom routes: