@zerotal/flow 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -0
  4. package/package.json +84 -0
  5. package/src/Component.ts +1498 -0
  6. package/src/Form.ts +189 -0
  7. package/src/Layout.ts +47 -0
  8. package/src/augment.ts +94 -0
  9. package/src/client/FlowClientError.ts +12 -0
  10. package/src/client/FlowComponent.ts +96 -0
  11. package/src/client/alpinejs-morph.d.ts +5 -0
  12. package/src/client/bridge.ts +3463 -0
  13. package/src/client/confirm.ts +338 -0
  14. package/src/client/cspAdapter.ts +79 -0
  15. package/src/client/cspEvaluator.ts +494 -0
  16. package/src/client/errorOverlay.ts +124 -0
  17. package/src/client/headless.ts +948 -0
  18. package/src/client/index.csp.ts +47 -0
  19. package/src/client/index.ts +56 -0
  20. package/src/client/optimistic.ts +52 -0
  21. package/src/client/timeline.ts +160 -0
  22. package/src/client/url.ts +51 -0
  23. package/src/commands/MakeFlowCommand.ts +290 -0
  24. package/src/compiler/cache.ts +101 -0
  25. package/src/compiler/directives.ts +107 -0
  26. package/src/compiler/helpers.ts +26 -0
  27. package/src/compiler/index.ts +172 -0
  28. package/src/compiler/transform.ts +2070 -0
  29. package/src/components.ts +1465 -0
  30. package/src/config.ts +52 -0
  31. package/src/decorators.ts +978 -0
  32. package/src/dehydrate.ts +333 -0
  33. package/src/durable.ts +243 -0
  34. package/src/events.ts +118 -0
  35. package/src/frameworkEvents.ts +40 -0
  36. package/src/headless.ts +1147 -0
  37. package/src/index.ts +267 -0
  38. package/src/jsx-dev-runtime.ts +14 -0
  39. package/src/jsx-runtime.ts +1272 -0
  40. package/src/mixins.ts +199 -0
  41. package/src/observability.ts +167 -0
  42. package/src/pagination.ts +300 -0
  43. package/src/presence.ts +81 -0
  44. package/src/provider/FlowProvider.ts +1401 -0
  45. package/src/registry.ts +118 -0
  46. package/src/renderContext.ts +55 -0
  47. package/src/router.ts +401 -0
  48. package/src/shared.ts +226 -0
  49. package/src/store.ts +116 -0
  50. package/src/synths/CarbonIntervalSynth.ts +22 -0
  51. package/src/synths/CarbonSynth.ts +20 -0
  52. package/src/synths/CollectionSynth.ts +62 -0
  53. package/src/synths/DateSynth.ts +24 -0
  54. package/src/synths/ModelSynth.ts +78 -0
  55. package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
  56. package/src/synths/index.ts +148 -0
  57. package/src/testing.ts +361 -0
  58. package/src/types.ts +315 -0
  59. package/src/uploads/FileUploads.ts +94 -0
  60. package/src/uploads/TemporaryUploadedFile.ts +274 -0
  61. package/src/uploads/sign.ts +18 -0
  62. package/src/urlSafety.ts +0 -0
  63. package/src/utils.ts +216 -0
  64. package/src/validation.ts +55 -0
@@ -0,0 +1,118 @@
1
+ import type { Component } from "./Component.ts";
2
+ import type { BaseModel } from "@zerotal/orm";
3
+ import { registerFlowModel } from "./synths/ModelSynth.ts";
4
+ import { getExposedMethods, getRenderlessMethods } from "./decorators.ts";
5
+
6
+ // ── Component registry ────────────────────────────────────────────────────────
7
+
8
+ export interface PageEntry {
9
+ PageClass: typeof Component;
10
+ path: string;
11
+ }
12
+
13
+ const _pages = new Map<string, PageEntry>();
14
+
15
+ /** Register a Component class by its constructor name. Called by Router.flow(). */
16
+ export function registerPage(path: string, PageClass: typeof Component): void {
17
+ const name = PageClass.name;
18
+ _pages.set(name, { PageClass, path });
19
+
20
+ // Auto-register any model classes declared in PageClass.models. The map key is
21
+ // the stable, developer-assigned name — using it (rather than ModelClass.name,
22
+ // which minifiers mangle) preserves ModelSynth's minification-safe design.
23
+ const models = (PageClass as unknown as PageClassWithMeta).models;
24
+ if (models) {
25
+ for (const [name, ModelClass] of Object.entries(models)) {
26
+ registerFlowModel(name, ModelClass as typeof BaseModel);
27
+ }
28
+ }
29
+ }
30
+
31
+ /** Look up a registered Component class by its constructor name. */
32
+ export function getPage(name: string): PageEntry | undefined {
33
+ return _pages.get(name);
34
+ }
35
+
36
+ /** @internal — used by the compiler to iterate all registered pages. */
37
+ export function _getPageEntries(): Map<string, PageEntry> {
38
+ return _pages;
39
+ }
40
+
41
+ /**
42
+ * Register a Component class that is used as a nested component (no route of its
43
+ * own). Called automatically the first time a parent embeds it via
44
+ * `this.child(...)`; call it explicitly from a provider when child components
45
+ * must survive a server restart before any parent page has been rendered.
46
+ *
47
+ * Registration is keyed by the class's constructor name and is idempotent — a class
48
+ * already registered (e.g. via `Router.flow()`) is left untouched, so the `path` here
49
+ * only applies to a first-time, routeless registration.
50
+ *
51
+ * @param PageClass - The Component subclass to register.
52
+ * @param path - Route path to associate; defaults to `""` for a nested (routeless) component.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * // In a ServiceProvider's boot method:
57
+ * registerComponent(CounterWidget);
58
+ * ```
59
+ */
60
+ export function registerComponent(PageClass: typeof Component, path = ""): void {
61
+ if (!_pages.has(PageClass.name)) registerPage(path, PageClass);
62
+ }
63
+
64
+ // ── Method allowlist (opt-in via @expose) ────────────────────────────────────
65
+
66
+ // Built-in action names handled directly by the WS handler before method validation.
67
+ // $mount is the lazy-loading trigger: runs onMount() for components that were
68
+ // initially rendered as placeholders (lazy/defer child components).
69
+ // $rerender is a no-op re-render from the current snapshot (no onMount) — used by
70
+ // dev fast refresh to apply new code while preserving state.
71
+ // $presence refills the @presence member list(s) from the broadcast presence channel.
72
+ // $shared re-reads @shared props from the room store (convergence) and re-renders.
73
+ export const BUILTIN_ACTIONS = new Set([
74
+ "$set",
75
+ "$refresh",
76
+ "$mount",
77
+ "$rerender",
78
+ "$presence",
79
+ "$shared",
80
+ ]);
81
+
82
+ /**
83
+ * Returns the set of method names on a Component subclass that are safe to call
84
+ * from the browser via WebSocket — exclusively those decorated with @expose.
85
+ *
86
+ * The opt-in model eliminates prototype-scanning ambiguity: no base-class
87
+ * exclusion lists, no `_`-prefix heuristics, no duck-typing to find the
88
+ * "base Component" prototype. If the developer did not @expose it, it is not callable.
89
+ */
90
+ export function getAllowedMethods(PageClass: typeof Component): Set<string> {
91
+ return getExposedMethods(PageClass);
92
+ }
93
+
94
+ /**
95
+ * Returns the set of method names that skip the re-render step after execution.
96
+ * Methods must still be @expose-d to be callable; @renderless only suppresses rendering.
97
+ */
98
+ export { getRenderlessMethods };
99
+
100
+ // ── PageClass static metadata interface ──────────────────────────────────────
101
+
102
+ /**
103
+ * A Component constructor plus the optional static metadata a page class may declare
104
+ * (`title`, `head`, `models`, `layout`). Used when resolving file-route page exports and
105
+ * when auto-registering associated model classes.
106
+ */
107
+ export interface PageClassWithMeta {
108
+ new (): Component;
109
+ name: string;
110
+ /** Sets the HTML <title> for this page. */
111
+ title?: string;
112
+ /** Raw HTML to inject into <head> for this page. */
113
+ head?: string;
114
+ /** Optional model associations for auto-registration. */
115
+ models?: Record<string, typeof import("@zerotal/orm").BaseModel>;
116
+ /** Optional layout class. */
117
+ layout?: new () => import("./Layout.ts").Layout;
118
+ }
@@ -0,0 +1,55 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { ChildMemo } from "./types.ts";
3
+
4
+ // ── Pending child descriptor ──────────────────────────────────────────────────
5
+
6
+ export interface PendingChild {
7
+ placeholderId: string;
8
+ /** The Component subclass to render. Typed loosely to avoid circular imports. */
9
+ WidgetClass: { new (): unknown; name: string };
10
+ props: Record<string, unknown>;
11
+ key: string;
12
+ }
13
+
14
+ // ── Render context ────────────────────────────────────────────────────────────
15
+
16
+ export class RenderContext {
17
+ readonly pending: PendingChild[] = [];
18
+ private readonly _existingByKey: ReadonlyMap<string, ChildMemo>;
19
+
20
+ constructor(existingChildren: ChildMemo[] = []) {
21
+ this._existingByKey = new Map(existingChildren.map((c) => [c.key, c]));
22
+ }
23
+
24
+ getExisting(key: string): ChildMemo | undefined {
25
+ return this._existingByKey.get(key);
26
+ }
27
+
28
+ registerChild(
29
+ WidgetClass: { new (): unknown; name: string },
30
+ props: Record<string, unknown>,
31
+ key: string,
32
+ ): string {
33
+ const placeholderId = `__flow_child_${key}`;
34
+ this.pending.push({ placeholderId, WidgetClass, props, key });
35
+ return placeholderId;
36
+ }
37
+ }
38
+
39
+ // ── AsyncLocalStorage store ───────────────────────────────────────────────────
40
+
41
+ const _als = new AsyncLocalStorage<RenderContext>();
42
+
43
+ export function createRenderContext(existingChildren?: ChildMemo[]): RenderContext {
44
+ return new RenderContext(existingChildren);
45
+ }
46
+
47
+ /** Run `fn` with `ctx` as the active render context for the current async chain. */
48
+ export function runWithRenderContext<T>(ctx: RenderContext, fn: () => T): T {
49
+ return _als.run(ctx, fn);
50
+ }
51
+
52
+ /** Returns the active render context, or undefined when called outside a render. */
53
+ export function getRenderContext(): RenderContext | undefined {
54
+ return _als.getStore();
55
+ }
package/src/router.ts ADDED
@@ -0,0 +1,401 @@
1
+ import {
2
+ Router,
3
+ type HttpContext,
4
+ type RouteRegistration,
5
+ type MiddlewareClass,
6
+ } from "@zerotal/core";
7
+ import type { Component } from "./Component.ts";
8
+ import type { PageClassWithMeta } from "./registry.ts";
9
+ import { registerPage } from "./registry.ts";
10
+ import { dehydrate, warnIfLarge } from "./dehydrate.ts";
11
+ import { _renderFlowPage } from "./jsx-runtime.ts";
12
+ import { populatePresence } from "./presence.ts";
13
+ import { populateShared } from "./shared.ts";
14
+ import { restoreDurable, persistDurable } from "./durable.ts";
15
+ import type { HtmlNode } from "./jsx-runtime.ts";
16
+ import { _runtimeJs } from "./provider/FlowProvider.ts";
17
+ import { randomComponentId, toScriptJson } from "./utils.ts";
18
+ import type { Layout } from "./Layout.ts";
19
+ import {
20
+ getUrlProps,
21
+ getSessionProps,
22
+ sessionKeyFor,
23
+ getRouteParamProps,
24
+ type ParamModel,
25
+ getLockedProps,
26
+ getExposedProps,
27
+ } from "./decorators.ts";
28
+
29
+ /**
30
+ * Register a Flow Component as a GET route.
31
+ *
32
+ * Middleware attached here (or via an enclosing Router.group()) runs on the
33
+ * initial page load AND is re-applied on every WebSocket update for this page
34
+ * (Livewire-style persistent middleware).
35
+ *
36
+ * @example
37
+ * import { Router } from '@zerotal/core';
38
+ * Router.flow('/dashboard', Dashboard);
39
+ * Router.flow('/admin', AdminPage, [RequireAuthMiddleware]);
40
+ */
41
+ export function flowRoute(
42
+ path: string,
43
+ PageClass: PageClassWithMeta,
44
+ middleware: MiddlewareClass[] = [],
45
+ ): RouteRegistration {
46
+ // Resolve the full runtime path (group prefixes included) so the snapshot
47
+ // memo path matches the registered route — required for middleware lookup
48
+ // on WebSocket updates.
49
+ const fullPath = Router.groupPrefix + path;
50
+ registerPage(fullPath, PageClass as unknown as typeof Component);
51
+
52
+ const handler = _makeFlowHandler(fullPath, PageClass);
53
+ return Router.get(path, handler, "handle", middleware);
54
+ }
55
+
56
+ /**
57
+ * Register a Flow page discovered by the file-route scanner.
58
+ *
59
+ * Unlike {@link flowRoute} (the `Router.flow` macro, which prepends the active group
60
+ * prefix), `fullPath` here is the COMPLETE URL already produced by `scanFileRoutes`
61
+ * (group prefix + file path). It is therefore registered at the absolute path — mirroring
62
+ * core's `_registerFileHandler` for `.ts` file routes — so a `/:tenancy` group prefix is
63
+ * applied once, not twice.
64
+ */
65
+ export function registerFlowFileRoute(
66
+ fullPath: string,
67
+ PageClass: PageClassWithMeta,
68
+ middleware: MiddlewareClass[] = [],
69
+ name?: string,
70
+ ): void {
71
+ registerPage(fullPath, PageClass as unknown as typeof Component);
72
+ const handler = _makeFlowHandler(fullPath, PageClass);
73
+ Router._registerAbsolute("GET", fullPath, handler as never, "handle", middleware, name);
74
+ }
75
+
76
+ // ── HTTP GET handler factory ──────────────────────────────────────────────────
77
+
78
+ type PageClassWithLayout = PageClassWithMeta & { layout?: new () => Layout };
79
+
80
+ /**
81
+ * Stable DOM-marker id for a JSX-native `layout(page)` wrapper. Derived from the
82
+ * override's source so two pages that wrap with the same layout produce the same
83
+ * id (their shells match on flow:navigate); different wrappers differ (full nav).
84
+ * Not security-sensitive — just a client-side same-layout comparison key.
85
+ */
86
+ export function _layoutId(page: Component): string {
87
+ const src = page.layout.toString();
88
+ let hash = 5381;
89
+ for (let i = 0; i < src.length; i++) hash = (hash * 33) ^ src.charCodeAt(i);
90
+ return "l" + (hash >>> 0).toString(36);
91
+ }
92
+
93
+ function _makeFlowHandler(path: string, PageClass: PageClassWithMeta) {
94
+ return class FlowPageHandler {
95
+ async handle(ctx: HttpContext): Promise<void> {
96
+ const http = ctx;
97
+ // Wrap the initial render: an onMount()/render() throw surfaces as the dev error overlay
98
+ // (like an action throw) rather than a bare 500. Production rethrows → a normal 500 with no
99
+ // stack leak.
100
+ try {
101
+ const page = new PageClass() as Component;
102
+ const compId = randomComponentId(PageClass.name);
103
+ const compName = PageClass.name;
104
+
105
+ // Identity used by nested children (deterministic child ids) and by
106
+ // persistent middleware (children inherit the parent's route path).
107
+ page._flowId = compId;
108
+ page._flowPath = path;
109
+
110
+ // Seed @url properties from the request's query string before onMount().
111
+ // This makes the page state reflect the URL on initial render.
112
+ _seedUrlProps(page, http.request);
113
+
114
+ // Seed @session properties from the session (if SessionMiddleware is active).
115
+ _seedSessionProps(page, compName, http);
116
+
117
+ // Seed @param properties from the matched route segments — including resolved
118
+ // model bindings. Initial GET only; round-trips restore these from the snapshot.
119
+ _seedRouteParams(page, ctx);
120
+
121
+ // boot() runs on every request (here: the initial render), before mount().
122
+ // Forward the request HttpContext so dynamic-segment pages can read route
123
+ // params and implicitly-bound models off `ctx.params` (e.g. `:account` ->
124
+ // Account at `ctx.params.account`) inside onBoot()/onMount().
125
+ await page.onBoot(ctx);
126
+
127
+ // Durable/resumable snapshots: if this component opted into `static durable` and a valid
128
+ // stored snapshot exists for this user/session + route, resume from it — restore state and
129
+ // run onHydrate (like a WS round-trip), skipping onMount. Otherwise mount fresh. A missing,
130
+ // tampered, or stale-key entry falls through to a normal mount. No-op unless opted in.
131
+ if (await restoreDurable(page, ctx)) {
132
+ await page.onHydrate();
133
+ } else {
134
+ await page.onMount(ctx);
135
+ }
136
+
137
+ // Persist @session props back to session after onMount (captures any changes).
138
+ _persistSessionProps(page, compName, http);
139
+
140
+ // A redirect() issued from onBoot()/onMount() short-circuits the initial render —
141
+ // the page never paints, the browser is sent straight on (e.g. a landing page that
142
+ // forwards "/" to "/dashboard", or a server-side auth bounce). Without this, the
143
+ // redirect was only honoured on WebSocket actions and silently ignored on first GET.
144
+ if (page._redirectUrl) {
145
+ http.redirect(page._redirectUrl, _redirectStatus(page._redirectStatus));
146
+ return;
147
+ }
148
+
149
+ // Fill @presence member lists before the first paint (the client refreshes them on
150
+ // join/leave thereafter). No-op unless the component has @presence props.
151
+ await populatePresence(page);
152
+
153
+ // Refill @shared props from the room store before the first paint (seeding the store
154
+ // with the default when the room is new). No-op unless the component has @shared props.
155
+ populateShared(page);
156
+
157
+ const innerHtml = await _renderFlowPage(page, () => page.render());
158
+
159
+ // dehydrate() hook runs at the end of the request, before serialisation.
160
+ await page.onDehydrate();
161
+ const snapshot = dehydrate(page, { id: compId, name: compName, path });
162
+ warnIfLarge(snapshot, compName);
163
+
164
+ // Persist the durable snapshot (or clear it if clearDurable() was called). No-op unless
165
+ // the component opted into `static durable`.
166
+ await persistDurable(page, ctx, snapshot);
167
+
168
+ // toScriptJson, not JSON.stringify: the snapshot carries @expose/@locked values and
169
+ // @url props seeded from the query string, so it routinely holds attacker-controlled
170
+ // strings that would otherwise close the <script> island. See toScriptJson's docblock.
171
+ const snapshotJson = toScriptJson(snapshot);
172
+
173
+ // The flow component root — always the same wrapper regardless of layout.
174
+ const flowRoot: HtmlNode = {
175
+ html: `<div data-flow-root x-data="{}" data-flow-id="${compId}" data-flow-name="${compName}">${innerHtml}</div>`,
176
+ };
177
+
178
+ // Layout resolution. The JSX-native `layout(page)` instance hook wins; otherwise
179
+ // fall back to the legacy `static layout = SomeLayout` class. Both wrap the root in
180
+ // a [data-flow-layout] marker so the client navigate logic swaps only the
181
+ // [data-flow-root] on same-layout navigations (a full navigation otherwise).
182
+ let bodyContent: string;
183
+ let layoutHead = "";
184
+
185
+ const wrapped = await page.layout(flowRoot);
186
+ const LayoutClass = (PageClass as PageClassWithLayout).layout;
187
+
188
+ if (wrapped !== flowRoot) {
189
+ // JSX-native layout. If the layout component declared its own identity
190
+ // (data-flow-layout on its root), honour it — that's the robust way to keep the
191
+ // shell persistent across navigations, especially when the wrapper passes
192
+ // page-specific props. Otherwise fall back to a source-derived id, which matches
193
+ // for identical wrappers like `(page) => <AppLayout>{page}</AppLayout>`.
194
+ bodyContent = /\bdata-flow-layout\b/.test(wrapped.html)
195
+ ? wrapped.html
196
+ : `<div data-flow-layout="${_layoutId(page)}">${wrapped.html}</div>`;
197
+ } else if (LayoutClass) {
198
+ const layout = new LayoutClass();
199
+ const layoutNode = await layout.render(flowRoot);
200
+ bodyContent = `<div data-flow-layout="${LayoutClass.name}">${layoutNode.html}</div>`;
201
+ layoutHead = (LayoutClass as { head?: string }).head ?? "";
202
+ } else {
203
+ bodyContent = flowRoot.html;
204
+ }
205
+
206
+ const titleTag = PageClass.title ? `<title>${PageClass.title}</title>` : "";
207
+ // Component head takes precedence; layout head provides global resources.
208
+ const headExtra = [PageClass.head ?? "", layoutHead].filter(Boolean).join("\n ");
209
+
210
+ const runtimeSrc = _runtimeJs() ? "/__flow/runtime.js" : "";
211
+ const runtimeTag = runtimeSrc ? `<script src="${runtimeSrc}" defer></script>` : "";
212
+
213
+ const html = `<!DOCTYPE html>
214
+ <html lang="en">
215
+ <head>
216
+ <meta charset="utf-8" />
217
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
218
+ <style>[flow\\:cloak],[x-cloak]{display:none!important}[flow\\:loading]{display:none}@media (prefers-reduced-motion:reduce){[flow\\:transition]{transition:none!important}::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}</style>
219
+ ${titleTag}
220
+ ${headExtra}
221
+ </head>
222
+ <body>
223
+ ${bodyContent}
224
+ <script type="application/json" id="flow-state-${compId}">${snapshotJson}</script>
225
+ ${runtimeTag}
226
+ </body>
227
+ </html>`;
228
+
229
+ http.html(html);
230
+ } catch (error) {
231
+ // Only UNEXPECTED throws get the dev overlay. Intended HTTP errors — auth (401/403), 404,
232
+ // validation, and redirects thrown as errors — carry a numeric `status` and must keep
233
+ // flowing to the framework's error handler. Production rethrows everything (no stack leak).
234
+ const intended = typeof (error as { status?: unknown } | null)?.status === "number";
235
+ if (!_IS_DEV_WORKER || intended) throw error;
236
+ http.response = new Response(_renderBootErrorPage(error, PageClass.name), {
237
+ status: 500,
238
+ headers: { "Content-Type": "text/html; charset=utf-8" },
239
+ });
240
+ }
241
+ }
242
+ };
243
+ }
244
+
245
+ /** True under the `serve --dev` worker — gates the dev-only initial-render error overlay. */
246
+ const _IS_DEV_WORKER = Bun.argv.includes("--dev-worker");
247
+
248
+ /**
249
+ * Dev-only: a minimal HTML page that boots the flow runtime and shows the error overlay for an
250
+ * error thrown during the initial GET render. The detail rides an embedded JSON `<script>` the
251
+ * bridge reads on load. Never used in production — the handler rethrows there.
252
+ * @internal exported for tests.
253
+ */
254
+ export function _renderBootErrorPage(error: unknown, compName: string): string {
255
+ const e = error instanceof Error ? error : new Error(String(error));
256
+ // Shares toScriptJson with the main state island — same hazard, one implementation.
257
+ const boot = toScriptJson({
258
+ name: e.name || "Error",
259
+ message: e.message,
260
+ stack: e.stack,
261
+ action: "initial render",
262
+ component: compName,
263
+ });
264
+ const runtimeTag = _runtimeJs() ? `<script src="/__flow/runtime.js" defer></script>` : "";
265
+ return `<!DOCTYPE html>
266
+ <html lang="en">
267
+ <head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>Error — ${compName}</title></head>
268
+ <body>
269
+ <script type="application/json" id="flow-boot-error">${boot}</script>
270
+ ${runtimeTag}
271
+ </body>
272
+ </html>`;
273
+ }
274
+
275
+ /** Narrow the advisory status from redirect() to the HTTP redirect codes, defaulting to 302. */
276
+ function _redirectStatus(status: number | null): 301 | 302 | 303 | 307 | 308 {
277
+ switch (status) {
278
+ case 301:
279
+ case 303:
280
+ case 307:
281
+ case 308:
282
+ return status;
283
+ default:
284
+ return 302;
285
+ }
286
+ }
287
+
288
+ // ── @url seeding ──────────────────────────────────────────────────────────────
289
+
290
+ /** Seed @url properties from the request query string before onMount(). */
291
+ function _seedUrlProps(page: Component, request: Request): void {
292
+ const urlProps = getUrlProps(page);
293
+ if (urlProps.size === 0) return;
294
+
295
+ const params = new URL(request.url).searchParams;
296
+ for (const [prop, opts] of urlProps) {
297
+ const paramName = opts.as ?? prop;
298
+ const raw = params.get(paramName);
299
+ if (raw === null) continue;
300
+
301
+ // Coerce to the existing type
302
+ const current = (page as unknown as Record<string, unknown>)[prop];
303
+ if (typeof current === "number") {
304
+ const n = Number(raw);
305
+ if (!isNaN(n)) (page as unknown as Record<string, unknown>)[prop] = n;
306
+ } else if (typeof current === "boolean") {
307
+ (page as unknown as Record<string, unknown>)[prop] = raw === "1" || raw === "true";
308
+ } else {
309
+ (page as unknown as Record<string, unknown>)[prop] = raw;
310
+ }
311
+ }
312
+ }
313
+
314
+ // ── @param seeding ────────────────────────────────────────────────────────────
315
+
316
+ /**
317
+ * Seed `@param` properties from the matched route segments before onMount().
318
+ *
319
+ * Initial GET only — that is the sole request carrying the URL's segments, and the only
320
+ * one where route-model bindings are resolved. WebSocket round-trips rebuild the context
321
+ * from the stored route *pattern* (so `ctx.params` is empty) and restore these fields from
322
+ * the snapshot instead, models included via the model synth. Calling this on that path
323
+ * would overwrite a hydrated value with nothing.
324
+ */
325
+ export function _seedRouteParams(page: Component, ctx: HttpContext): void {
326
+ const params = ctx.params as Record<string, unknown>;
327
+ if (!params) return;
328
+
329
+ const target = page as unknown as Record<string, unknown>;
330
+ const explicit = getRouteParamProps(page);
331
+
332
+ // Implicit: a serialized field whose name matches a route segment is filled from it —
333
+ // `@locked post` on `/posts/:post` receives the model the router already resolved, with
334
+ // nothing to declare. Only names the route actually matched are touched.
335
+ for (const prop of [...getLockedProps(page), ...getExposedProps(page)]) {
336
+ if (explicit.has(prop)) continue; // an explicit @param mapping wins
337
+ const value = params[prop];
338
+ if (value !== undefined) target[prop] = value;
339
+ }
340
+
341
+ // Explicit: @param — a named segment, or a model class matched against the resolved
342
+ // segment values so the field never has to know what the route called it.
343
+ for (const [prop, source] of explicit) {
344
+ const value =
345
+ typeof source === "function" ? _paramOfType(params, source) : params[source ?? prop];
346
+ if (value !== undefined) target[prop] = value;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * The first resolved segment that is an instance of `Model`.
352
+ *
353
+ * Segment order is the route's, so a path binding the same model twice
354
+ * (`/users/:user/friends/:friend`) yields the leftmost — name that one with
355
+ * `@param("friend")` instead.
356
+ */
357
+ function _paramOfType(params: Record<string, unknown>, Model: ParamModel): unknown {
358
+ for (const value of Object.values(params)) {
359
+ if (value instanceof Model) return value;
360
+ }
361
+ return undefined;
362
+ }
363
+
364
+ // ── @session seeding / persisting ─────────────────────────────────────────────
365
+
366
+ type SessionLike = {
367
+ has(k: string): boolean;
368
+ get(k: string): unknown;
369
+ set(k: string, v: unknown): void;
370
+ };
371
+
372
+ /** Resolve the session from ctx — avoids the facade / ALS chain. */
373
+ function _getSession(ctx: HttpContext): SessionLike | undefined {
374
+ return (ctx as unknown as { session?: SessionLike }).session;
375
+ }
376
+
377
+ /** Seed @session properties from the session store before onMount(). */
378
+ function _seedSessionProps(page: Component, className: string, ctx: HttpContext): void {
379
+ const sessionProps = getSessionProps(page);
380
+ if (sessionProps.size === 0) return;
381
+ const session = _getSession(ctx);
382
+ if (!session) return;
383
+ for (const [prop, opts] of sessionProps) {
384
+ const key = sessionKeyFor(prop, opts, className);
385
+ if (session.has(key)) {
386
+ (page as unknown as Record<string, unknown>)[prop] = session.get(key);
387
+ }
388
+ }
389
+ }
390
+
391
+ /** Persist @session properties back to the session store after onMount/actions. */
392
+ export function _persistSessionProps(page: Component, className: string, ctx: HttpContext): void {
393
+ const sessionProps = getSessionProps(page);
394
+ if (sessionProps.size === 0) return;
395
+ const session = _getSession(ctx);
396
+ if (!session) return;
397
+ for (const [prop, opts] of sessionProps) {
398
+ const key = sessionKeyFor(prop, opts, className);
399
+ session.set(key, (page as unknown as Record<string, unknown>)[prop]);
400
+ }
401
+ }