From d4b645b1901b2ceb8177a734ca0998b007b2df98 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 30 Jul 2026 10:39:42 -0500 Subject: [PATCH 01/12] Add redirect rules to host routing A hostRoutingRules entry can now declare { path, redirectTo, statusCode? } alongside the existing { path, instance } form. A matched redirect rule answers 302 by default (301/308 opt-in); a realm-relative target resolves against the realm's mount pathname while external http(s) URLs pass through verbatim (other schemes are dropped). The request query string carries over when the target has none, and the declared redirect fires from either trailing-slash form in a single hop. The SPA mirrors the server: an in-app transition to a redirect-ruled path navigates to the target instead of rendering a card. The realm config editor gains a card/redirect toggle with advisory validation of the target. Co-Authored-By: Claude Fable 5 --- packages/base/realm-config.gts | 176 +++++++++++++++++- packages/host/app/routes/index.gts | 17 +- .../host/app/services/host-mode-service.ts | 32 +++- .../realm-config-routing-rule-edit-test.gts | 65 +++++++ packages/realm-server/handlers/serve-index.ts | 54 +++++- packages/realm-server/lib/realm-routing.ts | 12 +- .../tests/host-routing-validation-test.ts | 28 +++ .../realm-server/tests/realm-routing-test.ts | 28 ++- .../server-endpoints/index-responses-test.ts | 100 +++++++++- .../runtime-common/host-routing-validation.ts | 103 +++++++++- packages/runtime-common/realm.ts | 41 +++- .../tests/host-routing-validation-test.ts | 82 ++++++++ 12 files changed, 699 insertions(+), 39 deletions(-) diff --git a/packages/base/realm-config.gts b/packages/base/realm-config.gts index 26e3abfbd69..4182bbc30a4 100644 --- a/packages/base/realm-config.gts +++ b/packages/base/realm-config.gts @@ -10,25 +10,34 @@ import { realmURL, } from './card-api'; import BooleanField from './boolean'; +import NumberField from './number'; import StringField from './string'; import CardInfoTemplates from './default-templates/card-info'; import { cardDefComputedFields, + DEFAULT_REDIRECT_STATUS, findDuplicateRoutingPaths, getField, getFieldIcon, + REDIRECT_STATUS_CODES, + validateRedirectTarget, validateRoutingPath, } from '@cardstack/runtime-common'; import { + BoxelInput, BoxelInputGroup, + BoxelSelect, FieldContainer, Header, + RadioInput, } from '@cardstack/boxel-ui/components'; import { eq } from '@cardstack/boxel-ui/helpers'; import FileSettingsIcon from '@cardstack/boxel-icons/file-settings'; import LinkIcon from '@cardstack/boxel-icons/link'; +import { fn } from '@ember/helper'; import { action } from '@ember/object'; import type Owner from '@ember/owner'; +import { tracked } from '@glimmer/tracking'; import { startCase } from 'lodash-es'; import type { FieldsTypeFor } from './card-api'; @@ -36,7 +45,12 @@ class RoutingRuleAtom extends Component { } +let routingRuleKindGroupNumber = 0; + class RoutingRuleEdit extends Component { + // Which target editor is showing. Backed by the data: a rule whose + // `redirectTo` is non-null (empty string included — `setKind` seeds + // '' so the choice survives a reload) is a redirect rule. The tracked + // override exists only so the toggle responds instantly while the + // model write settles. + @tracked private kindOverride: 'card' | 'redirect' | null = null; + + private kindItems: { id: 'card' | 'redirect'; text: string }[] = [ + { id: 'card', text: 'Render a card' }, + { id: 'redirect', text: 'Redirect' }, + ]; + + private kindRadioGroup = `__routing_rule_kind${routingRuleKindGroupNumber++}__`; + + private statusCodeOptions = [...REDIRECT_STATUS_CODES]; + constructor(owner: Owner, args: any) { super(owner, args); // The path input renders an empty input alongside a fixed `/` @@ -104,6 +137,69 @@ class RoutingRuleEdit extends Component { this.args.model.path = `/${trimmed}`; } + get kind(): 'card' | 'redirect' { + return ( + this.kindOverride ?? + (this.args.model.redirectTo != null ? 'redirect' : 'card') + ); + } + + get isRedirect(): boolean { + return this.kind === 'redirect'; + } + + // Switching kind clears the other kind's target so a rule is never + // ambiguous (the read path prefers `redirectTo` when both are set, + // but only a hand-edited realm.json can get into that state). + @action + setKind(kind: 'card' | 'redirect') { + this.kindOverride = kind; + if (kind === 'redirect') { + this.args.model.instance = undefined; + if (this.args.model.redirectTo == null) { + this.args.model.redirectTo = ''; + } + } else { + this.args.model.redirectTo = undefined; + this.args.model.statusCode = undefined; + } + } + + get redirectToValue(): string { + return this.args.model.redirectTo ?? ''; + } + + @action + setRedirectTo(value: string) { + this.args.model.redirectTo = value ?? ''; + } + + get redirectWarning(): string | undefined { + return validateRedirectTarget(this.args.model.redirectTo); + } + + get selectedStatusCode(): number { + return this.args.model.statusCode ?? DEFAULT_REDIRECT_STATUS; + } + + @action + setStatusCode(code: number) { + this.args.model.statusCode = code; + } + + @action + statusCodeLabel(code: number): string { + switch (code) { + case 301: + return '301 · permanent'; + case 308: + return '308 · permanent, method-preserving'; + case 302: + default: + return '302 · temporary'; + } + } + // The chooser is locked to the consuming realm; pass it through // explicitly rather than letting LinksToEditor read it from // `RealmURLContext`. The context is only provided by the operator-mode @@ -119,6 +215,21 @@ class RoutingRuleEdit extends Component {