@zerotal/flow 1.8.1 → 1.10.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.
@@ -0,0 +1,205 @@
1
+ // ── @zerotal/flow plain-form enhancement bundle ─────────────────────────────────
2
+ //
3
+ // The entry point for the second Bun.build() in FlowProvider.onBooting(). It
4
+ // produces /__flow/enhance.js.
5
+ //
6
+ // **Deliberately separate from the runtime bundle, and deliberately tiny.** The
7
+ // whole point of this feature is a page with *no Flow component on it* — an
8
+ // ordinary server-rendered form, on a route that is not `Router.flow`. Such a page
9
+ // is never served `/__flow/runtime.js`, so there is no script to hook into; and
10
+ // pulling Alpine and the WebSocket bridge onto a page with no component to drive
11
+ // would be paying the entire framework's client cost for a submit handler.
12
+ //
13
+ // **This file exports nothing, and must not.** It is loaded as a classic script,
14
+ // where a trailing `export` statement is a SyntaxError that discards the whole
15
+ // bundle — silently, because the forms then post natively and the page still
16
+ // works. The decisions worth testing live in `enhanceSupport.ts`, which Bun inlines
17
+ // back in at build time.
18
+ //
19
+ // ## The contract
20
+ //
21
+ // <form method="post" action="/subscribe" data-enhance>
22
+ //
23
+ // The form posts through `fetch`, and the matching form in the response replaces
24
+ // this one in place. Validation errors then appear where they belong and the rest
25
+ // of the page is untouched — no flash, no scroll loss, no re-render of a page that
26
+ // did not change.
27
+ //
28
+ // Everything about it degrades:
29
+ //
30
+ // - **No JavaScript is an ordinary post.** `data-enhance` is additive on a form
31
+ // that already has `method` and `action`, so the no-JS path is not a fallback
32
+ // that was written and might rot — it is the form itself, unmodified. If that
33
+ // is ever not free, the word "enhancement" is doing no work.
34
+ // - **A network failure re-submits natively.** The difference between a bad
35
+ // connection being slow and a bad connection losing what somebody typed.
36
+ // - **A redirect is followed**, its document swapped in and `pushState`d, so the
37
+ // address bar agrees with the page.
38
+
39
+ import { ATTR, BUSY_ATTR, matchIn, sameUrl, targetFor } from "./enhanceSupport.ts";
40
+
41
+ /**
42
+ * Move focus back to where it was before the swap.
43
+ *
44
+ * Replacing a subtree destroys the focused element, and a form that reports "this
45
+ * field is required" having just taken the cursor away from that field is worse
46
+ * than one that did not enhance at all. Matched by `name`, which survives a
47
+ * re-render in a way a DOM node cannot.
48
+ */
49
+ function restoreFocus(root: Element, name: string | null, start: number | null): void {
50
+ if (!name) return;
51
+ const next = root.querySelector<HTMLInputElement>(`[name="${CSS.escape(name)}"]`);
52
+ if (!next) return;
53
+ next.focus();
54
+ if (start === null) return;
55
+ try {
56
+ next.setSelectionRange(start, start);
57
+ } catch {
58
+ // Not a text-like control (checkbox, select, file) — focus alone is the job.
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Submit the form the way the browser would have, with the enhancement out of the
64
+ * way so this handler does not catch its own fallback.
65
+ */
66
+ function nativeSubmit(form: HTMLFormElement, submitter: HTMLElement | null): void {
67
+ form.removeAttribute(ATTR);
68
+ if (submitter && "click" in submitter) {
69
+ // Through the submitter, so a named button still contributes its value.
70
+ submitter.click();
71
+ return;
72
+ }
73
+ form.submit();
74
+ }
75
+
76
+ /**
77
+ * Handle one enhanced submission.
78
+ *
79
+ * Throws nothing: every failure path either falls back to a native submit or
80
+ * leaves the page exactly as it was.
81
+ */
82
+ async function submitEnhanced(form: HTMLFormElement, submitter: HTMLElement | null): Promise<void> {
83
+ const method = (form.getAttribute("method") || "get").toUpperCase();
84
+ const action = form.getAttribute("action") || window.location.href;
85
+
86
+ // `new FormData(form, submitter)` includes the button that was actually pressed,
87
+ // which is how `<button name="intent" value="delete">` tells the server which one.
88
+ const data = new FormData(form, submitter as HTMLButtonElement | null);
89
+
90
+ let url = action;
91
+ let body: FormData | null = data;
92
+ if (method === "GET") {
93
+ // A GET form carries its fields in the query string, not a body.
94
+ const query = new URLSearchParams();
95
+ for (const [key, value] of data.entries()) {
96
+ if (typeof value === "string") query.append(key, value);
97
+ }
98
+ const separator = action.includes("?") ? "&" : "?";
99
+ url = query.toString() ? `${action}${separator}${query}` : action;
100
+ body = null;
101
+ }
102
+
103
+ const target = targetFor(form, document, (selector) =>
104
+ console.warn(`[flow] data-enhance-target="${selector}" matched nothing; replacing the form.`),
105
+ );
106
+
107
+ const active = document.activeElement as HTMLInputElement | null;
108
+ const focusedName = active && form.contains(active) ? active.getAttribute("name") : null;
109
+ const caret = (() => {
110
+ try {
111
+ return active?.selectionStart ?? null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ })();
116
+
117
+ form.setAttribute(BUSY_ATTR, "");
118
+
119
+ let response: Response;
120
+ let html: string;
121
+ try {
122
+ response = await fetch(url, {
123
+ method,
124
+ ...(body ? { body } : {}),
125
+ credentials: "same-origin",
126
+ redirect: "follow",
127
+ headers: { "X-Flow-Enhance": "1", Accept: "text/html" },
128
+ });
129
+ html = await response.text();
130
+ } catch {
131
+ // The network, not the server. Hand the submission back to the browser, which
132
+ // shows its own error and — crucially — has not lost the form data.
133
+ form.removeAttribute(BUSY_ATTR);
134
+ nativeSubmit(form, submitter);
135
+ return;
136
+ }
137
+
138
+ const parsed = new DOMParser().parseFromString(html, "text/html");
139
+
140
+ // `fetch` followed the redirect for us, so a changed URL means the server sent us
141
+ // somewhere. Swap the whole document and record the move, or the address bar
142
+ // keeps claiming we are on the page that did the posting.
143
+ if (response.redirected || !sameUrl(response.url, url, window.location.href)) {
144
+ document.documentElement.replaceWith(document.importNode(parsed.documentElement, true));
145
+ window.history.pushState({}, "", response.url);
146
+ window.dispatchEvent(new CustomEvent("flow:enhanced", { detail: { navigated: true } }));
147
+ return;
148
+ }
149
+
150
+ const replacement = matchIn(parsed, document, form, target);
151
+ if (!replacement) {
152
+ // The response has no counterpart for what we were going to replace — the page
153
+ // changed shape. Guessing would put arbitrary markup somewhere arbitrary; a
154
+ // full navigation is the honest answer.
155
+ window.location.href = response.url || url;
156
+ return;
157
+ }
158
+
159
+ const adopted = document.importNode(replacement, true);
160
+ target.replaceWith(adopted);
161
+ adopted.removeAttribute(BUSY_ATTR);
162
+ restoreFocus(adopted, focusedName, caret);
163
+ window.dispatchEvent(
164
+ new CustomEvent("flow:enhanced", { detail: { navigated: false, status: response.status } }),
165
+ );
166
+ }
167
+
168
+ /**
169
+ * One delegated listener on the document, for every enhanced form that exists now
170
+ * or is added later.
171
+ *
172
+ * Delegation rather than per-form binding because the swap above replaces forms
173
+ * with fresh nodes: anything bound directly would be discarded by the first
174
+ * successful submission, and the second one would post natively for no visible
175
+ * reason.
176
+ */
177
+ function install(): void {
178
+ document.addEventListener(
179
+ "submit",
180
+ (event) => {
181
+ const form = (event.target as HTMLElement | null)?.closest?.(`form[${ATTR}]`) as
182
+ HTMLFormElement | null | undefined;
183
+ if (!form) return;
184
+ // Let a default-prevented submit stay prevented; another handler owns it.
185
+ if (event.defaultPrevented) return;
186
+ // An explicit opt-out on a form that carries the attribute for styling.
187
+ if (form.getAttribute(ATTR) === "false") return;
188
+ // A submission already in flight: drop the second rather than racing two
189
+ // responses into the same target.
190
+ if (form.hasAttribute(BUSY_ATTR)) {
191
+ event.preventDefault();
192
+ return;
193
+ }
194
+
195
+ event.preventDefault();
196
+ void submitEnhanced(form, (event as SubmitEvent).submitter);
197
+ },
198
+ // Bubble phase: a component's own handler gets to preventDefault first.
199
+ false,
200
+ );
201
+ }
202
+
203
+ if (typeof document !== "undefined") {
204
+ install();
205
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The parts of the plain-form enhancement that are decisions rather than effects.
3
+ *
4
+ * Split out of `enhance.ts` for a reason worth recording. That file is served as a
5
+ * **classic script** — `<script src="/__flow/enhance.js" defer>` on pages that have
6
+ * no module loader and no framework on them. A bundle ending in an `export`
7
+ * statement is a `SyntaxError` in that position, and the browser discards the whole
8
+ * file. It does so *silently*: the forms fall back to native posts and the page
9
+ * still works, so the enhancement is simply absent on exactly the pages it was
10
+ * added for, with nothing in the console to say why.
11
+ *
12
+ * So the entry exports nothing, and everything worth asserting on lives here, where
13
+ * a test can import it. Bun inlines this back into the bundle at build time, so the
14
+ * split costs the browser nothing.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ /** Marks a form as enhanced. */
20
+ export const ATTR = "data-enhance";
21
+
22
+ /** Overrides what gets replaced: any CSS selector. */
23
+ export const TARGET_ATTR = "data-enhance-target";
24
+
25
+ /** Set on a form while its submission is in flight, for styling and re-entry guards. */
26
+ export const BUSY_ATTR = "data-enhance-busy";
27
+
28
+ /**
29
+ * Whether two URLs name the same place, resolved against the document.
30
+ *
31
+ * Both sides are resolved before comparing, because one of them is whatever the
32
+ * author wrote in `action` — usually relative — and the other is `response.url`,
33
+ * which `fetch` always reports absolute. Comparing them raw would call every
34
+ * ordinary submission a redirect.
35
+ */
36
+ export function sameUrl(a: string, b: string, baseHref: string): boolean {
37
+ if (!a || !b) return false;
38
+ try {
39
+ return new URL(a, baseHref).href === new URL(b, baseHref).href;
40
+ } catch {
41
+ return a === b;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Where a form's response should be written.
47
+ *
48
+ * The form itself by default. That is the useful default because it is what makes
49
+ * validation errors land where the person is looking without the page author
50
+ * naming anything — and because the form is the one element guaranteed to exist in
51
+ * the response, given the server just re-rendered it.
52
+ *
53
+ * @param onMissing - Called when `data-enhance-target` matches nothing.
54
+ */
55
+ export function targetFor(
56
+ form: HTMLFormElement,
57
+ root: ParentNode,
58
+ onMissing: (selector: string) => void,
59
+ ): Element {
60
+ const selector = form.getAttribute(TARGET_ATTR);
61
+ if (!selector) return form;
62
+ const found = root.querySelector(selector);
63
+ if (found) return found;
64
+ // A selector matching nothing is an authoring mistake. Swapping the form instead
65
+ // is both harmless and visible: the page still works and the console says why it
66
+ // did not do what was asked.
67
+ onMissing(selector);
68
+ return form;
69
+ }
70
+
71
+ /**
72
+ * Find, in a freshly parsed response document, the element corresponding to
73
+ * `target` in the live one.
74
+ *
75
+ * Identity is by `id` when there is one, then by the selector the author gave, then
76
+ * — for a bare `<form data-enhance>` — by position among the enhanced forms on the
77
+ * page. The positional fallback is what lets the simplest possible usage work with
78
+ * no attributes at all, and it is why `id` is tried first: two forms that change
79
+ * order between renders would otherwise swap content.
80
+ */
81
+ export function matchIn(
82
+ doc: Document,
83
+ live: ParentNode,
84
+ form: HTMLFormElement,
85
+ target: Element,
86
+ ): Element | null {
87
+ if (target.id) {
88
+ const byId = doc.getElementById(target.id);
89
+ if (byId) return byId;
90
+ }
91
+
92
+ const selector = form.getAttribute(TARGET_ATTR);
93
+ if (selector) return doc.querySelector(selector);
94
+
95
+ const forms = Array.from(live.querySelectorAll(`form[${ATTR}]`));
96
+ const index = forms.indexOf(form);
97
+ const candidates = doc.querySelectorAll(`form[${ATTR}]`);
98
+ return index >= 0 ? (candidates[index] ?? null) : (candidates[0] ?? null);
99
+ }
package/src/client/url.ts CHANGED
@@ -11,9 +11,9 @@ export interface CurrentUrlOptions {
11
11
  * `null`, `undefined`, or `""` removes the param; anything else is stringified
12
12
  * and set. Params not listed here are preserved.
13
13
  */
14
- query?: Record<string, unknown>;
14
+ query?: Record<string, unknown> | undefined;
15
15
  /** Replace the URL hash. `""` clears it; omit to leave the hash untouched. */
16
- hash?: string;
16
+ hash?: string | undefined;
17
17
  }
18
18
 
19
19
  /**
@@ -13,13 +13,21 @@
13
13
  * @packageDocumentation
14
14
  */
15
15
 
16
- /** Escape a value for safe injection as HTML text content. */
16
+ /**
17
+ * Escape a value for safe injection as HTML text content.
18
+ *
19
+ * @internal
20
+ */
17
21
  export function __esc(v: unknown): string {
18
22
  if (v == null || v === false || v === true) return "";
19
23
  return String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
20
24
  }
21
25
 
22
- /** Escape a value for safe injection inside an HTML attribute (double-quoted). */
26
+ /**
27
+ * Escape a value for safe injection inside an HTML attribute (double-quoted).
28
+ *
29
+ * @internal
30
+ */
23
31
  export function __escAttr(v: unknown): string {
24
32
  if (v == null) return "";
25
33
  return String(v).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
package/src/config.ts CHANGED
@@ -23,7 +23,11 @@ export interface FlowConfigShape {
23
23
  persistentMiddleware: (string | MiddlewareClass)[];
24
24
  }
25
25
 
26
- /** Global middleware re-run on every WebSocket action unless overridden. */
26
+ /**
27
+ * Global middleware re-run on every WebSocket action unless overridden.
28
+ *
29
+ * @internal
30
+ */
27
31
  export const DEFAULT_PERSISTENT_MIDDLEWARE: (string | MiddlewareClass)[] = [
28
32
  "SessionMiddleware",
29
33
  "PersistUserMiddleware",
package/src/decorators.ts CHANGED
@@ -732,6 +732,8 @@ export function resolvePresenceChannel(channel: PresenceChannel, instance: objec
732
732
  * component instance to compute a per-instance name (for dynamic rooms).
733
733
  *
734
734
  * @category Realtime
735
+ *
736
+ * @internal
735
737
  */
736
738
  export type SharedChannel = string | ((self: Record<string, any>) => string);
737
739
 
@@ -1066,9 +1068,9 @@ export function resolveListeners(
1066
1068
  */
1067
1069
  export interface UrlOptions {
1068
1070
  /** Override the URL query parameter name. Defaults to the property name. */
1069
- as?: string;
1071
+ as?: string | undefined;
1070
1072
  /** Push a new history entry or replace the current one. Defaults to `'replace'`. */
1071
- history?: "push" | "replace";
1073
+ history?: "push" | "replace" | undefined;
1072
1074
  }
1073
1075
 
1074
1076
  type UrlDecorator = (value: unknown, context: ClassFieldDecoratorContext) => void;
@@ -1194,13 +1196,13 @@ function _paramDecorator(source: ParamSource): ParamDecorator {
1194
1196
  /** Options for {@link session}. */
1195
1197
  export interface SessionOptions {
1196
1198
  /** Session key to read and write. Defaults to the property's own name. */
1197
- key?: string;
1199
+ key?: string | undefined;
1198
1200
  /**
1199
1201
  * Namespace the key to this component (`flow:<Component>:<key>`), so the value belongs to
1200
1202
  * this page alone and cannot collide with another component's field of the same name.
1201
1203
  * Default: `false` — the key is the session's own.
1202
1204
  */
1203
- scoped?: boolean;
1205
+ scoped?: boolean | undefined;
1204
1206
  }
1205
1207
 
1206
1208
  type SessionDecorator = (value: unknown, context: ClassFieldDecoratorContext) => void;
package/src/durable.ts CHANGED
@@ -66,6 +66,8 @@ interface DurableConfig {
66
66
  * `@zerotal/cache`/Redis) via {@link setDurableStore} to also survive redeploys.
67
67
  * Methods may be sync or async; a swapped store is responsible for its own TTL
68
68
  * eviction (the `ttlMs` hint is passed to {@link DurableStore.set | set}).
69
+ *
70
+ * @internal
69
71
  */
70
72
  export interface DurableStore {
71
73
  /** Fetch a stored snapshot by key, or `undefined` if absent/expired. */
@@ -130,6 +132,8 @@ export function setDurableStore(store: DurableStore): void {
130
132
  * inspects persisted snapshots directly.
131
133
  *
132
134
  * @returns The current {@link DurableStore}.
135
+ *
136
+ * @internal
133
137
  */
134
138
  export function getDurableStore(): DurableStore {
135
139
  return _store;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The one line an app's layout needs for `<form data-enhance>` to work.
3
+ *
4
+ * A helper rather than a documented string, for the reason a sibling feature
5
+ * already learned the hard way: a resource nothing links is a resource nobody
6
+ * discovers is missing. A page whose layout forgot the tag does not fail — the
7
+ * forms simply post the way they always did, and the enhancement is silently
8
+ * absent on exactly the pages it was added for.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ /** The path `FlowProvider` serves the enhancement bundle at. */
14
+ export const FLOW_ENHANCE_PATH = "/__flow/enhance.js";
15
+
16
+ /**
17
+ * A `<script>` tag loading the plain-form enhancement.
18
+ *
19
+ * Put it in the layout that renders your non-Flow pages. Flow pages already get
20
+ * `/__flow/runtime.js`, which does not include this — the two are separate because
21
+ * the enhancement exists for pages that have no Flow component to run a runtime.
22
+ *
23
+ * `defer` so it never blocks the parse, and the listener is delegated on
24
+ * `document`, so a form that appears later is picked up without re-running
25
+ * anything.
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <head>
30
+ * {flowEnhanceTag()}
31
+ * </head>
32
+ * ```
33
+ */
34
+ export function flowEnhanceTag(): string {
35
+ return `<script src="${FLOW_ENHANCE_PATH}" defer></script>`;
36
+ }
package/src/index.ts CHANGED
@@ -74,6 +74,7 @@ export type {
74
74
  // `class PostsPage extends Component.using(Pagination, …)`.
75
75
  export type { Constructor, Mixin, Compose } from "./mixins.ts";
76
76
  export { Layout } from "./Layout.ts";
77
+ export { flowEnhanceTag, FLOW_ENHANCE_PATH } from "./enhanceTag.ts";
77
78
  export { Form, registerForm } from "./Form.ts";
78
79
  // Global client store — app-wide, client-only reactive UI state, read in JSX as
79
80
  // `$flow.$store.*`. `defineStore` declares its initial shape at app start; augment
@@ -99,6 +99,17 @@ export function _devErrorInfo(error: unknown, action: string): DevErrorInfo {
99
99
 
100
100
  let _runtimeBundle: string | null = null;
101
101
 
102
+ /**
103
+ * The `/__flow/enhance.js` bundle — the standalone plain-form enhancement.
104
+ *
105
+ * Separate from the runtime bundle on purpose. `/__flow/runtime.js` is injected
106
+ * only by `Router.flow` routes, and the whole point of `<form data-enhance>` is a
107
+ * page with no Flow component on it, which is therefore never served that script.
108
+ * It is also tiny and dependency-free, so an app that wants a form not to flash
109
+ * does not pay for Alpine and a WebSocket bridge to get it.
110
+ */
111
+ let _enhanceBundle: string | null = null;
112
+
102
113
  /**
103
114
  * CSP-safe mode flag (no 'unsafe-eval'). Set `flow.cspSafe` in `config/flow.ts`,
104
115
  * or the ZT_FLOW_CSP_SAFE / APP_CSP_SAFE env flag when there is no config file.
@@ -113,6 +124,51 @@ export function _runtimeJs(): string | null {
113
124
  return _runtimeBundle;
114
125
  }
115
126
 
127
+ /** @internal — the built plain-form enhancement bundle, or null when it failed to build. */
128
+ export function _enhanceJs(): string | null {
129
+ return _enhanceBundle;
130
+ }
131
+
132
+ /** In-flight build, so concurrent first requests share one rather than racing. */
133
+ let _enhanceBuild: Promise<string | null> | null = null;
134
+
135
+ /**
136
+ * Build the enhancement bundle once, on first request.
137
+ *
138
+ * No CSP variant: it has no expression evaluator to swap, because it evaluates
139
+ * nothing — it reads attributes and calls `fetch`.
140
+ *
141
+ * @internal
142
+ */
143
+ async function _buildEnhanceBundle(): Promise<string | null> {
144
+ if (_enhanceBundle) return _enhanceBundle;
145
+ // Cache the promise, not the result — two requests arriving together would
146
+ // otherwise both bundle.
147
+ _enhanceBuild ??= (async () => {
148
+ try {
149
+ const entry = fileURLToPath(new URL("../client/enhance.ts", import.meta.url));
150
+ const result = await Bun.build({
151
+ entrypoints: [entry],
152
+ target: "browser",
153
+ minify: isProdLike(deployEnv()),
154
+ format: "esm",
155
+ });
156
+ if (result.success && result.outputs.length > 0) {
157
+ _enhanceBundle = await result.outputs[0]!.text();
158
+ return _enhanceBundle;
159
+ }
160
+ console.warn("[Flow] Enhancement bundle build failed:", result.logs);
161
+ } catch (e) {
162
+ console.warn("[Flow] Could not build enhancement bundle:", e);
163
+ }
164
+ // A failed build is not cached: the next request tries again, which is right
165
+ // for a dev-time transient and harmless for a real one.
166
+ _enhanceBuild = null;
167
+ return null;
168
+ })();
169
+ return _enhanceBuild;
170
+ }
171
+
116
172
  // ── Rate limiter for HMAC failures ───────────────────────────────────────────
117
173
 
118
174
  const _checksumFailures = new Map<string, { count: number; since: number }>();
@@ -1029,6 +1085,38 @@ export class FlowProvider extends ServiceProvider {
1029
1085
  console.warn("[Flow] Could not build client bundle:", e);
1030
1086
  }
1031
1087
 
1088
+ // Serve the plain-form enhancement bundle. Deliberately cacheable, unlike the
1089
+ // runtime: it carries no per-app route table, so there is nothing in it that can
1090
+ // go stale between deploys of the same framework version.
1091
+ //
1092
+ // Built on first request rather than at boot. Most apps never serve a page that
1093
+ // uses `<form data-enhance>` — it is for the pages Flow does not otherwise touch
1094
+ // — so building it at boot spends time on an artefact nobody asks for, in every
1095
+ // process, including every test that boots a provider.
1096
+ Router.get(
1097
+ "/__flow/enhance.js",
1098
+ class FlowEnhanceHandler {
1099
+ async handle(http: HttpContext): Promise<void> {
1100
+ const bundle = await _buildEnhanceBundle();
1101
+ if (!bundle) {
1102
+ http.response = new Response("// Flow enhancement unavailable", {
1103
+ status: 503,
1104
+ headers: { "Content-Type": "text/javascript; charset=utf-8" },
1105
+ });
1106
+ return;
1107
+ }
1108
+ http.response = new Response(bundle, {
1109
+ status: 200,
1110
+ headers: {
1111
+ "Content-Type": "text/javascript; charset=utf-8",
1112
+ "Cache-Control": "public, max-age=3600",
1113
+ },
1114
+ });
1115
+ }
1116
+ },
1117
+ "handle",
1118
+ );
1119
+
1032
1120
  // Serve the client-side runtime bundle
1033
1121
  Router.get(
1034
1122
  "/__flow/runtime.js",
@@ -1371,6 +1459,8 @@ let _activeConnections = 0;
1371
1459
  * Number of currently-open Flow (reactive SSR) WebSocket connections.
1372
1460
  * @returns The live count of open `/__flow/ws` connections.
1373
1461
  * @category Connections
1462
+ *
1463
+ * @internal
1374
1464
  */
1375
1465
  export function flowActiveConnections(): number {
1376
1466
  return _activeConnections;
@@ -1379,6 +1469,8 @@ export function flowActiveConnections(): number {
1379
1469
  /**
1380
1470
  * One currently-open Flow WebSocket client, enriched as actions identify the user.
1381
1471
  * @category Connections
1472
+ *
1473
+ * @internal
1382
1474
  */
1383
1475
  export interface FlowConnection {
1384
1476
  id: string;
@@ -1397,6 +1489,8 @@ const _connections = new Map<string, FlowConnection>();
1397
1489
  * Snapshot of currently-connected Flow clients (most-recently-active first).
1398
1490
  * @returns An array of {@link FlowConnection}, sorted by `lastActivityAt` descending.
1399
1491
  * @category Connections
1492
+ *
1493
+ * @internal
1400
1494
  */
1401
1495
  export function flowConnections(): FlowConnection[] {
1402
1496
  return [..._connections.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
package/src/registry.ts CHANGED
@@ -56,6 +56,8 @@ export function _getPageEntries(): Map<string, PageEntry> {
56
56
  * // In a ServiceProvider's boot method:
57
57
  * registerComponent(CounterWidget);
58
58
  * ```
59
+ *
60
+ * @internal
59
61
  */
60
62
  export function registerComponent(PageClass: typeof Component, path = ""): void {
61
63
  if (!_pages.has(PageClass.name)) registerPage(path, PageClass);
@@ -103,6 +105,8 @@ export { getRenderlessMethods };
103
105
  * A Component constructor plus the optional static metadata a page class may declare
104
106
  * (`title`, `head`, `models`, `layout`). Used when resolving file-route page exports and
105
107
  * when auto-registering associated model classes.
108
+ *
109
+ * @internal
106
110
  */
107
111
  export interface PageClassWithMeta {
108
112
  new (): Component;
package/src/shared.ts CHANGED
@@ -35,6 +35,8 @@ export const SHARED_EVENT = "flow:shared";
35
35
  * The default is an in-process {@link MemorySharedStore}; swap a distributed
36
36
  * implementation (e.g. Redis-backed) via {@link setSharedStore} to converge
37
37
  * across processes. Implement all three methods synchronously.
38
+ *
39
+ * @internal
38
40
  */
39
41
  export interface SharedStore {
40
42
  /** Read the current value for a `channel::prop` key, or `undefined` if unset. */
@@ -93,6 +95,8 @@ export function setSharedStore(store: SharedStore): void {
93
95
  * @expose increment() { this.count++; } // written back + broadcast on commit
94
96
  * }
95
97
  * ```
98
+ *
99
+ * @internal
96
100
  */
97
101
  export function getSharedStore(): SharedStore {
98
102
  return _store;