@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.
- package/CHANGELOG.md +57 -0
- package/api-surface.md +17 -195
- package/package.json +6 -6
- package/src/Component.ts +13 -10
- package/src/Form.ts +5 -1
- package/src/browser/FlowBrowser.ts +3 -2
- package/src/browser/cdp.ts +2 -0
- package/src/client/confirm.ts +6 -6
- package/src/client/enhance.ts +205 -0
- package/src/client/enhanceSupport.ts +99 -0
- package/src/client/url.ts +2 -2
- package/src/compiler/helpers.ts +10 -2
- package/src/config.ts +5 -1
- package/src/decorators.ts +6 -4
- package/src/durable.ts +4 -0
- package/src/enhanceTag.ts +36 -0
- package/src/index.ts +1 -0
- package/src/provider/FlowProvider.ts +94 -0
- package/src/registry.ts +4 -0
- package/src/shared.ts +4 -0
- package/src/synths/ModelSynth.ts +77 -1
- package/src/synths/index.ts +4 -0
- package/src/types.ts +20 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +2 -0
|
@@ -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
|
/**
|
package/src/compiler/helpers.ts
CHANGED
|
@@ -13,13 +13,21 @@
|
|
|
13
13
|
* @packageDocumentation
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
/**
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
/**
|
|
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, "&").replace(/"/g, """);
|
package/src/config.ts
CHANGED
|
@@ -23,7 +23,11 @@ export interface FlowConfigShape {
|
|
|
23
23
|
persistentMiddleware: (string | MiddlewareClass)[];
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
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;
|