@zerotal/flow 1.7.0 → 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 +162 -0
- package/package.json +6 -5
- package/src/browser/FlowBrowser.ts +7 -2
- package/src/client/bridge.ts +329 -79
- 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 +8 -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/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
|
|
|
@@ -2819,6 +3009,32 @@ function _setupPrefetch(): void {
|
|
|
2819
3009
|
},
|
|
2820
3010
|
{ passive: true },
|
|
2821
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
|
+
);
|
|
2822
3038
|
}
|
|
2823
3039
|
|
|
2824
3040
|
function _setupNavigationLinks(): void {
|
|
@@ -3252,7 +3468,7 @@ function _scanComponents(): void {
|
|
|
3252
3468
|
_setupPolling(comp);
|
|
3253
3469
|
_setupIntersect(comp);
|
|
3254
3470
|
_setupSort(comp);
|
|
3255
|
-
|
|
3471
|
+
_setupSocketListeners(comp);
|
|
3256
3472
|
_setupPresence(comp);
|
|
3257
3473
|
_setupShared(comp);
|
|
3258
3474
|
|
|
@@ -3318,7 +3534,7 @@ function _cleanupDisconnected(): void {
|
|
|
3318
3534
|
obs.disconnect();
|
|
3319
3535
|
_lazyObservers.delete(id);
|
|
3320
3536
|
}
|
|
3321
|
-
|
|
3537
|
+
_teardownSocketListeners(id);
|
|
3322
3538
|
_teardownPresence(id);
|
|
3323
3539
|
_teardownShared(id);
|
|
3324
3540
|
_optOps.delete(id);
|
|
@@ -3327,61 +3543,95 @@ function _cleanupDisconnected(): void {
|
|
|
3327
3543
|
}
|
|
3328
3544
|
}
|
|
3329
3545
|
|
|
3330
|
-
// ── Real-time @on('
|
|
3546
|
+
// ── Real-time @on('socket:…') listeners ───────────────────────────────────────
|
|
3331
3547
|
//
|
|
3332
|
-
// A listener whose event name is `
|
|
3333
|
-
//
|
|
3334
|
-
// (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
|
|
3335
3550
|
// arrives, the component's @on method runs server-side via a normal action frame —
|
|
3336
|
-
// so a broadcast updates the component exactly like any other event.
|
|
3337
|
-
//
|
|
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
|
+
}
|
|
3338
3585
|
|
|
3339
|
-
type
|
|
3340
|
-
const
|
|
3586
|
+
type SocketSub = { channel: string; kind: "" | "-private" | "-presence" };
|
|
3587
|
+
const _socketSubs = new Map<string, SocketSub[]>();
|
|
3341
3588
|
|
|
3342
|
-
/** Parse `
|
|
3343
|
-
export function
|
|
3589
|
+
/** Parse `socket[-private|-presence]:channel,event` → parts, or null if not a socket listener. @internal */
|
|
3590
|
+
export function _parseSocketListener(
|
|
3344
3591
|
name: string,
|
|
3345
|
-
): { kind:
|
|
3346
|
-
const m = /^
|
|
3592
|
+
): { kind: SocketSub["kind"]; channel: string; event: string } | null {
|
|
3593
|
+
const m = /^socket(-private|-presence)?:/.exec(name);
|
|
3347
3594
|
if (!m) return null;
|
|
3348
|
-
const kind = (m[1] ?? "") as
|
|
3595
|
+
const kind = (m[1] ?? "") as SocketSub["kind"];
|
|
3349
3596
|
const rest = name.slice(m[0].length);
|
|
3350
3597
|
const ci = rest.lastIndexOf(","); // split on the LAST comma (channels may contain dots, not commas)
|
|
3351
3598
|
if (ci === -1) return null;
|
|
3352
3599
|
return { kind, channel: rest.slice(0, ci), event: rest.slice(ci + 1) };
|
|
3353
3600
|
}
|
|
3354
3601
|
|
|
3355
|
-
interface
|
|
3356
|
-
listen(event: string, cb: (payload: unknown) => void):
|
|
3357
|
-
here?(cb: (payload: unknown) => void):
|
|
3358
|
-
joining?(cb: (payload: unknown) => void):
|
|
3359
|
-
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;
|
|
3360
3607
|
}
|
|
3361
|
-
interface
|
|
3362
|
-
channel(name: string):
|
|
3363
|
-
private(name: string):
|
|
3364
|
-
join(name: string):
|
|
3608
|
+
interface SocketClient {
|
|
3609
|
+
channel(name: string): SocketChannel;
|
|
3610
|
+
private(name: string): SocketChannel;
|
|
3611
|
+
join(name: string): SocketChannel;
|
|
3365
3612
|
leave(name: string): void;
|
|
3366
3613
|
}
|
|
3367
3614
|
|
|
3368
|
-
function
|
|
3369
|
-
|
|
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.
|
|
3370
3619
|
const listeners = comp.snapshot.memo.listeners;
|
|
3371
|
-
if (!
|
|
3620
|
+
if (!listeners) return;
|
|
3621
|
+
const client = _socketClient();
|
|
3372
3622
|
|
|
3373
|
-
const subs:
|
|
3623
|
+
const subs: SocketSub[] = [];
|
|
3374
3624
|
for (const [name, method] of Object.entries(listeners)) {
|
|
3375
|
-
const parsed =
|
|
3625
|
+
const parsed = _parseSocketListener(name);
|
|
3376
3626
|
if (!parsed) continue;
|
|
3377
3627
|
const { kind, channel, event } = parsed;
|
|
3378
3628
|
|
|
3379
3629
|
const ch =
|
|
3380
3630
|
kind === "-private"
|
|
3381
|
-
?
|
|
3631
|
+
? client.private(channel)
|
|
3382
3632
|
: kind === "-presence"
|
|
3383
|
-
?
|
|
3384
|
-
:
|
|
3633
|
+
? client.join(channel)
|
|
3634
|
+
: client.channel(channel);
|
|
3385
3635
|
|
|
3386
3636
|
const cb = (payload: unknown) => _callAction(comp, method, [payload]);
|
|
3387
3637
|
// Presence sub-events (here/joining/leaving) use their own methods; everything
|
|
@@ -3393,43 +3643,43 @@ function _setupEchoListeners(comp: FlowComponent): void {
|
|
|
3393
3643
|
}
|
|
3394
3644
|
subs.push({ channel, kind });
|
|
3395
3645
|
}
|
|
3396
|
-
if (subs.length)
|
|
3646
|
+
if (subs.length) _socketSubs.set(comp.id, subs);
|
|
3397
3647
|
}
|
|
3398
3648
|
|
|
3399
|
-
function
|
|
3400
|
-
const
|
|
3401
|
-
const subs =
|
|
3402
|
-
if (
|
|
3649
|
+
function _teardownSocketListeners(id: string): void {
|
|
3650
|
+
const client = _existingSocketClient();
|
|
3651
|
+
const subs = _socketSubs.get(id);
|
|
3652
|
+
if (client && subs)
|
|
3403
3653
|
for (const s of subs) {
|
|
3404
3654
|
try {
|
|
3405
|
-
|
|
3655
|
+
client.leave(s.channel);
|
|
3406
3656
|
} catch {
|
|
3407
3657
|
/* ignore */
|
|
3408
3658
|
}
|
|
3409
3659
|
}
|
|
3410
|
-
|
|
3660
|
+
_socketSubs.delete(id);
|
|
3411
3661
|
}
|
|
3412
3662
|
|
|
3413
3663
|
// ── @presence channels ────────────────────────────────────────────────────────
|
|
3414
3664
|
// A component with @presence props carries its resolved channel(s) in memo.presence.
|
|
3415
3665
|
// Join each presence channel and, on here/joining/leaving, dispatch `$presence` so the
|
|
3416
3666
|
// server refills the member list and re-renders. Cursors/ephemeral state ride the same
|
|
3417
|
-
// channel via
|
|
3667
|
+
// channel via client whispers (see _presenceChannelFor / whisper helpers below).
|
|
3418
3668
|
|
|
3419
|
-
interface
|
|
3420
|
-
whisper?(event: string, data: unknown):
|
|
3421
|
-
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;
|
|
3422
3672
|
}
|
|
3423
3673
|
const _presenceChannels = new Map<string, string[]>(); // componentId → joined channels
|
|
3424
3674
|
|
|
3425
3675
|
function _setupPresence(comp: FlowComponent): void {
|
|
3426
|
-
const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
|
|
3427
3676
|
const presence = comp.snapshot.memo.presence;
|
|
3428
|
-
if (!
|
|
3677
|
+
if (!presence || presence.length === 0) return;
|
|
3678
|
+
const client = _socketClient();
|
|
3429
3679
|
|
|
3430
3680
|
const channels: string[] = [];
|
|
3431
3681
|
for (const { channel } of presence) {
|
|
3432
|
-
const ch =
|
|
3682
|
+
const ch = client.join(channel);
|
|
3433
3683
|
const refresh = (): void => _callAction(comp, "$presence", []);
|
|
3434
3684
|
ch.here?.(refresh);
|
|
3435
3685
|
ch.joining?.(refresh);
|
|
@@ -3440,12 +3690,12 @@ function _setupPresence(comp: FlowComponent): void {
|
|
|
3440
3690
|
}
|
|
3441
3691
|
|
|
3442
3692
|
function _teardownPresence(id: string): void {
|
|
3443
|
-
const
|
|
3693
|
+
const client = _existingSocketClient();
|
|
3444
3694
|
const channels = _presenceChannels.get(id);
|
|
3445
|
-
if (
|
|
3695
|
+
if (client && channels)
|
|
3446
3696
|
for (const c of channels) {
|
|
3447
3697
|
try {
|
|
3448
|
-
|
|
3698
|
+
client.leave(c);
|
|
3449
3699
|
} catch {
|
|
3450
3700
|
/* ignore */
|
|
3451
3701
|
}
|
|
@@ -3461,21 +3711,21 @@ function _presenceChannelFor(comp: FlowComponent): string | null {
|
|
|
3461
3711
|
/**
|
|
3462
3712
|
* Broadcast ephemeral state (e.g. a cursor position) to the other members of the
|
|
3463
3713
|
* component's presence channel — client-only, never hits the server or the DB. Pair with
|
|
3464
|
-
* `onWhisper`. No-op without `window.
|
|
3714
|
+
* `onWhisper`. No-op without `window.client` or a presence channel.
|
|
3465
3715
|
*/
|
|
3466
3716
|
function _whisper(comp: FlowComponent, event: string, data: unknown): void {
|
|
3467
|
-
const
|
|
3717
|
+
const client = _existingSocketClient();
|
|
3468
3718
|
const channel = _presenceChannelFor(comp);
|
|
3469
|
-
if (!
|
|
3470
|
-
(
|
|
3719
|
+
if (!client || !channel) return;
|
|
3720
|
+
(client.join(channel) as PresenceSocketChannel).whisper?.(event, data);
|
|
3471
3721
|
}
|
|
3472
3722
|
|
|
3473
3723
|
/** Listen for peers' whispers on the component's presence channel (e.g. cursor moves). */
|
|
3474
3724
|
function _onWhisper(comp: FlowComponent, event: string, cb: (data: unknown) => void): void {
|
|
3475
|
-
const
|
|
3725
|
+
const client = _existingSocketClient();
|
|
3476
3726
|
const channel = _presenceChannelFor(comp);
|
|
3477
|
-
if (!
|
|
3478
|
-
(
|
|
3727
|
+
if (!client || !channel) return;
|
|
3728
|
+
(client.join(channel) as PresenceSocketChannel).listenForWhisper?.(event, cb);
|
|
3479
3729
|
}
|
|
3480
3730
|
|
|
3481
3731
|
// ── @task cancellation ─────────────────────────────────────────────────────────
|
|
@@ -3503,32 +3753,32 @@ function _cancelTask(comp: FlowComponent): void {
|
|
|
3503
3753
|
// A component with @shared props carries its resolved channel(s) in memo.shared. Join each
|
|
3504
3754
|
// channel and, on the `flow:shared` broadcast, dispatch `$shared` so the server re-reads
|
|
3505
3755
|
// the room store and re-renders — every subscriber converges to the same state. Shared and
|
|
3506
|
-
// presence channels are joined the same way (
|
|
3756
|
+
// presence channels are joined the same way (client.join), so one room channel can carry both.
|
|
3507
3757
|
|
|
3508
3758
|
const SHARED_EVENT = "flow:shared";
|
|
3509
3759
|
const _sharedChannels = new Map<string, string[]>(); // componentId → joined channels
|
|
3510
3760
|
|
|
3511
3761
|
function _setupShared(comp: FlowComponent): void {
|
|
3512
|
-
const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
|
|
3513
3762
|
const shared = comp.snapshot.memo.shared;
|
|
3514
|
-
if (!
|
|
3763
|
+
if (!shared || shared.length === 0) return;
|
|
3764
|
+
const client = _socketClient();
|
|
3515
3765
|
|
|
3516
3766
|
const channels: string[] = [];
|
|
3517
3767
|
for (const { channel } of shared) {
|
|
3518
3768
|
const converge = (): void => _callAction(comp, "$shared", []);
|
|
3519
|
-
|
|
3769
|
+
client.join(channel).listen(SHARED_EVENT, converge);
|
|
3520
3770
|
channels.push(channel);
|
|
3521
3771
|
}
|
|
3522
3772
|
if (channels.length) _sharedChannels.set(comp.id, channels);
|
|
3523
3773
|
}
|
|
3524
3774
|
|
|
3525
3775
|
function _teardownShared(id: string): void {
|
|
3526
|
-
const
|
|
3776
|
+
const client = _existingSocketClient();
|
|
3527
3777
|
const channels = _sharedChannels.get(id);
|
|
3528
|
-
if (
|
|
3778
|
+
if (client && channels)
|
|
3529
3779
|
for (const c of channels) {
|
|
3530
3780
|
try {
|
|
3531
|
-
|
|
3781
|
+
client.leave(c);
|
|
3532
3782
|
} catch {
|
|
3533
3783
|
/* ignore */
|
|
3534
3784
|
}
|