@zerotal/flow 1.7.0 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -2710,12 +2900,32 @@ async function _navigateTo(href: string, options: NavigateOptions = {}): Promise
2710
2900
  // the animation ends at the right offset instead of sliding there.
2711
2901
  _applyScroll(scrollIntent, _hashOf(href));
2712
2902
  };
2713
- const _startVT = (document as unknown as { startViewTransition?: (cb: () => void) => unknown })
2714
- .startViewTransition;
2903
+ // The return value is typed here rather than cast at the call site, so reading
2904
+ // the transition's promises below costs no new assertion.
2905
+ const _startVT = (
2906
+ document as unknown as {
2907
+ startViewTransition?: (cb: () => void) => {
2908
+ ready?: Promise<unknown>;
2909
+ finished?: Promise<unknown>;
2910
+ };
2911
+ }
2912
+ ).startViewTransition;
2715
2913
  const _reduceMotion =
2716
2914
  typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
2717
- if (typeof _startVT === "function" && !_reduceMotion) _startVT.call(document, _swap);
2718
- else _swap(); // reduced-motion (or unsupported): swap instantly, no cross-page animation
2915
+ if (typeof _startVT === "function" && !_reduceMotion) {
2916
+ const _vt = _startVT.call(document, _swap);
2917
+ // A navigation that starts while a transition is still running makes the
2918
+ // browser skip the older one and reject its `ready` and `finished` promises
2919
+ // with an AbortError. Nothing has gone wrong — the swap itself already ran
2920
+ // inside the callback — but an unhandled rejection reaches `window.onerror`
2921
+ // and whatever error tracker the app has wired to it. Clicking through links
2922
+ // faster than the animation reported a stream of errors describing an
2923
+ // animation the browser skipped on purpose.
2924
+ void _vt?.ready?.catch(() => {});
2925
+ void _vt?.finished?.catch(() => {});
2926
+ } else {
2927
+ _swap(); // reduced-motion (or unsupported): swap instantly, no cross-page animation
2928
+ }
2719
2929
 
2720
2930
  if (push) _pushHistoryEntry(href);
2721
2931
  const incomingTitle = doc.querySelector("title");
@@ -2819,6 +3029,32 @@ function _setupPrefetch(): void {
2819
3029
  },
2820
3030
  { passive: true },
2821
3031
  );
3032
+
3033
+ // `flow:navigate.down` — prefetch on pointer-down, with no dwell.
3034
+ //
3035
+ // The middle setting between "never" and "every link the pointer crosses".
3036
+ // Hover prefetch is free speed on a handful of stable links and the opposite
3037
+ // on a dense list: moving the pointer down a table of a hundred rows asks the
3038
+ // server for a hundred pages, none of which anyone chose. Pointer-down fires
3039
+ // once, on the link the reader has committed to, and still beats the click by
3040
+ // however long the button stays held — around 100ms of ordinary human timing,
3041
+ // which is most of a fast page render.
3042
+ //
3043
+ // `pointerdown` rather than `mousedown` so a touch lands here too, and it is
3044
+ // passive because this never calls preventDefault — the click that follows is
3045
+ // handled by the navigation listener as usual, and finds the page waiting in
3046
+ // the cache.
3047
+ document.addEventListener(
3048
+ "pointerdown",
3049
+ (e) => {
3050
+ const a = (e.target as Element).closest?.(
3051
+ "a[flow\\:navigate\\.down]",
3052
+ ) as HTMLAnchorElement | null;
3053
+ const href = a ? sameOrigin(a) : null;
3054
+ if (href) _prefetch(href);
3055
+ },
3056
+ { passive: true },
3057
+ );
2822
3058
  }
2823
3059
 
2824
3060
  function _setupNavigationLinks(): void {
@@ -3252,7 +3488,7 @@ function _scanComponents(): void {
3252
3488
  _setupPolling(comp);
3253
3489
  _setupIntersect(comp);
3254
3490
  _setupSort(comp);
3255
- _setupEchoListeners(comp);
3491
+ _setupSocketListeners(comp);
3256
3492
  _setupPresence(comp);
3257
3493
  _setupShared(comp);
3258
3494
 
@@ -3318,7 +3554,7 @@ function _cleanupDisconnected(): void {
3318
3554
  obs.disconnect();
3319
3555
  _lazyObservers.delete(id);
3320
3556
  }
3321
- _teardownEchoListeners(id);
3557
+ _teardownSocketListeners(id);
3322
3558
  _teardownPresence(id);
3323
3559
  _teardownShared(id);
3324
3560
  _optOps.delete(id);
@@ -3327,61 +3563,95 @@ function _cleanupDisconnected(): void {
3327
3563
  }
3328
3564
  }
3329
3565
 
3330
- // ── Real-time @on('echo:…') listeners ─────────────────────────────────────────
3566
+ // ── Real-time @on('socket:…') listeners ───────────────────────────────────────
3331
3567
  //
3332
- // A listener whose event name is `echo:CHANNEL,EVENT` (or echo-private: /
3333
- // echo-presence:) subscribes to a broadcast channel via the global `window.Echo`
3334
- // (the standard broadcast client the app configures). When the broadcast
3568
+ // A listener whose event name is `socket:CHANNEL,EVENT` (or socket-private: /
3569
+ // socket-presence:) subscribes to a broadcast channel. When the broadcast
3335
3570
  // 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. No-op when
3337
- // `window.Echo` is absent.
3571
+ // so a broadcast updates the component exactly like any other event.
3572
+ //
3573
+ // The client is bundled into this runtime and created on first use, so a page
3574
+ // that declares a listener is live without the app shipping any script. That is
3575
+ // not a convenience: these subscriptions used to be *silently inert* when the
3576
+ // global was missing — no error, no warning — so a live feature with no script
3577
+ // looked exactly like a live feature that was never written. An app that needs a
3578
+ // configured client (a different host, its own auth endpoint) still sets
3579
+ // `window.Socket` itself before the runtime loads, and that one wins.
3580
+
3581
+ /**
3582
+ * The broadcast client: whatever the app published, or one made on demand.
3583
+ *
3584
+ * Constructed on first use rather than at load, so an app with no realtime
3585
+ * feature never opens a second connection. `new Socket()` needs no arguments in
3586
+ * a browser — it derives its URL from `location` and posts to
3587
+ * `/broadcasting/auth` — which is exactly the wiring every app was writing by
3588
+ * hand.
3589
+ */
3590
+ function _socketClient(): SocketClient {
3591
+ const w = window as unknown as { Socket?: SocketClient };
3592
+ return (w.Socket ??= new BundledSocket() as unknown as SocketClient);
3593
+ }
3594
+
3595
+ /**
3596
+ * The client, but only if one already exists.
3597
+ *
3598
+ * Teardown and whisper paths use this: creating a connection in order to leave a
3599
+ * channel, or to whisper on a page that never joined one, would open a socket to
3600
+ * do nothing with it.
3601
+ */
3602
+ function _existingSocketClient(): SocketClient | undefined {
3603
+ return (window as unknown as { Socket?: SocketClient }).Socket;
3604
+ }
3338
3605
 
3339
- type EchoSub = { channel: string; kind: "" | "-private" | "-presence" };
3340
- const _echoSubs = new Map<string, EchoSub[]>();
3606
+ type SocketSub = { channel: string; kind: "" | "-private" | "-presence" };
3607
+ const _socketSubs = new Map<string, SocketSub[]>();
3341
3608
 
3342
- /** Parse `echo[-private|-presence]:channel,event` → parts, or null if not an echo listener. @internal */
3343
- export function _parseEchoListener(
3609
+ /** Parse `socket[-private|-presence]:channel,event` → parts, or null if not a socket listener. @internal */
3610
+ export function _parseSocketListener(
3344
3611
  name: string,
3345
- ): { kind: EchoSub["kind"]; channel: string; event: string } | null {
3346
- const m = /^echo(-private|-presence)?:/.exec(name);
3612
+ ): { kind: SocketSub["kind"]; channel: string; event: string } | null {
3613
+ const m = /^socket(-private|-presence)?:/.exec(name);
3347
3614
  if (!m) return null;
3348
- const kind = (m[1] ?? "") as EchoSub["kind"];
3615
+ const kind = (m[1] ?? "") as SocketSub["kind"];
3349
3616
  const rest = name.slice(m[0].length);
3350
3617
  const ci = rest.lastIndexOf(","); // split on the LAST comma (channels may contain dots, not commas)
3351
3618
  if (ci === -1) return null;
3352
3619
  return { kind, channel: rest.slice(0, ci), event: rest.slice(ci + 1) };
3353
3620
  }
3354
3621
 
3355
- interface EchoChannel {
3356
- listen(event: string, cb: (payload: unknown) => void): EchoChannel;
3357
- here?(cb: (payload: unknown) => void): EchoChannel;
3358
- joining?(cb: (payload: unknown) => void): EchoChannel;
3359
- leaving?(cb: (payload: unknown) => void): EchoChannel;
3622
+ interface SocketChannel {
3623
+ listen(event: string, cb: (payload: unknown) => void): SocketChannel;
3624
+ here?(cb: (payload: unknown) => void): SocketChannel;
3625
+ joining?(cb: (payload: unknown) => void): SocketChannel;
3626
+ leaving?(cb: (payload: unknown) => void): SocketChannel;
3360
3627
  }
3361
- interface EchoClient {
3362
- channel(name: string): EchoChannel;
3363
- private(name: string): EchoChannel;
3364
- join(name: string): EchoChannel;
3628
+ interface SocketClient {
3629
+ channel(name: string): SocketChannel;
3630
+ private(name: string): SocketChannel;
3631
+ join(name: string): SocketChannel;
3365
3632
  leave(name: string): void;
3366
3633
  }
3367
3634
 
3368
- function _setupEchoListeners(comp: FlowComponent): void {
3369
- const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3635
+ function _setupSocketListeners(comp: FlowComponent): void {
3636
+ // The snapshot decides whether this page needs a connection at all — asking
3637
+ // for the client first would open one on every page, including the ones with
3638
+ // nothing to subscribe to.
3370
3639
  const listeners = comp.snapshot.memo.listeners;
3371
- if (!Echo || !listeners) return;
3640
+ if (!listeners) return;
3641
+ const client = _socketClient();
3372
3642
 
3373
- const subs: EchoSub[] = [];
3643
+ const subs: SocketSub[] = [];
3374
3644
  for (const [name, method] of Object.entries(listeners)) {
3375
- const parsed = _parseEchoListener(name);
3645
+ const parsed = _parseSocketListener(name);
3376
3646
  if (!parsed) continue;
3377
3647
  const { kind, channel, event } = parsed;
3378
3648
 
3379
3649
  const ch =
3380
3650
  kind === "-private"
3381
- ? Echo.private(channel)
3651
+ ? client.private(channel)
3382
3652
  : kind === "-presence"
3383
- ? Echo.join(channel)
3384
- : Echo.channel(channel);
3653
+ ? client.join(channel)
3654
+ : client.channel(channel);
3385
3655
 
3386
3656
  const cb = (payload: unknown) => _callAction(comp, method, [payload]);
3387
3657
  // Presence sub-events (here/joining/leaving) use their own methods; everything
@@ -3393,43 +3663,43 @@ function _setupEchoListeners(comp: FlowComponent): void {
3393
3663
  }
3394
3664
  subs.push({ channel, kind });
3395
3665
  }
3396
- if (subs.length) _echoSubs.set(comp.id, subs);
3666
+ if (subs.length) _socketSubs.set(comp.id, subs);
3397
3667
  }
3398
3668
 
3399
- function _teardownEchoListeners(id: string): void {
3400
- const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3401
- const subs = _echoSubs.get(id);
3402
- if (Echo && subs)
3669
+ function _teardownSocketListeners(id: string): void {
3670
+ const client = _existingSocketClient();
3671
+ const subs = _socketSubs.get(id);
3672
+ if (client && subs)
3403
3673
  for (const s of subs) {
3404
3674
  try {
3405
- Echo.leave(s.channel);
3675
+ client.leave(s.channel);
3406
3676
  } catch {
3407
3677
  /* ignore */
3408
3678
  }
3409
3679
  }
3410
- _echoSubs.delete(id);
3680
+ _socketSubs.delete(id);
3411
3681
  }
3412
3682
 
3413
3683
  // ── @presence channels ────────────────────────────────────────────────────────
3414
3684
  // A component with @presence props carries its resolved channel(s) in memo.presence.
3415
3685
  // Join each presence channel and, on here/joining/leaving, dispatch `$presence` so the
3416
3686
  // server refills the member list and re-renders. Cursors/ephemeral state ride the same
3417
- // channel via Echo whispers (see _presenceChannelFor / whisper helpers below).
3687
+ // channel via client whispers (see _presenceChannelFor / whisper helpers below).
3418
3688
 
3419
- interface PresenceEchoChannel extends EchoChannel {
3420
- whisper?(event: string, data: unknown): PresenceEchoChannel;
3421
- listenForWhisper?(event: string, cb: (data: unknown) => void): PresenceEchoChannel;
3689
+ interface PresenceSocketChannel extends SocketChannel {
3690
+ whisper?(event: string, data: unknown): PresenceSocketChannel;
3691
+ listenForWhisper?(event: string, cb: (data: unknown) => void): PresenceSocketChannel;
3422
3692
  }
3423
3693
  const _presenceChannels = new Map<string, string[]>(); // componentId → joined channels
3424
3694
 
3425
3695
  function _setupPresence(comp: FlowComponent): void {
3426
- const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3427
3696
  const presence = comp.snapshot.memo.presence;
3428
- if (!Echo || !presence || presence.length === 0) return;
3697
+ if (!presence || presence.length === 0) return;
3698
+ const client = _socketClient();
3429
3699
 
3430
3700
  const channels: string[] = [];
3431
3701
  for (const { channel } of presence) {
3432
- const ch = Echo.join(channel);
3702
+ const ch = client.join(channel);
3433
3703
  const refresh = (): void => _callAction(comp, "$presence", []);
3434
3704
  ch.here?.(refresh);
3435
3705
  ch.joining?.(refresh);
@@ -3440,12 +3710,12 @@ function _setupPresence(comp: FlowComponent): void {
3440
3710
  }
3441
3711
 
3442
3712
  function _teardownPresence(id: string): void {
3443
- const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3713
+ const client = _existingSocketClient();
3444
3714
  const channels = _presenceChannels.get(id);
3445
- if (Echo && channels)
3715
+ if (client && channels)
3446
3716
  for (const c of channels) {
3447
3717
  try {
3448
- Echo.leave(c);
3718
+ client.leave(c);
3449
3719
  } catch {
3450
3720
  /* ignore */
3451
3721
  }
@@ -3461,21 +3731,21 @@ function _presenceChannelFor(comp: FlowComponent): string | null {
3461
3731
  /**
3462
3732
  * Broadcast ephemeral state (e.g. a cursor position) to the other members of the
3463
3733
  * component's presence channel — client-only, never hits the server or the DB. Pair with
3464
- * `onWhisper`. No-op without `window.Echo` or a presence channel.
3734
+ * `onWhisper`. No-op without `window.client` or a presence channel.
3465
3735
  */
3466
3736
  function _whisper(comp: FlowComponent, event: string, data: unknown): void {
3467
- const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3737
+ const client = _existingSocketClient();
3468
3738
  const channel = _presenceChannelFor(comp);
3469
- if (!Echo || !channel) return;
3470
- (Echo.join(channel) as PresenceEchoChannel).whisper?.(event, data);
3739
+ if (!client || !channel) return;
3740
+ (client.join(channel) as PresenceSocketChannel).whisper?.(event, data);
3471
3741
  }
3472
3742
 
3473
3743
  /** Listen for peers' whispers on the component's presence channel (e.g. cursor moves). */
3474
3744
  function _onWhisper(comp: FlowComponent, event: string, cb: (data: unknown) => void): void {
3475
- const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3745
+ const client = _existingSocketClient();
3476
3746
  const channel = _presenceChannelFor(comp);
3477
- if (!Echo || !channel) return;
3478
- (Echo.join(channel) as PresenceEchoChannel).listenForWhisper?.(event, cb);
3747
+ if (!client || !channel) return;
3748
+ (client.join(channel) as PresenceSocketChannel).listenForWhisper?.(event, cb);
3479
3749
  }
3480
3750
 
3481
3751
  // ── @task cancellation ─────────────────────────────────────────────────────────
@@ -3503,32 +3773,32 @@ function _cancelTask(comp: FlowComponent): void {
3503
3773
  // A component with @shared props carries its resolved channel(s) in memo.shared. Join each
3504
3774
  // channel and, on the `flow:shared` broadcast, dispatch `$shared` so the server re-reads
3505
3775
  // the room store and re-renders — every subscriber converges to the same state. Shared and
3506
- // presence channels are joined the same way (Echo.join), so one room channel can carry both.
3776
+ // presence channels are joined the same way (client.join), so one room channel can carry both.
3507
3777
 
3508
3778
  const SHARED_EVENT = "flow:shared";
3509
3779
  const _sharedChannels = new Map<string, string[]>(); // componentId → joined channels
3510
3780
 
3511
3781
  function _setupShared(comp: FlowComponent): void {
3512
- const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3513
3782
  const shared = comp.snapshot.memo.shared;
3514
- if (!Echo || !shared || shared.length === 0) return;
3783
+ if (!shared || shared.length === 0) return;
3784
+ const client = _socketClient();
3515
3785
 
3516
3786
  const channels: string[] = [];
3517
3787
  for (const { channel } of shared) {
3518
3788
  const converge = (): void => _callAction(comp, "$shared", []);
3519
- Echo.join(channel).listen(SHARED_EVENT, converge);
3789
+ client.join(channel).listen(SHARED_EVENT, converge);
3520
3790
  channels.push(channel);
3521
3791
  }
3522
3792
  if (channels.length) _sharedChannels.set(comp.id, channels);
3523
3793
  }
3524
3794
 
3525
3795
  function _teardownShared(id: string): void {
3526
- const Echo = (window as unknown as { Echo?: EchoClient }).Echo;
3796
+ const client = _existingSocketClient();
3527
3797
  const channels = _sharedChannels.get(id);
3528
- if (Echo && channels)
3798
+ if (client && channels)
3529
3799
  for (const c of channels) {
3530
3800
  try {
3531
- Echo.leave(c);
3801
+ client.leave(c);
3532
3802
  } catch {
3533
3803
  /* ignore */
3534
3804
  }