@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.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- package/src/validation.ts +55 -0
package/src/registry.ts
ADDED
|
@@ -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
|
+
}
|