@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.
@@ -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: fixedUpdates ?? _pendingUpdates(comp),
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: "Timeline",
1230
+ title: "Flow",
1190
1231
  badge: () => _timeline.getFrames().length || undefined,
1191
- render: (el: HTMLElement) => _renderTimelineInto(el),
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: _takeLastAction(component),
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
- if (frame.message === "Unknown component" && !_reloadedForExpiry()) {
1765
+ const reloadReason = _reloadReasonFor(frame, _reloadedForExpiry());
1766
+ if (reloadReason) {
1598
1767
  _markReloadedForExpiry();
1599
1768
  console.warn(
1600
- `[Flow] Component "${frame.component}" is no longer registered on the server ` +
1601
- `(likely a server restart) — reloading the page to recover.`,
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 the method a component last dispatched, so the patch it produces is
2059
- // labelled with that action name in the timeline. Consumed (and cleared) when the patch lands.
2060
- const _pendingActionByComp = new Map<string, string>();
2061
- function _takeLastAction(compId: string): string {
2062
- const a = _pendingActionByComp.get(compId);
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 a ?? "action";
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
- document.querySelector('[id^="flow-state-"]')?.remove();
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
- _setupEchoListeners(comp);
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
- _teardownEchoListeners(id);
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('echo:…') listeners ─────────────────────────────────────────
3546
+ // ── Real-time @on('socket:…') listeners ───────────────────────────────────────
3317
3547
  //
3318
- // A listener whose event name is `echo:CHANNEL,EVENT` (or echo-private: /
3319
- // echo-presence:) subscribes to a broadcast channel via the global `window.Echo`
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. No-op when
3323
- // `window.Echo` is absent.
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 EchoSub = { channel: string; kind: "" | "-private" | "-presence" };
3326
- const _echoSubs = new Map<string, EchoSub[]>();
3586
+ type SocketSub = { channel: string; kind: "" | "-private" | "-presence" };
3587
+ const _socketSubs = new Map<string, SocketSub[]>();
3327
3588
 
3328
- /** Parse `echo[-private|-presence]:channel,event` → parts, or null if not an echo listener. @internal */
3329
- export function _parseEchoListener(
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: EchoSub["kind"]; channel: string; event: string } | null {
3332
- const m = /^echo(-private|-presence)?:/.exec(name);
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 EchoSub["kind"];
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 EchoChannel {
3342
- listen(event: string, cb: (payload: unknown) => void): EchoChannel;
3343
- here?(cb: (payload: unknown) => void): EchoChannel;
3344
- joining?(cb: (payload: unknown) => void): EchoChannel;
3345
- leaving?(cb: (payload: unknown) => void): EchoChannel;
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 EchoClient {
3348
- channel(name: string): EchoChannel;
3349
- private(name: string): EchoChannel;
3350
- join(name: string): EchoChannel;
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 _setupEchoListeners(comp: FlowComponent): void {
3355
- const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
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 (!Echo || !listeners) return;
3620
+ if (!listeners) return;
3621
+ const client = _socketClient();
3358
3622
 
3359
- const subs: EchoSub[] = [];
3623
+ const subs: SocketSub[] = [];
3360
3624
  for (const [name, method] of Object.entries(listeners)) {
3361
- const parsed = _parseEchoListener(name);
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
- ? Echo.private(channel)
3631
+ ? client.private(channel)
3368
3632
  : kind === "-presence"
3369
- ? Echo.join(channel)
3370
- : Echo.channel(channel);
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) _echoSubs.set(comp.id, subs);
3646
+ if (subs.length) _socketSubs.set(comp.id, subs);
3383
3647
  }
3384
3648
 
3385
- function _teardownEchoListeners(id: string): void {
3386
- const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3387
- const subs = _echoSubs.get(id);
3388
- if (Echo && subs)
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
- Echo.leave(s.channel);
3655
+ client.leave(s.channel);
3392
3656
  } catch {
3393
3657
  /* ignore */
3394
3658
  }
3395
3659
  }
3396
- _echoSubs.delete(id);
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 Echo whispers (see _presenceChannelFor / whisper helpers below).
3667
+ // channel via client whispers (see _presenceChannelFor / whisper helpers below).
3404
3668
 
3405
- interface PresenceEchoChannel extends EchoChannel {
3406
- whisper?(event: string, data: unknown): PresenceEchoChannel;
3407
- listenForWhisper?(event: string, cb: (data: unknown) => void): PresenceEchoChannel;
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 (!Echo || !presence || presence.length === 0) return;
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 = Echo.join(channel);
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 Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3693
+ const client = _existingSocketClient();
3430
3694
  const channels = _presenceChannels.get(id);
3431
- if (Echo && channels)
3695
+ if (client && channels)
3432
3696
  for (const c of channels) {
3433
3697
  try {
3434
- Echo.leave(c);
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.Echo` or a presence channel.
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 Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3717
+ const client = _existingSocketClient();
3454
3718
  const channel = _presenceChannelFor(comp);
3455
- if (!Echo || !channel) return;
3456
- (Echo.join(channel) as PresenceEchoChannel).whisper?.(event, data);
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 Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3725
+ const client = _existingSocketClient();
3462
3726
  const channel = _presenceChannelFor(comp);
3463
- if (!Echo || !channel) return;
3464
- (Echo.join(channel) as PresenceEchoChannel).listenForWhisper?.(event, cb);
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 (Echo.join), so one room channel can carry both.
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 (!Echo || !shared || shared.length === 0) return;
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
- Echo.join(channel).listen(SHARED_EVENT, converge);
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 Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3776
+ const client = _existingSocketClient();
3513
3777
  const channels = _sharedChannels.get(id);
3514
- if (Echo && channels)
3778
+ if (client && channels)
3515
3779
  for (const c of channels) {
3516
3780
  try {
3517
- Echo.leave(c);
3781
+ client.leave(c);
3518
3782
  } catch {
3519
3783
  /* ignore */
3520
3784
  }