@zerotal/flow 1.6.3 → 1.7.2
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 +181 -0
- package/api-surface.md +1125 -0
- package/package.json +7 -5
- package/src/Component.ts +13 -2
- package/src/browser/FlowBrowser.ts +7 -2
- package/src/client/bridge.ts +344 -80
- 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 +10 -4
- package/src/durable.ts +2 -1
- 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/client/bridge.ts
CHANGED
|
@@ -34,6 +34,11 @@ import { confirmDialog, type ConfirmDialogOptions } from "./confirm.ts";
|
|
|
34
34
|
import { showErrorOverlay } from "./errorOverlay.ts";
|
|
35
35
|
import { applyAppend, applyRemove, type OptOp } from "./optimistic.ts";
|
|
36
36
|
import { clientStore } from "../store.ts";
|
|
37
|
+
// The `/Socket` subpath, not the package root: the root also exports
|
|
38
|
+
// `ClientProvider`, which reaches @zerotal/core's CLI modules — one of which
|
|
39
|
+
// does `await import("bun")`, a hard error in a browser bundle before
|
|
40
|
+
// tree-shaking can drop it.
|
|
41
|
+
import { Socket as BundledSocket } from "@zerotal/client/Socket";
|
|
37
42
|
import { route } from "@zerotal/core/routes";
|
|
38
43
|
import { URL_ATTRIBUTES, sanitizeUrl } from "../urlSafety.ts";
|
|
39
44
|
import {
|
|
@@ -43,6 +48,11 @@ import {
|
|
|
43
48
|
setTimelineApplier,
|
|
44
49
|
} from "./timeline.ts";
|
|
45
50
|
import * as _timeline from "./timeline.ts";
|
|
51
|
+
import {
|
|
52
|
+
framePanes as _framePanes,
|
|
53
|
+
renderFramePanes as _renderFramePanes,
|
|
54
|
+
type FrameServerCost as _ServerCost,
|
|
55
|
+
} from "./framePanes.ts";
|
|
46
56
|
|
|
47
57
|
// CSP-safe mode: set by the CSP client entry. When on, bridge-managed bindings
|
|
48
58
|
// use the eval-free evaluateCsp() instead of `new Function`.
|
|
@@ -712,14 +722,19 @@ function _sendCall(
|
|
|
712
722
|
.then(
|
|
713
723
|
() =>
|
|
714
724
|
new Promise<void>((resolve) => {
|
|
725
|
+
const updates = fixedUpdates ?? _pendingUpdates(comp);
|
|
715
726
|
const frame = {
|
|
716
727
|
type: "call",
|
|
717
728
|
component: compId,
|
|
718
729
|
method,
|
|
719
730
|
args,
|
|
720
|
-
updates
|
|
731
|
+
updates,
|
|
721
732
|
snapshot: comp.snapshot,
|
|
722
733
|
};
|
|
734
|
+
// Here is where the client writes riding along with this call are
|
|
735
|
+
// finally resolved, so it is the only place the panel can learn them.
|
|
736
|
+
const pending = _pendingActionByComp.get(compId);
|
|
737
|
+
if (pending && pending.method === method) pending.updates = updates;
|
|
723
738
|
// Safety net: never wedge the queue if a patch is dropped (server crash / HTTP error).
|
|
724
739
|
const armSafetyNet = (): void => {
|
|
725
740
|
setTimeout(() => {
|
|
@@ -1049,6 +1064,32 @@ function _dispatchFlash(detail: Record<string, unknown>): void {
|
|
|
1049
1064
|
// episode triggers at most one reload, while a later, genuine restart can recover again.
|
|
1050
1065
|
const _EXPIRY_RELOAD_KEY = "flow:reloaded-for-expired-component";
|
|
1051
1066
|
|
|
1067
|
+
/**
|
|
1068
|
+
* Why an error frame should be recovered by a reload, if it should.
|
|
1069
|
+
*
|
|
1070
|
+
* Pure and exported so the policy is testable without a DOM — the handler that
|
|
1071
|
+
* calls it needs `window`, `sessionStorage` and a live component map.
|
|
1072
|
+
*
|
|
1073
|
+
* `reload` is set by the server when the snapshot no longer belongs to the
|
|
1074
|
+
* session that is asking, which is what signing out in another tab looks like
|
|
1075
|
+
* from here. The page's state belongs to who you *were*, so no patch can
|
|
1076
|
+
* succeed and reloading is the only honest recovery — the fresh request meets
|
|
1077
|
+
* auth middleware and lands wherever a signed-out visitor belongs.
|
|
1078
|
+
*
|
|
1079
|
+
* Both cases share the one-shot guard: a reload that does not fix the problem
|
|
1080
|
+
* must not reload again.
|
|
1081
|
+
*/
|
|
1082
|
+
export function _reloadReasonFor(
|
|
1083
|
+
frame: { message?: string; reload?: boolean },
|
|
1084
|
+
alreadyReloaded: boolean,
|
|
1085
|
+
): "session-changed" | "unknown-component" | null {
|
|
1086
|
+
if (alreadyReloaded) return null;
|
|
1087
|
+
// The server's explicit instruction outranks any inference from the message.
|
|
1088
|
+
if (frame.reload) return "session-changed";
|
|
1089
|
+
if (frame.message === "Unknown component") return "unknown-component";
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1052
1093
|
function _reloadedForExpiry(): boolean {
|
|
1053
1094
|
try {
|
|
1054
1095
|
return sessionStorage.getItem(_EXPIRY_RELOAD_KEY) === "1";
|
|
@@ -1186,9 +1227,10 @@ function _enableTimeline(): void {
|
|
|
1186
1227
|
if (dt && typeof dt.register === "function") {
|
|
1187
1228
|
dt.register({
|
|
1188
1229
|
id: "flow-timeline",
|
|
1189
|
-
title: "
|
|
1230
|
+
title: "Flow",
|
|
1190
1231
|
badge: () => _timeline.getFrames().length || undefined,
|
|
1191
|
-
render: (el: HTMLElement) =>
|
|
1232
|
+
render: (el: HTMLElement, context?: { trace?: _TraceLike | null }) =>
|
|
1233
|
+
_renderTimelineInto(el, context?.trace ?? null),
|
|
1192
1234
|
});
|
|
1193
1235
|
_timeline.onTimelineChange(() => dt.refresh("flow-timeline"));
|
|
1194
1236
|
} else {
|
|
@@ -1207,9 +1249,90 @@ interface _DevtoolsRegistryLike {
|
|
|
1207
1249
|
refresh(id?: string): void;
|
|
1208
1250
|
}
|
|
1209
1251
|
|
|
1252
|
+
/**
|
|
1253
|
+
* The half of a devtools trace this panel reads.
|
|
1254
|
+
*
|
|
1255
|
+
* Structural rather than imported: Flow depends on no observer package, and an
|
|
1256
|
+
* app without devtools installed simply never passes one. Every field is optional
|
|
1257
|
+
* for the same reason — this is a shape read from another package's data.
|
|
1258
|
+
*/
|
|
1259
|
+
interface _TraceLike {
|
|
1260
|
+
statusCode?: number;
|
|
1261
|
+
durationMs?: number;
|
|
1262
|
+
channels?: Record<string, Array<Record<string, unknown>>>;
|
|
1263
|
+
queries?: Array<{ sql?: unknown; durationMs?: unknown; rowCount?: unknown }>;
|
|
1264
|
+
logs?: Array<{ level?: unknown; args?: unknown }>;
|
|
1265
|
+
exception?: { message?: unknown } | null;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Server cost per frame, accumulated as traces arrive.
|
|
1270
|
+
*
|
|
1271
|
+
* A trace carries one action, and the panel follows the newest — so each render
|
|
1272
|
+
* can bind at most the frame that just happened. Keeping what earlier renders
|
|
1273
|
+
* bound is what lets a frame from four clicks ago still show what it cost, rather
|
|
1274
|
+
* than only ever the selected one being annotated.
|
|
1275
|
+
*/
|
|
1276
|
+
const _frameCost = new Map<number, _ServerCost>();
|
|
1277
|
+
|
|
1278
|
+
/** Attach the selected trace's action to the frame it produced, once. */
|
|
1279
|
+
function _bindServerCost(
|
|
1280
|
+
trace: _TraceLike | null,
|
|
1281
|
+
frames: readonly _timeline.TimelineFrame[],
|
|
1282
|
+
): void {
|
|
1283
|
+
for (const entry of trace?.channels?.["flow"] ?? []) {
|
|
1284
|
+
const component = entry["component"];
|
|
1285
|
+
const action = entry["action"];
|
|
1286
|
+
const durationMs = entry["durationMs"];
|
|
1287
|
+
if (typeof component !== "string" || typeof action !== "string") continue;
|
|
1288
|
+
if (typeof durationMs !== "number") continue;
|
|
1289
|
+
|
|
1290
|
+
// The newest matching frame that has no cost yet: an action produces its
|
|
1291
|
+
// frame before the trace reaches the panel, and re-rendering the same trace
|
|
1292
|
+
// must not walk the binding back through older frames of the same name.
|
|
1293
|
+
for (let i = frames.length - 1; i >= 0; i--) {
|
|
1294
|
+
const frame = frames[i]!;
|
|
1295
|
+
if (frame.compId !== component || frame.action !== action) continue;
|
|
1296
|
+
if (_frameCost.has(frame.seq)) break;
|
|
1297
|
+
const ip = entry["ip"];
|
|
1298
|
+
_frameCost.set(frame.seq, {
|
|
1299
|
+
durationMs,
|
|
1300
|
+
ip: typeof ip === "string" ? ip : null,
|
|
1301
|
+
statusCode: typeof trace?.statusCode === "number" ? trace.statusCode : null,
|
|
1302
|
+
// One action is one trace, so the whole trace is this action's server half.
|
|
1303
|
+
queries: (trace?.queries ?? []).map((q) => ({
|
|
1304
|
+
sql: String(q.sql ?? ""),
|
|
1305
|
+
durationMs: typeof q.durationMs === "number" ? q.durationMs : null,
|
|
1306
|
+
rowCount: typeof q.rowCount === "number" ? q.rowCount : null,
|
|
1307
|
+
})),
|
|
1308
|
+
logs: (trace?.logs ?? []).map((l) => ({
|
|
1309
|
+
level: String(l.level ?? "log"),
|
|
1310
|
+
text: Array.isArray(l.args) ? l.args.map((a) => String(a)).join(" ") : "",
|
|
1311
|
+
})),
|
|
1312
|
+
error:
|
|
1313
|
+
trace?.exception && typeof trace.exception.message === "string"
|
|
1314
|
+
? trace.exception.message
|
|
1315
|
+
: null,
|
|
1316
|
+
});
|
|
1317
|
+
break;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** Which frames are expanded. Module state so a redraw does not collapse them. */
|
|
1323
|
+
const _expanded = new Set<number>();
|
|
1324
|
+
|
|
1325
|
+
/** The trace of the last draw, so toggling a row can redraw without waiting for one. */
|
|
1326
|
+
let _lastTrace: _TraceLike | null = null;
|
|
1327
|
+
|
|
1328
|
+
/** Which pane of a frame the reader last picked. See `activePane`. */
|
|
1329
|
+
let _framePref = "";
|
|
1330
|
+
|
|
1210
1331
|
/** Render the timeline into the Zerotal devtools content area (themed with its CSS vars/classes). */
|
|
1211
|
-
function _renderTimelineInto(el: HTMLElement): void {
|
|
1332
|
+
function _renderTimelineInto(el: HTMLElement, trace: _TraceLike | null = null): void {
|
|
1212
1333
|
const frames = _timeline.getFrames();
|
|
1334
|
+
_lastTrace = trace;
|
|
1335
|
+
_bindServerCost(trace, frames);
|
|
1213
1336
|
const compIds = [...new Set(frames.map((f) => f.compId))];
|
|
1214
1337
|
const curSeqs = new Set(compIds.map((id) => _timeline.currentSeq(id)));
|
|
1215
1338
|
const rewound = compIds.some((id) => !_timeline.isLive(id));
|
|
@@ -1228,13 +1351,30 @@ function _renderTimelineInto(el: HTMLElement): void {
|
|
|
1228
1351
|
const cur = curSeqs.has(f.seq);
|
|
1229
1352
|
const changed = f.changed.length ? f.changed.join(", ") : "—";
|
|
1230
1353
|
const time = new Date(f.ts).toLocaleTimeString();
|
|
1354
|
+
// A frame with no cost ran entirely in the browser — a client expression
|
|
1355
|
+
// never reached the server, and saying nothing is the honest rendering.
|
|
1356
|
+
const cost = _frameCost.get(f.seq);
|
|
1357
|
+
const server = cost
|
|
1358
|
+
? `<span class="dim" style="font-size:10px">server ${cost.durationMs}ms${cost.queries.length ? ` · ${cost.queries.length}q` : ""}</span>`
|
|
1359
|
+
: "";
|
|
1360
|
+
const open = _expanded.has(f.seq);
|
|
1361
|
+
const detail = open
|
|
1362
|
+
? `<div class="hdetail" style="margin-left:26px">` +
|
|
1363
|
+
_renderFramePanes(_framePanes(f, frames, _frameCost.get(f.seq) ?? null), _framePref) +
|
|
1364
|
+
`</div>`
|
|
1365
|
+
: "";
|
|
1231
1366
|
return (
|
|
1232
1367
|
`<div class="qrow" data-tl-seq="${f.seq}" style="cursor:pointer;display:flex;gap:8px;align-items:baseline${cur ? ";background:var(--card)" : ""}">` +
|
|
1368
|
+
// Its own hit area: the row time-travels, and expanding to read what
|
|
1369
|
+
// happened must not also rewind the page you are reading it from.
|
|
1370
|
+
`<span data-tl-exp="${f.seq}" title="${open ? "Collapse" : "What this action did"}" style="min-width:12px;cursor:pointer;user-select:none">${open ? "▾" : "▸"}</span>` +
|
|
1233
1371
|
`<span class="dim" style="min-width:30px">#${f.seq}</span>` +
|
|
1234
1372
|
`<span style="color:var(--purple);font-weight:700">${_escapeHtml(f.action)}</span>` +
|
|
1235
1373
|
`<span class="dim" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_escapeHtml(f.compName)} · ${_escapeHtml(changed)}</span>` +
|
|
1374
|
+
server +
|
|
1236
1375
|
`<span class="dim" style="font-size:10px">${time}</span>` +
|
|
1237
|
-
`</div>`
|
|
1376
|
+
`</div>` +
|
|
1377
|
+
detail
|
|
1238
1378
|
);
|
|
1239
1379
|
})
|
|
1240
1380
|
.join("")
|
|
@@ -1249,6 +1389,24 @@ function _renderTimelineInto(el: HTMLElement): void {
|
|
|
1249
1389
|
_timeline.resumeLive(id);
|
|
1250
1390
|
return;
|
|
1251
1391
|
}
|
|
1392
|
+
// A pane of the frame being read. First, since the strip sits inside the
|
|
1393
|
+
// detail the row opened and a click there must not also rewind the page.
|
|
1394
|
+
const pane = target.closest<HTMLElement>("[data-fsec]");
|
|
1395
|
+
if (pane?.dataset["fsec"]) {
|
|
1396
|
+
_framePref = pane.dataset["fsec"];
|
|
1397
|
+
_renderTimelineInto(el, _lastTrace);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Checked before the row, since the toggle sits inside it.
|
|
1402
|
+
const toggle = target.closest<HTMLElement>("[data-tl-exp]");
|
|
1403
|
+
if (toggle?.dataset["tlExp"]) {
|
|
1404
|
+
const seq = Number(toggle.dataset["tlExp"]);
|
|
1405
|
+
if (_expanded.has(seq)) _expanded.delete(seq);
|
|
1406
|
+
else _expanded.add(seq);
|
|
1407
|
+
_renderTimelineInto(el, _lastTrace);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1252
1410
|
const row = target.closest<HTMLElement>("[data-tl-seq]");
|
|
1253
1411
|
if (row?.dataset["tlSeq"]) _timeline.jumpTo(Number(row.dataset["tlSeq"]));
|
|
1254
1412
|
};
|
|
@@ -1436,6 +1594,7 @@ async function _handleServerFrame(
|
|
|
1436
1594
|
dataRemoved?: string[];
|
|
1437
1595
|
actionError?: boolean;
|
|
1438
1596
|
dev?: boolean;
|
|
1597
|
+
reload?: boolean;
|
|
1439
1598
|
},
|
|
1440
1599
|
): Promise<void> {
|
|
1441
1600
|
if (frame.type === "ready") {
|
|
@@ -1515,12 +1674,21 @@ async function _handleServerFrame(
|
|
|
1515
1674
|
// Time-travel: record this applied patch as a timeline frame (final patches only — mid-@task
|
|
1516
1675
|
// partials would flood the timeline). No-op unless recording is enabled (dev).
|
|
1517
1676
|
if (!frame.partial && isTimelineEnabled()) {
|
|
1677
|
+
const dispatched = _takeLastDispatch(component);
|
|
1518
1678
|
_recordTimelineFrame({
|
|
1519
1679
|
compId: component,
|
|
1520
1680
|
compName: comp.name,
|
|
1521
|
-
action:
|
|
1681
|
+
action: dispatched.action,
|
|
1522
1682
|
snapshot: comp.snapshot,
|
|
1523
1683
|
html: comp.rootEl.outerHTML,
|
|
1684
|
+
...(dispatched.sent
|
|
1685
|
+
? {
|
|
1686
|
+
sent: {
|
|
1687
|
+
args: dispatched.sent.args,
|
|
1688
|
+
...(dispatched.sent.updates ? { updates: dispatched.sent.updates } : {}),
|
|
1689
|
+
},
|
|
1690
|
+
}
|
|
1691
|
+
: {}),
|
|
1524
1692
|
});
|
|
1525
1693
|
}
|
|
1526
1694
|
|
|
@@ -1594,11 +1762,14 @@ async function _handleServerFrame(
|
|
|
1594
1762
|
// fresh document re-renders and re-registers every component server-side, then
|
|
1595
1763
|
// re-hydrates the client. A one-shot guard (cleared once patches flow again, below)
|
|
1596
1764
|
// prevents a reload loop if the reload doesn't resolve it.
|
|
1597
|
-
|
|
1765
|
+
const reloadReason = _reloadReasonFor(frame, _reloadedForExpiry());
|
|
1766
|
+
if (reloadReason) {
|
|
1598
1767
|
_markReloadedForExpiry();
|
|
1599
1768
|
console.warn(
|
|
1600
|
-
|
|
1601
|
-
`
|
|
1769
|
+
reloadReason === "session-changed"
|
|
1770
|
+
? `[Flow] ${frame.message ?? "The session changed"} — reloading the page.`
|
|
1771
|
+
: `[Flow] Component "${frame.component}" is no longer registered on the server ` +
|
|
1772
|
+
`(likely a server restart) — reloading the page to recover.`,
|
|
1602
1773
|
);
|
|
1603
1774
|
window.location.reload();
|
|
1604
1775
|
return;
|
|
@@ -1687,6 +1858,16 @@ function _syncModelInputs(comp: FlowComponent): void {
|
|
|
1687
1858
|
el.checked = el.value === String(serverVal ?? "");
|
|
1688
1859
|
return;
|
|
1689
1860
|
}
|
|
1861
|
+
// A file input's value belongs to the user agent: assigning anything but "" throws
|
|
1862
|
+
// InvalidStateError, and that throw escapes the frame handler — the morph never runs
|
|
1863
|
+
// and the action's ack never resolves, wedging the component's queue for good. The
|
|
1864
|
+
// server side of the binding is an upload reference, not a filename, so there is
|
|
1865
|
+
// nothing to write back; only the server *clearing* it is worth mirroring, so the
|
|
1866
|
+
// same file can be picked again after a remove.
|
|
1867
|
+
if (el instanceof HTMLInputElement && el.type === "file") {
|
|
1868
|
+
if (serverVal === null || serverVal === undefined) el.value = "";
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1690
1871
|
const str = String(serverVal ?? "");
|
|
1691
1872
|
if (el.value !== str) el.value = str;
|
|
1692
1873
|
// A draft-backed field the server just emptied (e.g. after a successful submit) → drop the draft.
|
|
@@ -2055,20 +2236,29 @@ function _reapplyOptOps(comp: FlowComponent): void {
|
|
|
2055
2236
|
}
|
|
2056
2237
|
}
|
|
2057
2238
|
|
|
2058
|
-
// Time-travel: remember
|
|
2059
|
-
//
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2239
|
+
// Time-travel: remember what a component last dispatched, so the patch it produces is labelled
|
|
2240
|
+
// with that action name in the timeline — and so the panel can show the call that caused it.
|
|
2241
|
+
// Consumed (and cleared) when the patch lands.
|
|
2242
|
+
interface _PendingDispatch {
|
|
2243
|
+
method: string;
|
|
2244
|
+
args: unknown[];
|
|
2245
|
+
/** Filled in by `_sendCall`, which is where the batch of client writes is resolved. */
|
|
2246
|
+
updates?: Record<string, unknown>;
|
|
2247
|
+
}
|
|
2248
|
+
const _pendingActionByComp = new Map<string, _PendingDispatch>();
|
|
2249
|
+
|
|
2250
|
+
function _takeLastDispatch(compId: string): { action: string; sent?: _PendingDispatch } {
|
|
2251
|
+
const d = _pendingActionByComp.get(compId);
|
|
2063
2252
|
_pendingActionByComp.delete(compId);
|
|
2064
|
-
return
|
|
2253
|
+
if (!d) return { action: "action" };
|
|
2254
|
+
return { action: d.method, sent: d };
|
|
2065
2255
|
}
|
|
2066
2256
|
|
|
2067
2257
|
function _callAction(comp: FlowComponent, method: string, args: unknown[]): void {
|
|
2068
2258
|
_countActionDispatch(comp);
|
|
2069
2259
|
_setActionError(comp, false); // retrying clears any prior failed state
|
|
2070
2260
|
_setLoading(comp, true, method);
|
|
2071
|
-
if (isTimelineEnabled()) _pendingActionByComp.set(comp.id, method);
|
|
2261
|
+
if (isTimelineEnabled()) _pendingActionByComp.set(comp.id, { method, args });
|
|
2072
2262
|
_dispatchFrame(comp, method, args);
|
|
2073
2263
|
}
|
|
2074
2264
|
|
|
@@ -2684,7 +2874,21 @@ async function _navigateTo(href: string, options: NavigateOptions = {}): Promise
|
|
|
2684
2874
|
// browser supports it, for a smooth cross-page animation; falls back to an
|
|
2685
2875
|
// instant swap otherwise.
|
|
2686
2876
|
const _swap = (): void => {
|
|
2687
|
-
|
|
2877
|
+
// Page-level state scripts only: the server renders one as a direct child of
|
|
2878
|
+
// <body>, after the body content, while every child island's script sits
|
|
2879
|
+
// *inside* that content.
|
|
2880
|
+
//
|
|
2881
|
+
// This used to be `document.querySelector('[id^="flow-state-"]')`, which
|
|
2882
|
+
// matches in document order — so on any page with an island it removed the
|
|
2883
|
+
// first island's script and left the page's own behind. The outgoing
|
|
2884
|
+
// snapshot was orphaned, and ids are random per request, so they accumulated
|
|
2885
|
+
// one per navigation for as long as the tab stayed open.
|
|
2886
|
+
//
|
|
2887
|
+
// Removing *all* of them rather than just this page's also clears whatever a
|
|
2888
|
+
// previously-buggy session left behind.
|
|
2889
|
+
for (const stale of document.querySelectorAll('body > script[id^="flow-state-"]')) {
|
|
2890
|
+
stale.remove();
|
|
2891
|
+
}
|
|
2688
2892
|
document.body.appendChild(incomingState.cloneNode(true));
|
|
2689
2893
|
currentRoot.replaceWith(incomingClone);
|
|
2690
2894
|
_cleanupDisconnected();
|
|
@@ -2805,6 +3009,32 @@ function _setupPrefetch(): void {
|
|
|
2805
3009
|
},
|
|
2806
3010
|
{ passive: true },
|
|
2807
3011
|
);
|
|
3012
|
+
|
|
3013
|
+
// `flow:navigate.down` — prefetch on pointer-down, with no dwell.
|
|
3014
|
+
//
|
|
3015
|
+
// The middle setting between "never" and "every link the pointer crosses".
|
|
3016
|
+
// Hover prefetch is free speed on a handful of stable links and the opposite
|
|
3017
|
+
// on a dense list: moving the pointer down a table of a hundred rows asks the
|
|
3018
|
+
// server for a hundred pages, none of which anyone chose. Pointer-down fires
|
|
3019
|
+
// once, on the link the reader has committed to, and still beats the click by
|
|
3020
|
+
// however long the button stays held — around 100ms of ordinary human timing,
|
|
3021
|
+
// which is most of a fast page render.
|
|
3022
|
+
//
|
|
3023
|
+
// `pointerdown` rather than `mousedown` so a touch lands here too, and it is
|
|
3024
|
+
// passive because this never calls preventDefault — the click that follows is
|
|
3025
|
+
// handled by the navigation listener as usual, and finds the page waiting in
|
|
3026
|
+
// the cache.
|
|
3027
|
+
document.addEventListener(
|
|
3028
|
+
"pointerdown",
|
|
3029
|
+
(e) => {
|
|
3030
|
+
const a = (e.target as Element).closest?.(
|
|
3031
|
+
"a[flow\\:navigate\\.down]",
|
|
3032
|
+
) as HTMLAnchorElement | null;
|
|
3033
|
+
const href = a ? sameOrigin(a) : null;
|
|
3034
|
+
if (href) _prefetch(href);
|
|
3035
|
+
},
|
|
3036
|
+
{ passive: true },
|
|
3037
|
+
);
|
|
2808
3038
|
}
|
|
2809
3039
|
|
|
2810
3040
|
function _setupNavigationLinks(): void {
|
|
@@ -3238,7 +3468,7 @@ function _scanComponents(): void {
|
|
|
3238
3468
|
_setupPolling(comp);
|
|
3239
3469
|
_setupIntersect(comp);
|
|
3240
3470
|
_setupSort(comp);
|
|
3241
|
-
|
|
3471
|
+
_setupSocketListeners(comp);
|
|
3242
3472
|
_setupPresence(comp);
|
|
3243
3473
|
_setupShared(comp);
|
|
3244
3474
|
|
|
@@ -3304,7 +3534,7 @@ function _cleanupDisconnected(): void {
|
|
|
3304
3534
|
obs.disconnect();
|
|
3305
3535
|
_lazyObservers.delete(id);
|
|
3306
3536
|
}
|
|
3307
|
-
|
|
3537
|
+
_teardownSocketListeners(id);
|
|
3308
3538
|
_teardownPresence(id);
|
|
3309
3539
|
_teardownShared(id);
|
|
3310
3540
|
_optOps.delete(id);
|
|
@@ -3313,61 +3543,95 @@ function _cleanupDisconnected(): void {
|
|
|
3313
3543
|
}
|
|
3314
3544
|
}
|
|
3315
3545
|
|
|
3316
|
-
// ── Real-time @on('
|
|
3546
|
+
// ── Real-time @on('socket:…') listeners ───────────────────────────────────────
|
|
3317
3547
|
//
|
|
3318
|
-
// A listener whose event name is `
|
|
3319
|
-
//
|
|
3320
|
-
// (the standard broadcast client the app configures). When the broadcast
|
|
3548
|
+
// A listener whose event name is `socket:CHANNEL,EVENT` (or socket-private: /
|
|
3549
|
+
// socket-presence:) subscribes to a broadcast channel. When the broadcast
|
|
3321
3550
|
// arrives, the component's @on method runs server-side via a normal action frame —
|
|
3322
|
-
// so a broadcast updates the component exactly like any other event.
|
|
3323
|
-
//
|
|
3551
|
+
// so a broadcast updates the component exactly like any other event.
|
|
3552
|
+
//
|
|
3553
|
+
// The client is bundled into this runtime and created on first use, so a page
|
|
3554
|
+
// that declares a listener is live without the app shipping any script. That is
|
|
3555
|
+
// not a convenience: these subscriptions used to be *silently inert* when the
|
|
3556
|
+
// global was missing — no error, no warning — so a live feature with no script
|
|
3557
|
+
// looked exactly like a live feature that was never written. An app that needs a
|
|
3558
|
+
// configured client (a different host, its own auth endpoint) still sets
|
|
3559
|
+
// `window.Socket` itself before the runtime loads, and that one wins.
|
|
3560
|
+
|
|
3561
|
+
/**
|
|
3562
|
+
* The broadcast client: whatever the app published, or one made on demand.
|
|
3563
|
+
*
|
|
3564
|
+
* Constructed on first use rather than at load, so an app with no realtime
|
|
3565
|
+
* feature never opens a second connection. `new Socket()` needs no arguments in
|
|
3566
|
+
* a browser — it derives its URL from `location` and posts to
|
|
3567
|
+
* `/broadcasting/auth` — which is exactly the wiring every app was writing by
|
|
3568
|
+
* hand.
|
|
3569
|
+
*/
|
|
3570
|
+
function _socketClient(): SocketClient {
|
|
3571
|
+
const w = window as unknown as { Socket?: SocketClient };
|
|
3572
|
+
return (w.Socket ??= new BundledSocket() as unknown as SocketClient);
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
/**
|
|
3576
|
+
* The client, but only if one already exists.
|
|
3577
|
+
*
|
|
3578
|
+
* Teardown and whisper paths use this: creating a connection in order to leave a
|
|
3579
|
+
* channel, or to whisper on a page that never joined one, would open a socket to
|
|
3580
|
+
* do nothing with it.
|
|
3581
|
+
*/
|
|
3582
|
+
function _existingSocketClient(): SocketClient | undefined {
|
|
3583
|
+
return (window as unknown as { Socket?: SocketClient }).Socket;
|
|
3584
|
+
}
|
|
3324
3585
|
|
|
3325
|
-
type
|
|
3326
|
-
const
|
|
3586
|
+
type SocketSub = { channel: string; kind: "" | "-private" | "-presence" };
|
|
3587
|
+
const _socketSubs = new Map<string, SocketSub[]>();
|
|
3327
3588
|
|
|
3328
|
-
/** Parse `
|
|
3329
|
-
export function
|
|
3589
|
+
/** Parse `socket[-private|-presence]:channel,event` → parts, or null if not a socket listener. @internal */
|
|
3590
|
+
export function _parseSocketListener(
|
|
3330
3591
|
name: string,
|
|
3331
|
-
): { kind:
|
|
3332
|
-
const m = /^
|
|
3592
|
+
): { kind: SocketSub["kind"]; channel: string; event: string } | null {
|
|
3593
|
+
const m = /^socket(-private|-presence)?:/.exec(name);
|
|
3333
3594
|
if (!m) return null;
|
|
3334
|
-
const kind = (m[1] ?? "") as
|
|
3595
|
+
const kind = (m[1] ?? "") as SocketSub["kind"];
|
|
3335
3596
|
const rest = name.slice(m[0].length);
|
|
3336
3597
|
const ci = rest.lastIndexOf(","); // split on the LAST comma (channels may contain dots, not commas)
|
|
3337
3598
|
if (ci === -1) return null;
|
|
3338
3599
|
return { kind, channel: rest.slice(0, ci), event: rest.slice(ci + 1) };
|
|
3339
3600
|
}
|
|
3340
3601
|
|
|
3341
|
-
interface
|
|
3342
|
-
listen(event: string, cb: (payload: unknown) => void):
|
|
3343
|
-
here?(cb: (payload: unknown) => void):
|
|
3344
|
-
joining?(cb: (payload: unknown) => void):
|
|
3345
|
-
leaving?(cb: (payload: unknown) => void):
|
|
3602
|
+
interface SocketChannel {
|
|
3603
|
+
listen(event: string, cb: (payload: unknown) => void): SocketChannel;
|
|
3604
|
+
here?(cb: (payload: unknown) => void): SocketChannel;
|
|
3605
|
+
joining?(cb: (payload: unknown) => void): SocketChannel;
|
|
3606
|
+
leaving?(cb: (payload: unknown) => void): SocketChannel;
|
|
3346
3607
|
}
|
|
3347
|
-
interface
|
|
3348
|
-
channel(name: string):
|
|
3349
|
-
private(name: string):
|
|
3350
|
-
join(name: string):
|
|
3608
|
+
interface SocketClient {
|
|
3609
|
+
channel(name: string): SocketChannel;
|
|
3610
|
+
private(name: string): SocketChannel;
|
|
3611
|
+
join(name: string): SocketChannel;
|
|
3351
3612
|
leave(name: string): void;
|
|
3352
3613
|
}
|
|
3353
3614
|
|
|
3354
|
-
function
|
|
3355
|
-
|
|
3615
|
+
function _setupSocketListeners(comp: FlowComponent): void {
|
|
3616
|
+
// The snapshot decides whether this page needs a connection at all — asking
|
|
3617
|
+
// for the client first would open one on every page, including the ones with
|
|
3618
|
+
// nothing to subscribe to.
|
|
3356
3619
|
const listeners = comp.snapshot.memo.listeners;
|
|
3357
|
-
if (!
|
|
3620
|
+
if (!listeners) return;
|
|
3621
|
+
const client = _socketClient();
|
|
3358
3622
|
|
|
3359
|
-
const subs:
|
|
3623
|
+
const subs: SocketSub[] = [];
|
|
3360
3624
|
for (const [name, method] of Object.entries(listeners)) {
|
|
3361
|
-
const parsed =
|
|
3625
|
+
const parsed = _parseSocketListener(name);
|
|
3362
3626
|
if (!parsed) continue;
|
|
3363
3627
|
const { kind, channel, event } = parsed;
|
|
3364
3628
|
|
|
3365
3629
|
const ch =
|
|
3366
3630
|
kind === "-private"
|
|
3367
|
-
?
|
|
3631
|
+
? client.private(channel)
|
|
3368
3632
|
: kind === "-presence"
|
|
3369
|
-
?
|
|
3370
|
-
:
|
|
3633
|
+
? client.join(channel)
|
|
3634
|
+
: client.channel(channel);
|
|
3371
3635
|
|
|
3372
3636
|
const cb = (payload: unknown) => _callAction(comp, method, [payload]);
|
|
3373
3637
|
// Presence sub-events (here/joining/leaving) use their own methods; everything
|
|
@@ -3379,43 +3643,43 @@ function _setupEchoListeners(comp: FlowComponent): void {
|
|
|
3379
3643
|
}
|
|
3380
3644
|
subs.push({ channel, kind });
|
|
3381
3645
|
}
|
|
3382
|
-
if (subs.length)
|
|
3646
|
+
if (subs.length) _socketSubs.set(comp.id, subs);
|
|
3383
3647
|
}
|
|
3384
3648
|
|
|
3385
|
-
function
|
|
3386
|
-
const
|
|
3387
|
-
const subs =
|
|
3388
|
-
if (
|
|
3649
|
+
function _teardownSocketListeners(id: string): void {
|
|
3650
|
+
const client = _existingSocketClient();
|
|
3651
|
+
const subs = _socketSubs.get(id);
|
|
3652
|
+
if (client && subs)
|
|
3389
3653
|
for (const s of subs) {
|
|
3390
3654
|
try {
|
|
3391
|
-
|
|
3655
|
+
client.leave(s.channel);
|
|
3392
3656
|
} catch {
|
|
3393
3657
|
/* ignore */
|
|
3394
3658
|
}
|
|
3395
3659
|
}
|
|
3396
|
-
|
|
3660
|
+
_socketSubs.delete(id);
|
|
3397
3661
|
}
|
|
3398
3662
|
|
|
3399
3663
|
// ── @presence channels ────────────────────────────────────────────────────────
|
|
3400
3664
|
// A component with @presence props carries its resolved channel(s) in memo.presence.
|
|
3401
3665
|
// Join each presence channel and, on here/joining/leaving, dispatch `$presence` so the
|
|
3402
3666
|
// server refills the member list and re-renders. Cursors/ephemeral state ride the same
|
|
3403
|
-
// channel via
|
|
3667
|
+
// channel via client whispers (see _presenceChannelFor / whisper helpers below).
|
|
3404
3668
|
|
|
3405
|
-
interface
|
|
3406
|
-
whisper?(event: string, data: unknown):
|
|
3407
|
-
listenForWhisper?(event: string, cb: (data: unknown) => void):
|
|
3669
|
+
interface PresenceSocketChannel extends SocketChannel {
|
|
3670
|
+
whisper?(event: string, data: unknown): PresenceSocketChannel;
|
|
3671
|
+
listenForWhisper?(event: string, cb: (data: unknown) => void): PresenceSocketChannel;
|
|
3408
3672
|
}
|
|
3409
3673
|
const _presenceChannels = new Map<string, string[]>(); // componentId → joined channels
|
|
3410
3674
|
|
|
3411
3675
|
function _setupPresence(comp: FlowComponent): void {
|
|
3412
|
-
const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
|
|
3413
3676
|
const presence = comp.snapshot.memo.presence;
|
|
3414
|
-
if (!
|
|
3677
|
+
if (!presence || presence.length === 0) return;
|
|
3678
|
+
const client = _socketClient();
|
|
3415
3679
|
|
|
3416
3680
|
const channels: string[] = [];
|
|
3417
3681
|
for (const { channel } of presence) {
|
|
3418
|
-
const ch =
|
|
3682
|
+
const ch = client.join(channel);
|
|
3419
3683
|
const refresh = (): void => _callAction(comp, "$presence", []);
|
|
3420
3684
|
ch.here?.(refresh);
|
|
3421
3685
|
ch.joining?.(refresh);
|
|
@@ -3426,12 +3690,12 @@ function _setupPresence(comp: FlowComponent): void {
|
|
|
3426
3690
|
}
|
|
3427
3691
|
|
|
3428
3692
|
function _teardownPresence(id: string): void {
|
|
3429
|
-
const
|
|
3693
|
+
const client = _existingSocketClient();
|
|
3430
3694
|
const channels = _presenceChannels.get(id);
|
|
3431
|
-
if (
|
|
3695
|
+
if (client && channels)
|
|
3432
3696
|
for (const c of channels) {
|
|
3433
3697
|
try {
|
|
3434
|
-
|
|
3698
|
+
client.leave(c);
|
|
3435
3699
|
} catch {
|
|
3436
3700
|
/* ignore */
|
|
3437
3701
|
}
|
|
@@ -3447,21 +3711,21 @@ function _presenceChannelFor(comp: FlowComponent): string | null {
|
|
|
3447
3711
|
/**
|
|
3448
3712
|
* Broadcast ephemeral state (e.g. a cursor position) to the other members of the
|
|
3449
3713
|
* component's presence channel — client-only, never hits the server or the DB. Pair with
|
|
3450
|
-
* `onWhisper`. No-op without `window.
|
|
3714
|
+
* `onWhisper`. No-op without `window.client` or a presence channel.
|
|
3451
3715
|
*/
|
|
3452
3716
|
function _whisper(comp: FlowComponent, event: string, data: unknown): void {
|
|
3453
|
-
const
|
|
3717
|
+
const client = _existingSocketClient();
|
|
3454
3718
|
const channel = _presenceChannelFor(comp);
|
|
3455
|
-
if (!
|
|
3456
|
-
(
|
|
3719
|
+
if (!client || !channel) return;
|
|
3720
|
+
(client.join(channel) as PresenceSocketChannel).whisper?.(event, data);
|
|
3457
3721
|
}
|
|
3458
3722
|
|
|
3459
3723
|
/** Listen for peers' whispers on the component's presence channel (e.g. cursor moves). */
|
|
3460
3724
|
function _onWhisper(comp: FlowComponent, event: string, cb: (data: unknown) => void): void {
|
|
3461
|
-
const
|
|
3725
|
+
const client = _existingSocketClient();
|
|
3462
3726
|
const channel = _presenceChannelFor(comp);
|
|
3463
|
-
if (!
|
|
3464
|
-
(
|
|
3727
|
+
if (!client || !channel) return;
|
|
3728
|
+
(client.join(channel) as PresenceSocketChannel).listenForWhisper?.(event, cb);
|
|
3465
3729
|
}
|
|
3466
3730
|
|
|
3467
3731
|
// ── @task cancellation ─────────────────────────────────────────────────────────
|
|
@@ -3489,32 +3753,32 @@ function _cancelTask(comp: FlowComponent): void {
|
|
|
3489
3753
|
// A component with @shared props carries its resolved channel(s) in memo.shared. Join each
|
|
3490
3754
|
// channel and, on the `flow:shared` broadcast, dispatch `$shared` so the server re-reads
|
|
3491
3755
|
// the room store and re-renders — every subscriber converges to the same state. Shared and
|
|
3492
|
-
// presence channels are joined the same way (
|
|
3756
|
+
// presence channels are joined the same way (client.join), so one room channel can carry both.
|
|
3493
3757
|
|
|
3494
3758
|
const SHARED_EVENT = "flow:shared";
|
|
3495
3759
|
const _sharedChannels = new Map<string, string[]>(); // componentId → joined channels
|
|
3496
3760
|
|
|
3497
3761
|
function _setupShared(comp: FlowComponent): void {
|
|
3498
|
-
const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
|
|
3499
3762
|
const shared = comp.snapshot.memo.shared;
|
|
3500
|
-
if (!
|
|
3763
|
+
if (!shared || shared.length === 0) return;
|
|
3764
|
+
const client = _socketClient();
|
|
3501
3765
|
|
|
3502
3766
|
const channels: string[] = [];
|
|
3503
3767
|
for (const { channel } of shared) {
|
|
3504
3768
|
const converge = (): void => _callAction(comp, "$shared", []);
|
|
3505
|
-
|
|
3769
|
+
client.join(channel).listen(SHARED_EVENT, converge);
|
|
3506
3770
|
channels.push(channel);
|
|
3507
3771
|
}
|
|
3508
3772
|
if (channels.length) _sharedChannels.set(comp.id, channels);
|
|
3509
3773
|
}
|
|
3510
3774
|
|
|
3511
3775
|
function _teardownShared(id: string): void {
|
|
3512
|
-
const
|
|
3776
|
+
const client = _existingSocketClient();
|
|
3513
3777
|
const channels = _sharedChannels.get(id);
|
|
3514
|
-
if (
|
|
3778
|
+
if (client && channels)
|
|
3515
3779
|
for (const c of channels) {
|
|
3516
3780
|
try {
|
|
3517
|
-
|
|
3781
|
+
client.leave(c);
|
|
3518
3782
|
} catch {
|
|
3519
3783
|
/* ignore */
|
|
3520
3784
|
}
|