@zerotal/flow 1.7.0 → 1.7.3
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 +176 -0
- package/README.md +1 -1
- package/api-surface.md +10 -6
- package/package.json +6 -5
- package/src/Component.ts +128 -50
- package/src/browser/FlowBrowser.ts +7 -2
- package/src/client/bridge.ts +353 -83
- package/src/client/framePanes.ts +229 -0
- package/src/client/headless.ts +7 -1
- package/src/client/timeline.ts +12 -0
- package/src/components.ts +13 -2
- package/src/decorators.ts +104 -14
- package/src/dehydrate.ts +51 -2
- package/src/durable.ts +5 -2
- package/src/events.ts +3 -3
- package/src/index.ts +1 -0
- package/src/jsx-runtime.ts +13 -0
- package/src/observability.ts +28 -1
- package/src/registry.ts +6 -2
- package/src/router.ts +7 -1
- package/src/synths/CollectionSynth.ts +17 -15
- package/src/synths/ModelSynth.ts +335 -35
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a time-travel frame opens into, as a small strip of its own tabs.
|
|
3
|
+
*
|
|
4
|
+
* The same shape a request uses in the devtools list, and for the same reason:
|
|
5
|
+
* the views are alternatives, not a sequence. You are reading the arguments the
|
|
6
|
+
* click sent, *or* the state it moved, *or* the SQL it ran on the server — and
|
|
7
|
+
* stacking them makes you scroll past two to reach the third.
|
|
8
|
+
*
|
|
9
|
+
* Only the views a frame actually has appear, so the strip doubles as the
|
|
10
|
+
* summary: a frame showing `Sent · State 2 · Server · Queries 3` has told you
|
|
11
|
+
* what the action did before you click anything.
|
|
12
|
+
*
|
|
13
|
+
* Pure string-building, with no `window` or `document` at module scope, so the
|
|
14
|
+
* decisions here are testable without a browser — which is the point, since the
|
|
15
|
+
* interesting cases (an action that queried, an action that only logged) depend
|
|
16
|
+
* on a signed-in session that is awkward to drive in a headless one.
|
|
17
|
+
*/
|
|
18
|
+
import type { TimelineFrame } from "./timeline.ts";
|
|
19
|
+
|
|
20
|
+
/** What the server did while handling one action, read off its devtools trace. */
|
|
21
|
+
export interface FrameServerCost {
|
|
22
|
+
durationMs: number;
|
|
23
|
+
ip: string | null;
|
|
24
|
+
statusCode: number | null;
|
|
25
|
+
queries: Array<{ sql: string; durationMs: number | null; rowCount: number | null }>;
|
|
26
|
+
logs: Array<{ level: string; text: string }>;
|
|
27
|
+
error: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One view of an action: a tab in the frame's own strip. */
|
|
31
|
+
export interface FramePane {
|
|
32
|
+
id: string;
|
|
33
|
+
label: string;
|
|
34
|
+
/** Shown beside the label when the view counts something. */
|
|
35
|
+
count?: number;
|
|
36
|
+
html: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function esc(s: string): string {
|
|
40
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A value as the panes show it — compact JSON, truncated rather than wrapped. */
|
|
44
|
+
export function brief(value: unknown, max = 160): string {
|
|
45
|
+
let text: string;
|
|
46
|
+
try {
|
|
47
|
+
text = typeof value === "string" ? value : JSON.stringify(value);
|
|
48
|
+
} catch {
|
|
49
|
+
text = String(value);
|
|
50
|
+
}
|
|
51
|
+
if (text === undefined || text === null) text = String(value);
|
|
52
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
56
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The value out of a snapshot entry, which is stored as `[value, meta]`.
|
|
61
|
+
*
|
|
62
|
+
* A developer reading a state diff wants `1 → 2`, not `[1,{}] → [2,{}]`: the
|
|
63
|
+
* second slot is the framework's own bookkeeping and only earns space when it
|
|
64
|
+
* holds something.
|
|
65
|
+
*/
|
|
66
|
+
export function snapshotValue(entry: unknown): unknown {
|
|
67
|
+
if (!Array.isArray(entry) || entry.length === 0) return entry;
|
|
68
|
+
const [value, meta] = entry as [unknown, unknown];
|
|
69
|
+
const bare =
|
|
70
|
+
meta === undefined || meta === null || (isPlainObject(meta) && !Object.keys(meta).length);
|
|
71
|
+
return bare ? value : { value, meta };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One `key before → after` row per field the action changed. */
|
|
75
|
+
function stateHtml(frame: TimelineFrame, frames: readonly TimelineFrame[]): string {
|
|
76
|
+
if (!frame.changed.length) return "";
|
|
77
|
+
const prior = frames.filter((f) => f.compId === frame.compId && f.seq < frame.seq).pop();
|
|
78
|
+
const before = prior?.snapshot?.data as Record<string, unknown> | undefined;
|
|
79
|
+
const after = frame.snapshot?.data as Record<string, unknown> | undefined;
|
|
80
|
+
|
|
81
|
+
return frame.changed
|
|
82
|
+
.map((key) => {
|
|
83
|
+
const from = before && key in before ? brief(snapshotValue(before[key]), 60) : "—";
|
|
84
|
+
const to = after && key in after ? brief(snapshotValue(after[key]), 60) : "—";
|
|
85
|
+
return (
|
|
86
|
+
`<div style="display:flex;gap:8px;align-items:baseline;padding:1px 0">` +
|
|
87
|
+
`<span style="min-width:120px;color:var(--cyan)">${esc(key)}</span>` +
|
|
88
|
+
`<span class="dim">${esc(from)}</span>` +
|
|
89
|
+
`<span class="dim">→</span>` +
|
|
90
|
+
`<span style="color:var(--green)">${esc(to)}</span>` +
|
|
91
|
+
`</div>`
|
|
92
|
+
);
|
|
93
|
+
})
|
|
94
|
+
.join("");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The call the browser made: its arguments, and any client writes flushed with it. */
|
|
98
|
+
function sentHtml(frame: TimelineFrame): string {
|
|
99
|
+
if (!frame.sent) return "";
|
|
100
|
+
const args = frame.sent.args.length
|
|
101
|
+
? frame.sent.args.map((a) => brief(a, 80)).join(", ")
|
|
102
|
+
: "no arguments";
|
|
103
|
+
const updates = frame.sent.updates ? Object.entries(frame.sent.updates) : [];
|
|
104
|
+
const updateRows = updates
|
|
105
|
+
.map(
|
|
106
|
+
([k, v]) =>
|
|
107
|
+
`<div style="display:flex;gap:8px;padding:1px 0">` +
|
|
108
|
+
`<span style="min-width:120px;color:var(--cyan)">${esc(k)}</span>` +
|
|
109
|
+
`<span class="dim">${esc(brief(v, 60))}</span></div>`,
|
|
110
|
+
)
|
|
111
|
+
.join("");
|
|
112
|
+
return (
|
|
113
|
+
`<div><span style="color:var(--purple)">${esc(frame.action)}</span>` +
|
|
114
|
+
`<span class="dim">(${esc(args)})</span></div>` +
|
|
115
|
+
(updateRows
|
|
116
|
+
? `<div class="dim" style="font-size:10px;margin:4px 0 1px">flushed with the call</div>${updateRows}`
|
|
117
|
+
: "")
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function serverHtml(s: FrameServerCost): string {
|
|
122
|
+
return (
|
|
123
|
+
`<div style="display:flex;gap:10px;flex-wrap:wrap">` +
|
|
124
|
+
`<span class="dim">${s.durationMs}ms</span>` +
|
|
125
|
+
(s.statusCode ? `<span class="dim">status ${s.statusCode}</span>` : "") +
|
|
126
|
+
(s.ip ? `<span class="dim">from ${esc(s.ip)}</span>` : "") +
|
|
127
|
+
`</div>` +
|
|
128
|
+
(s.error ? `<div class="red" style="margin-top:2px">${esc(s.error)}</div>` : "")
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function queriesHtml(s: FrameServerCost): string {
|
|
133
|
+
return s.queries
|
|
134
|
+
.map(
|
|
135
|
+
(q) =>
|
|
136
|
+
`<div style="display:flex;gap:8px;align-items:baseline;padding:1px 0">` +
|
|
137
|
+
`<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(brief(q.sql, 140))}</span>` +
|
|
138
|
+
`<span class="dim" style="font-size:10px">${q.durationMs ?? "—"}ms` +
|
|
139
|
+
`${q.rowCount === null ? "" : ` · ${q.rowCount} row${q.rowCount === 1 ? "" : "s"}`}</span>` +
|
|
140
|
+
`</div>`,
|
|
141
|
+
)
|
|
142
|
+
.join("");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function logsHtml(s: FrameServerCost): string {
|
|
146
|
+
return s.logs
|
|
147
|
+
.map(
|
|
148
|
+
(l) =>
|
|
149
|
+
`<div style="padding:1px 0">` +
|
|
150
|
+
`<span class="dim" style="min-width:44px;display:inline-block">${esc(l.level)}</span>` +
|
|
151
|
+
`${esc(brief(l.text, 140))}</div>`,
|
|
152
|
+
)
|
|
153
|
+
.join("");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The views a frame has to offer, in reading order.
|
|
158
|
+
*
|
|
159
|
+
* `cost` is null for a frame that never left the browser — a client expression —
|
|
160
|
+
* and that frame says so rather than showing an empty Server pane.
|
|
161
|
+
*/
|
|
162
|
+
export function framePanes(
|
|
163
|
+
frame: TimelineFrame,
|
|
164
|
+
frames: readonly TimelineFrame[],
|
|
165
|
+
cost: FrameServerCost | null,
|
|
166
|
+
): FramePane[] {
|
|
167
|
+
const panes: FramePane[] = [];
|
|
168
|
+
|
|
169
|
+
const sent = sentHtml(frame);
|
|
170
|
+
if (sent) panes.push({ id: "sent", label: "Sent", html: sent });
|
|
171
|
+
|
|
172
|
+
const state = stateHtml(frame, frames);
|
|
173
|
+
if (state) panes.push({ id: "state", label: "State", count: frame.changed.length, html: state });
|
|
174
|
+
|
|
175
|
+
if (!cost) {
|
|
176
|
+
panes.push({
|
|
177
|
+
id: "server",
|
|
178
|
+
label: "Server",
|
|
179
|
+
html: `<div class="dim" style="font-size:11px">Ran in the browser — nothing was sent to the server.</div>`,
|
|
180
|
+
});
|
|
181
|
+
return panes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
panes.push({ id: "server", label: "Server", html: serverHtml(cost) });
|
|
185
|
+
if (cost.queries.length) {
|
|
186
|
+
panes.push({
|
|
187
|
+
id: "queries",
|
|
188
|
+
label: "Queries",
|
|
189
|
+
count: cost.queries.length,
|
|
190
|
+
html: queriesHtml(cost),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (cost.logs.length) {
|
|
194
|
+
panes.push({ id: "logs", label: "Logs", count: cost.logs.length, html: logsHtml(cost) });
|
|
195
|
+
}
|
|
196
|
+
return panes;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Which pane is showing, given what the reader last picked.
|
|
201
|
+
*
|
|
202
|
+
* One preference across every frame rather than one each: stepping down a list of
|
|
203
|
+
* actions comparing the SQL of one against the next should keep showing SQL, not
|
|
204
|
+
* return you to the arguments at each step. Falls back to the first pane the
|
|
205
|
+
* frame in hand actually has.
|
|
206
|
+
*/
|
|
207
|
+
export function activePane(preferred: string, panes: FramePane[]): string {
|
|
208
|
+
return panes.some((p) => p.id === preferred) ? preferred : (panes[0]?.id ?? "");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A frame's panes, as the strip plus the one body that is showing. */
|
|
212
|
+
export function renderFramePanes(panes: FramePane[], preferred: string): string {
|
|
213
|
+
if (!panes.length) return "";
|
|
214
|
+
const active = activePane(preferred, panes);
|
|
215
|
+
const strip = panes
|
|
216
|
+
.map(
|
|
217
|
+
(p) =>
|
|
218
|
+
`<button class="dsect${p.id === active ? " on" : ""}" data-fsec="${p.id}">` +
|
|
219
|
+
`${esc(p.label)}` +
|
|
220
|
+
(p.count === undefined ? "" : `<span class="dsec-n">${p.count}</span>`) +
|
|
221
|
+
`</button>`,
|
|
222
|
+
)
|
|
223
|
+
.join("");
|
|
224
|
+
const body = panes.find((p) => p.id === active)?.html ?? "";
|
|
225
|
+
return (
|
|
226
|
+
`<div class="dsecs">${strip}</div>` +
|
|
227
|
+
`<div class="dsec-body" style="padding:6px 12px">${body}</div>`
|
|
228
|
+
);
|
|
229
|
+
}
|
package/src/client/headless.ts
CHANGED
|
@@ -523,7 +523,13 @@ export function flowFileUpload(config: { name: string }) {
|
|
|
523
523
|
this.error = e.detail.error ?? "Upload failed";
|
|
524
524
|
}) as EventListener);
|
|
525
525
|
|
|
526
|
-
|
|
526
|
+
// `-finish`, not `-done`. The bridge dispatches `flow:upload-finish`
|
|
527
|
+
// (`_uploadFiles`) and the guide documents that name; this listener was the
|
|
528
|
+
// only place `flow:upload-done` appeared, and nothing has ever dispatched
|
|
529
|
+
// it. So the success path never ran: `uploading` stayed true after a
|
|
530
|
+
// perfectly good upload and the dropzone read "Uploading… 100%" forever,
|
|
531
|
+
// while the file itself had already been stored and bound.
|
|
532
|
+
on("flow:upload-finish", ((e: CustomEvent) => {
|
|
527
533
|
if (e.detail.key !== this.name) return;
|
|
528
534
|
this.uploading = false;
|
|
529
535
|
this.progress = 100;
|
package/src/client/timeline.ts
CHANGED
|
@@ -27,6 +27,16 @@ export interface TimelineFrame {
|
|
|
27
27
|
/** The component's outer HTML at this frame, for DOM restore on a jump. */
|
|
28
28
|
html: string | null;
|
|
29
29
|
ts: number;
|
|
30
|
+
/**
|
|
31
|
+
* What the browser sent to produce this frame — absent for a mount, and for a
|
|
32
|
+
* frame produced by anything other than a dispatched action.
|
|
33
|
+
*
|
|
34
|
+
* Kept so the panel can answer "what did this click actually send" without the
|
|
35
|
+
* developer reading the socket. `updates` is the batch of client-side writes
|
|
36
|
+
* flushed alongside the call, which is why a field can differ between frames
|
|
37
|
+
* without appearing in `args`.
|
|
38
|
+
*/
|
|
39
|
+
sent?: { args: unknown[]; updates?: Record<string, unknown> };
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
const MAX_FRAMES = 250;
|
|
@@ -87,6 +97,7 @@ export function recordFrame(input: {
|
|
|
87
97
|
action: string;
|
|
88
98
|
snapshot: Snapshot;
|
|
89
99
|
html: string | null;
|
|
100
|
+
sent?: { args: unknown[]; updates?: Record<string, unknown> };
|
|
90
101
|
}): TimelineFrame | null {
|
|
91
102
|
if (!_enabled) return null;
|
|
92
103
|
const changed = computeChanged(_lastDataByComp.get(input.compId), input.snapshot.data);
|
|
@@ -100,6 +111,7 @@ export function recordFrame(input: {
|
|
|
100
111
|
changed,
|
|
101
112
|
html: input.html,
|
|
102
113
|
ts: Date.now(),
|
|
114
|
+
...(input.sent ? { sent: input.sent } : {}),
|
|
103
115
|
};
|
|
104
116
|
_frames.push(frame);
|
|
105
117
|
if (_frames.length > MAX_FRAMES) _frames.shift();
|
package/src/components.ts
CHANGED
|
@@ -28,6 +28,15 @@ import { jsLiteral } from "./utils.ts";
|
|
|
28
28
|
export interface LinkProps {
|
|
29
29
|
href: string;
|
|
30
30
|
hover?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Prefetch on pointer-down instead of on hover.
|
|
33
|
+
*
|
|
34
|
+
* The right choice for a row in a long list: hover prefetch fires for every
|
|
35
|
+
* link the pointer crosses on its way somewhere else, which on a dense table
|
|
36
|
+
* is a fetch per row nobody chose. Pointer-down fires once, on the one link
|
|
37
|
+
* the reader has committed to, and still lands ahead of the click.
|
|
38
|
+
*/
|
|
39
|
+
down?: boolean;
|
|
31
40
|
current?: boolean;
|
|
32
41
|
exact?: boolean;
|
|
33
42
|
preserveScroll?: boolean;
|
|
@@ -39,7 +48,8 @@ export interface LinkProps {
|
|
|
39
48
|
* that you can override or refine per link.
|
|
40
49
|
*
|
|
41
50
|
* @remarks
|
|
42
|
-
* `hover` prefetches the target on hover
|
|
51
|
+
* `hover` prefetches the target on hover and `down` on pointer-down (the one to reach
|
|
52
|
+
* for inside a long list); `current` forces (`true`) or suppresses
|
|
43
53
|
* (`false`) the active state instead of deriving it from the URL; `exact` restricts
|
|
44
54
|
* the automatic active state to an exact path match. Any other props pass through to
|
|
45
55
|
* the `<a>`.
|
|
@@ -60,9 +70,10 @@ export interface LinkProps {
|
|
|
60
70
|
* @category Navigation & data
|
|
61
71
|
*/
|
|
62
72
|
export function Link(props: LinkProps & { children?: unknown }): HtmlNode {
|
|
63
|
-
const { href, hover, current, exact, preserveScroll, children, ...rest } = props;
|
|
73
|
+
const { href, hover, down, current, exact, preserveScroll, children, ...rest } = props;
|
|
64
74
|
const out: Record<string, unknown> = { ...rest, href, navigate: true, children };
|
|
65
75
|
if (hover) out["navigateHover"] = true;
|
|
76
|
+
if (down) out["navigateDown"] = true;
|
|
66
77
|
if (preserveScroll) out["navigatePreserveScroll"] = true;
|
|
67
78
|
// `current` overrides the automatic URL-based active state:
|
|
68
79
|
// false → never active (flow:current.ignore)
|
package/src/decorators.ts
CHANGED
|
@@ -52,7 +52,7 @@ const _exposedMethods = new WeakMap<object, Set<string>>();
|
|
|
52
52
|
const _lockedProps = new WeakMap<object, Set<string>>();
|
|
53
53
|
const _renderlessMethods = new WeakMap<object, Set<string>>();
|
|
54
54
|
const _validateRules = new WeakMap<object, Map<string, ValidateBuilder>>();
|
|
55
|
-
const _onListeners = new WeakMap<object, Map<
|
|
55
|
+
const _onListeners = new WeakMap<object, Map<ListenerName, string>>();
|
|
56
56
|
const _urlProps = new WeakMap<object, Map<string, UrlOptions>>();
|
|
57
57
|
/**
|
|
58
58
|
* prop -> where its value comes from:
|
|
@@ -77,10 +77,10 @@ function _register(map: WeakMap<object, Set<string>>, proto: object, key: string
|
|
|
77
77
|
s.add(key);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function _registerMap<V>(
|
|
81
|
-
map: WeakMap<object, Map<
|
|
80
|
+
function _registerMap<V, K = string>(
|
|
81
|
+
map: WeakMap<object, Map<K, V>>,
|
|
82
82
|
proto: object,
|
|
83
|
-
key:
|
|
83
|
+
key: K,
|
|
84
84
|
value: V,
|
|
85
85
|
): void {
|
|
86
86
|
let m = map.get(proto);
|
|
@@ -102,8 +102,11 @@ function _collect(map: WeakMap<object, Set<string>>, startProto: object): Set<st
|
|
|
102
102
|
return result;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function _collectMap<V
|
|
106
|
-
|
|
105
|
+
function _collectMap<V, K = string>(
|
|
106
|
+
map: WeakMap<object, Map<K, V>>,
|
|
107
|
+
startProto: object,
|
|
108
|
+
): Map<K, V> {
|
|
109
|
+
const result = new Map<K, V>();
|
|
107
110
|
let proto: object | null = startProto;
|
|
108
111
|
while (proto && proto !== Object.prototype) {
|
|
109
112
|
const m = map.get(proto);
|
|
@@ -131,7 +134,8 @@ interface MemberTag {
|
|
|
131
134
|
renderless?: boolean;
|
|
132
135
|
task?: boolean;
|
|
133
136
|
computed?: boolean;
|
|
134
|
-
|
|
137
|
+
/** The event name, or a resolver over the instance — see {@link ListenerName}. */
|
|
138
|
+
on?: ListenerName;
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
// `unique symbol` (not a plain `symbol`) so the computed-key types below resolve.
|
|
@@ -302,11 +306,34 @@ function _drainFields(ctor: unknown): void {
|
|
|
302
306
|
// recently enqueued group: a class is drained after its own definition, so among
|
|
303
307
|
// equal candidates the newest is the one that just defined it, and older equals are
|
|
304
308
|
// leftovers by construction.
|
|
309
|
+
//
|
|
310
|
+
// Restricted first to groups this class could plausibly own: every name in the
|
|
311
|
+
// group is a field this class DECLARES. A sibling's group is rejected by the
|
|
312
|
+
// names it has that this class does not.
|
|
313
|
+
//
|
|
314
|
+
// Length alone was not enough to separate siblings of one decorated base. Given
|
|
315
|
+
// `NewPage { project }` and `EditPage { project, issue }` over a shared base, a
|
|
316
|
+
// drain of NewPage scanned every group, found `project` at the head of EditPage's
|
|
317
|
+
// group, tied on length with its own single entry, and — ties going to the newest
|
|
318
|
+
// group — claimed EditPage's `project` and spliced it away. EditPage then drained
|
|
319
|
+
// to `issue` alone, its `project` never registered, and route-param seeding skipped
|
|
320
|
+
// the field: a 500 on `this.project.slug` that appeared only when the sibling
|
|
321
|
+
// rendered first. Containment separates them, because `issue` is not a field
|
|
322
|
+
// NewPage declares.
|
|
323
|
+
//
|
|
324
|
+
// Falls back to every group when nothing is fully contained, so partial matches —
|
|
325
|
+
// including entries buffered without metadata, which share one bucket — behave as
|
|
326
|
+
// they did.
|
|
327
|
+
const contained = groups.filter((key) =>
|
|
328
|
+
byGroup.get(key)!.every((i) => ownIndex.has(_pendingFields[i]!.name)),
|
|
329
|
+
);
|
|
330
|
+
const candidates = contained.length > 0 ? contained : groups;
|
|
331
|
+
|
|
305
332
|
let bestIdxs: number[] | null = null;
|
|
306
333
|
let bestLen = 0;
|
|
307
334
|
let bestGroup = -1;
|
|
308
|
-
for (let g = 0; g <
|
|
309
|
-
const idxs = byGroup.get(
|
|
335
|
+
for (let g = 0; g < candidates.length; g++) {
|
|
336
|
+
const idxs = byGroup.get(candidates[g]!)!;
|
|
310
337
|
|
|
311
338
|
// Longest contiguous run whose names map to strictly-increasing declaration-order
|
|
312
339
|
// indices (equal allowed only for an identical repeated name — multiple decorators
|
|
@@ -433,9 +460,9 @@ export function getValidateRules(instance: object): Map<string, ValidateBuilder>
|
|
|
433
460
|
_ensureDrained(instance);
|
|
434
461
|
return _collectMap(_validateRules, Object.getPrototypeOf(instance) as object);
|
|
435
462
|
}
|
|
436
|
-
export function getListeners(PageClass: { prototype: object }): Map<
|
|
463
|
+
export function getListeners(PageClass: { prototype: object }): Map<ListenerName, string> {
|
|
437
464
|
_scanChain(PageClass.prototype as object);
|
|
438
|
-
return _collectMap(_onListeners, PageClass.prototype as object);
|
|
465
|
+
return _collectMap<string, ListenerName>(_onListeners, PageClass.prototype as object);
|
|
439
466
|
}
|
|
440
467
|
export function getUrlProps(instance: object): Map<string, UrlOptions> {
|
|
441
468
|
_ensureDrained(instance);
|
|
@@ -923,14 +950,36 @@ export function validate(
|
|
|
923
950
|
|
|
924
951
|
// ── @on ───────────────────────────────────────────────────────────────────────
|
|
925
952
|
|
|
953
|
+
/**
|
|
954
|
+
* What a `@on` listener listens to: a static event name, or a resolver called with the component
|
|
955
|
+
* instance to compute one — the same shape as {@link PresenceChannel} and {@link SharedChannel}.
|
|
956
|
+
*
|
|
957
|
+
* Use the resolver form for a `socket:` channel whose name contains a value only the instance
|
|
958
|
+
* knows. A template literal in a plain string cannot work: the decorator's argument is read off
|
|
959
|
+
* the class, long before any instance exists, so `"socket:issues.${this.id},E"` subscribes to a
|
|
960
|
+
* channel whose name contains those eleven characters.
|
|
961
|
+
*
|
|
962
|
+
* @example
|
|
963
|
+
* ```ts
|
|
964
|
+
* // Static — the channel is the same for every instance.
|
|
965
|
+
* @on("socket:orders,OrderPlaced")
|
|
966
|
+
*
|
|
967
|
+
* // Per-instance — one channel per issue.
|
|
968
|
+
* @on((self) => `socket-private:issues.${self.issue.id},CommentPosted`)
|
|
969
|
+
* ```
|
|
970
|
+
*
|
|
971
|
+
* @category Realtime
|
|
972
|
+
*/
|
|
973
|
+
export type ListenerName = LooseEventName | ((self: Record<string, any>) => string); // eslint-disable-line @typescript-eslint/no-explicit-any -- `unknown` would break `self.issue.id`
|
|
974
|
+
|
|
926
975
|
/**
|
|
927
976
|
* Binds a method as a cross-component (realtime) event listener. Auto-exposes the method.
|
|
928
977
|
*
|
|
929
978
|
* @remarks
|
|
930
979
|
* When the named event is dispatched (via `dispatch` / `dispatchTo` / `dispatchSelf`, or an
|
|
931
|
-
* `
|
|
980
|
+
* `socket:…` broadcast), the decorated method is invoked with the event payload. The event name
|
|
932
981
|
* autocompletes to the known {@link FlowEvents} contract while still accepting any string (for
|
|
933
|
-
* `
|
|
982
|
+
* `socket:…` broadcasts and gradually-typed events). Annotate the handler's parameter with
|
|
934
983
|
* `EventPayload<"name">` to type the payload against the same contract. Because it auto-exposes,
|
|
935
984
|
* no separate `@expose` is needed. Applies to a method.
|
|
936
985
|
*
|
|
@@ -952,7 +1001,7 @@ export function validate(
|
|
|
952
1001
|
* @category Realtime
|
|
953
1002
|
*/
|
|
954
1003
|
export function on(
|
|
955
|
-
eventName:
|
|
1004
|
+
eventName: ListenerName,
|
|
956
1005
|
): (fn: unknown, context: ClassMethodDecoratorContext) => void {
|
|
957
1006
|
return (_fn: unknown, context: ClassMethodDecoratorContext): void => {
|
|
958
1007
|
// `@on` implies `@expose`; the tag carries the event name so the scan can rebuild the map.
|
|
@@ -966,6 +1015,47 @@ export function on(
|
|
|
966
1015
|
};
|
|
967
1016
|
}
|
|
968
1017
|
|
|
1018
|
+
/**
|
|
1019
|
+
* The listener names a component actually subscribes to, resolved against `instance`.
|
|
1020
|
+
*
|
|
1021
|
+
* A static name passes through; a resolver is called with the component, so an
|
|
1022
|
+
* `socket:` channel can carry a value only the instance knows — `issues.417` rather
|
|
1023
|
+
* than the class-level `issues.:id`.
|
|
1024
|
+
*
|
|
1025
|
+
* This exists because the channel name reaches the browser through the snapshot,
|
|
1026
|
+
* and the snapshot is built per instance. Registering `(self) => …` under the
|
|
1027
|
+
* *class* and resolving here is the same split {@link presence} and {@link shared}
|
|
1028
|
+
* already use; before it, a `@on("socket-private:issues.${this.issueId},…")`
|
|
1029
|
+
* written the way the guide showed subscribed to a channel literally containing
|
|
1030
|
+
* `${this.issueId}` — no error, no warning, and no events, because template
|
|
1031
|
+
* syntax inside a plain string is just text.
|
|
1032
|
+
*
|
|
1033
|
+
* A resolver that throws is dropped rather than allowed to fail the render: a
|
|
1034
|
+
* page whose live updates do not arrive is a degraded page, while a page that
|
|
1035
|
+
* 500s because one channel could not be named is no page at all.
|
|
1036
|
+
*/
|
|
1037
|
+
export function resolveListeners(
|
|
1038
|
+
listeners: Map<ListenerName, string>,
|
|
1039
|
+
instance: object,
|
|
1040
|
+
): Map<string, string> {
|
|
1041
|
+
const resolved = new Map<string, string>();
|
|
1042
|
+
|
|
1043
|
+
for (const [name, method] of listeners) {
|
|
1044
|
+
if (typeof name !== "function") {
|
|
1045
|
+
resolved.set(name, method);
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
try {
|
|
1049
|
+
const channel = name(instance as Record<string, unknown>);
|
|
1050
|
+
if (typeof channel === "string" && channel !== "") resolved.set(channel, method);
|
|
1051
|
+
} catch {
|
|
1052
|
+
/* an unresolvable channel is not subscribed to */
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return resolved;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
969
1059
|
// ── @url ──────────────────────────────────────────────────────────────────────
|
|
970
1060
|
|
|
971
1061
|
/**
|
package/src/dehydrate.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getLockedProps,
|
|
9
9
|
getUrlProps,
|
|
10
10
|
getListeners,
|
|
11
|
+
resolveListeners,
|
|
11
12
|
getPresenceProps,
|
|
12
13
|
resolvePresenceChannel,
|
|
13
14
|
getSharedProps,
|
|
@@ -124,9 +125,14 @@ export function dehydrate(
|
|
|
124
125
|
data[key] = [serialized, meta];
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
// Collect @on listeners for the client event router
|
|
128
|
+
// Collect @on listeners for the client event router, resolving any registered as
|
|
129
|
+
// `(self) => name` against this instance — that is what lets a `socket:` channel carry
|
|
130
|
+
// a per-record id, since the decorator's argument is read off the class.
|
|
128
131
|
// Excluded from HMAC signing (server-derived, can't usefully be tampered with).
|
|
129
|
-
const listenersMap =
|
|
132
|
+
const listenersMap = resolveListeners(
|
|
133
|
+
getListeners(page.constructor as { prototype: object }),
|
|
134
|
+
page,
|
|
135
|
+
);
|
|
130
136
|
const listeners = listenersMap.size > 0 ? Object.fromEntries(listenersMap) : undefined;
|
|
131
137
|
|
|
132
138
|
// Named slot HTML passed down by the parent. Signed (server-generated), and carried
|
|
@@ -216,6 +222,49 @@ export async function hydrate<T extends Component>(
|
|
|
216
222
|
return page;
|
|
217
223
|
}
|
|
218
224
|
|
|
225
|
+
/**
|
|
226
|
+
* A copy of `snapshot` with client-supplied hidden values removed, re-signed.
|
|
227
|
+
*
|
|
228
|
+
* A hidden field the user typed — a new password — rides in the snapshot so it survives until
|
|
229
|
+
* they save. That is fine on the wire, where it goes back to the browser that produced it,
|
|
230
|
+
* and wrong in the durable store, which writes the whole snapshot server-side after every
|
|
231
|
+
* request and may be backed by Redis. A half-typed password should not be waiting there for
|
|
232
|
+
* anyone, and resuming into one is not what durable is for.
|
|
233
|
+
*
|
|
234
|
+
* Re-signed rather than edited in place: the checksum covers `data`, so a strip without a
|
|
235
|
+
* fresh signature would produce a snapshot that fails its own verification on restore.
|
|
236
|
+
*
|
|
237
|
+
* @internal
|
|
238
|
+
*/
|
|
239
|
+
export function stripPendingSecrets(snapshot: Snapshot): Snapshot {
|
|
240
|
+
let touched = false;
|
|
241
|
+
const data: SnapshotData = {};
|
|
242
|
+
|
|
243
|
+
for (const [prop, [value, meta]] of Object.entries(snapshot.data)) {
|
|
244
|
+
const pending = meta["p"];
|
|
245
|
+
if (
|
|
246
|
+
!Array.isArray(pending) ||
|
|
247
|
+
pending.length === 0 ||
|
|
248
|
+
value === null ||
|
|
249
|
+
typeof value !== "object"
|
|
250
|
+
) {
|
|
251
|
+
data[prop] = [value, meta];
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
touched = true;
|
|
255
|
+
const cleanValue: Record<string, unknown> = { ...(value as Record<string, unknown>) };
|
|
256
|
+
for (const key of pending as string[]) delete cleanValue[key];
|
|
257
|
+
const cleanMeta = { ...meta };
|
|
258
|
+
delete cleanMeta["p"];
|
|
259
|
+
data[prop] = [cleanValue, cleanMeta];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!touched) return snapshot;
|
|
263
|
+
|
|
264
|
+
const stripped = { ...snapshot, data };
|
|
265
|
+
return { ...stripped, checksum: _sign(_signingPayload(stripped)) };
|
|
266
|
+
}
|
|
267
|
+
|
|
219
268
|
/** Verify a snapshot's HMAC against the current APP_KEY. Public so durable-snapshot
|
|
220
269
|
* restore can validate a stored snapshot before trusting it (a rotated key → false). */
|
|
221
270
|
export function verifySnapshot(snapshot: Snapshot): boolean {
|
package/src/durable.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import type { Component } from "./Component.ts";
|
|
14
14
|
import type { Snapshot } from "./types.ts";
|
|
15
15
|
import type { HttpContext } from "@zerotal/core";
|
|
16
|
-
import { verifySnapshot, applySnapshotData } from "./dehydrate.ts";
|
|
16
|
+
import { verifySnapshot, applySnapshotData, stripPendingSecrets } from "./dehydrate.ts";
|
|
17
17
|
|
|
18
18
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
19
19
|
|
|
@@ -221,7 +221,10 @@ export async function persistDurable(
|
|
|
221
221
|
await _store.delete(key);
|
|
222
222
|
return;
|
|
223
223
|
}
|
|
224
|
-
|
|
224
|
+
// Never persist a hidden field the client is part-way through typing — see
|
|
225
|
+
// `stripPendingSecrets`. The wire may carry it back to the browser that produced it; a
|
|
226
|
+
// server-side store, possibly Redis, may not hold it.
|
|
227
|
+
await _store.set(key, stripPendingSecrets(snapshot), cfg.ttlMs);
|
|
225
228
|
}
|
|
226
229
|
|
|
227
230
|
/**
|
package/src/events.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// }
|
|
14
14
|
// }
|
|
15
15
|
//
|
|
16
|
-
// Unknown event names still work (a `string` fallback overload keeps `
|
|
16
|
+
// Unknown event names still work (a `string` fallback overload keeps `socket:…` broadcasts and
|
|
17
17
|
// gradual adoption compiling), they're just untyped. Payloads are object shapes or `void`.
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*
|
|
25
25
|
* @remarks
|
|
26
26
|
* Payloads are object shapes or `void` (for events with no payload). Unknown event names still
|
|
27
|
-
* compile (a `string` fallback keeps `
|
|
27
|
+
* compile (a `string` fallback keeps `socket:…` broadcasts and gradual adoption working); they are
|
|
28
28
|
* simply untyped.
|
|
29
29
|
*
|
|
30
30
|
* @example
|
|
@@ -64,7 +64,7 @@ export type EventPayload<K extends EventName> = FlowEvents[K];
|
|
|
64
64
|
*/
|
|
65
65
|
export type EventArgs<P> = [P] extends [void] ? [] : [payload: P];
|
|
66
66
|
|
|
67
|
-
/** An event name loosened to accept any string (autocomplete for known names, `
|
|
67
|
+
/** An event name loosened to accept any string (autocomplete for known names, `socket:…` too). */
|
|
68
68
|
export type LooseEventName = EventName | (string & {});
|
|
69
69
|
|
|
70
70
|
// ── Optional runtime shape guards ──────────────────────────────────────────────
|
package/src/index.ts
CHANGED
|
@@ -189,6 +189,7 @@ export { renderless } from "./decorators.ts";
|
|
|
189
189
|
export { task } from "./decorators.ts";
|
|
190
190
|
export { validate } from "./decorators.ts";
|
|
191
191
|
export { on } from "./decorators.ts";
|
|
192
|
+
export type { ListenerName } from "./decorators.ts";
|
|
192
193
|
export { url } from "./decorators.ts";
|
|
193
194
|
export { param } from "./decorators.ts";
|
|
194
195
|
export { session } from "./decorators.ts";
|
package/src/jsx-runtime.ts
CHANGED
|
@@ -59,6 +59,7 @@ interface FlowDirectives {
|
|
|
59
59
|
|
|
60
60
|
navigate?: boolean;
|
|
61
61
|
navigateHover?: boolean;
|
|
62
|
+
navigateDown?: boolean;
|
|
62
63
|
navigatePreserveScroll?: boolean;
|
|
63
64
|
current?: string;
|
|
64
65
|
currentExact?: boolean;
|
|
@@ -147,9 +148,20 @@ interface FlowDirectives {
|
|
|
147
148
|
show?: any;
|
|
148
149
|
showImportant?: boolean;
|
|
149
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The reorder action, as a method reference or its name.
|
|
153
|
+
*
|
|
154
|
+
* Read off the container a child was **dropped into** and called `(key, index)`,
|
|
155
|
+
* so the destination is encoded in which method runs and nowhere in the payload.
|
|
156
|
+
* Dragging between containers therefore needs one action per container.
|
|
157
|
+
*/
|
|
150
158
|
onSort?: any;
|
|
151
159
|
sortItem?: any;
|
|
152
160
|
sortGroup?: string;
|
|
161
|
+
/**
|
|
162
|
+
* Emitted as `flow:sort:group-id`, and **currently read by nothing** — neither the
|
|
163
|
+
* client runtime nor the compiler looks for it. Setting it has no effect today.
|
|
164
|
+
*/
|
|
153
165
|
sortGroupId?: any;
|
|
154
166
|
sortHandle?: boolean;
|
|
155
167
|
sortIgnore?: boolean;
|
|
@@ -738,6 +750,7 @@ const PULSE_PROP_MAP: Record<string, string> = {
|
|
|
738
750
|
dirtyTarget: "flow:target",
|
|
739
751
|
navigate: "flow:navigate",
|
|
740
752
|
navigateHover: "flow:navigate.hover",
|
|
753
|
+
navigateDown: "flow:navigate.down",
|
|
741
754
|
// One word on purpose: toFlowHtmlAttr() rewrites every `-` in a flow: attribute
|
|
742
755
|
// to a `.`, so a hyphenated modifier would reach the DOM under a different name
|
|
743
756
|
// here than the compiler emits.
|