sygnal 7.0.0 → 7.1.0

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/src/extra/ssr.ts CHANGED
@@ -147,7 +147,7 @@ function withUses(def: any, state: any): any {
147
147
  * state reads from the cache ('success' with the cached data), else 'loading' ('idle' for a
148
148
  * falsy request), as the client's first paint does with the same (hydrated) cache
149
149
  */
150
- function withResources(def: any, state: any): any {
150
+ function withResources(def: any, state: any, ctx?: any): any {
151
151
  state = withUses(def, state)
152
152
  const res = def && def.resources
153
153
  if (!res || !state || typeof state !== 'object' || Array.isArray(state)) return state
@@ -155,7 +155,7 @@ function withResources(def: any, state: any): any {
155
155
  for (const n of Object.keys(res)) {
156
156
  if (out[n] !== undefined) continue
157
157
  let q: any
158
- try { q = typeof res[n] === 'function' ? res[n](state) : res[n] } catch (_) {}
158
+ try { q = typeof res[n] === 'function' ? res[n](state, ctx) : res[n] } catch (_) {}
159
159
  const E = q && ssrCache ? ssrCache.get(ssrCache.key(q, 1)) : undefined
160
160
  out[n] = E && E.at > 0 ? {status: 'success', data: E.v} : {status: q ? 'loading' : 'idle'}
161
161
  }
@@ -198,10 +198,26 @@ function viewFailed(def: any, err: any, name: string): any {
198
198
  return vnode
199
199
  }
200
200
 
201
- function collectHead(def: any, state: any): void {
201
+ function collectHead(def: any, state: any, ctx?: any): void {
202
202
  const h = def && def.head
203
203
  if (!heads || !h) return
204
- try { heads.push(typeof h === 'function' ? h(state) : h) } catch (_) {}
204
+ try { heads.push(typeof h === 'function' ? h(state, ctx) : h) } catch (_) {}
205
+ }
206
+
207
+ /**
208
+ * PLAN-13 F-1: the context a component sees (its parent's, plus its own entries computed on
209
+ * `state`). The statics get it as on the client; for `resources` it is computed on the state
210
+ * before the resources are filled in (a context entry can't read the component's own resource)
211
+ */
212
+ function contextOf(def: any, state: any, parent: Record<string, any>): Record<string, any> {
213
+ const own = def.context || {}, out: Record<string, any> = {...parent}
214
+ for (const key of Object.keys(own)) {
215
+ const f = own[key]
216
+ if (typeof f === 'function' && state != null) {
217
+ try { out[key] = f(state) } catch (_) {}
218
+ }
219
+ }
220
+ return out
205
221
  }
206
222
 
207
223
  /**
@@ -245,20 +261,11 @@ function renderRoot(componentDef: any, options: RenderToStringOptions): string {
245
261
 
246
262
  const ownState = state !== undefined ? state : componentDef.initialState
247
263
  // 5-5: the view sees its resources; the hydration script keeps the state as it was given
248
- const resolvedState = withResources(componentDef, ownState)
249
- collectHead(componentDef, resolvedState)
264
+ const resolvedState = withResources(componentDef, ownState, contextOf(componentDef, ownState, context))
250
265
 
251
266
  // Build context: merge parent context with component's own context definitions
252
- const componentContext = componentDef.context || {}
253
- const mergedContext: Record<string, any> = {...context}
254
- for (const key of Object.keys(componentContext)) {
255
- const contextFn = componentContext[key]
256
- if (typeof contextFn === 'function' && resolvedState != null) {
257
- try {
258
- mergedContext[key] = contextFn(resolvedState)
259
- } catch (_) {}
260
- }
261
- }
267
+ const mergedContext = contextOf(componentDef, resolvedState, context)
268
+ collectHead(componentDef, resolvedState, mergedContext)
262
269
 
263
270
  // Call the view function
264
271
  let vnode: any
@@ -474,19 +481,10 @@ function renderSubComponent(vnode: any, context: Record<string, any>, parentStat
474
481
  // State passed directly
475
482
  childState = stateProp
476
483
  }
477
- childState = withResources(componentDef, childState)
484
+ childState = withResources(componentDef, childState, contextOf(componentDef, childState, context))
478
485
 
479
486
  // Build child context
480
- const childContext: Record<string, any> = {...context}
481
- const componentContext = componentDef.context || {}
482
- for (const key of Object.keys(componentContext)) {
483
- const contextFn = componentContext[key]
484
- if (typeof contextFn === 'function' && childState != null) {
485
- try {
486
- childContext[key] = contextFn(childState)
487
- } catch (_) {}
488
- }
489
- }
487
+ const childContext = contextOf(componentDef, childState, context)
490
488
 
491
489
  // Extract slots from children
492
490
  const slots: Record<string, any[]> = {}
@@ -507,7 +505,7 @@ function renderSubComponent(vnode: any, context: Record<string, any>, parentStat
507
505
  slots.default.push(...defaultSlotChildren)
508
506
  }
509
507
 
510
- collectHead(componentDef, childState)
508
+ collectHead(componentDef, childState, childContext)
511
509
 
512
510
  // Call the view function
513
511
  let result: any
@@ -713,20 +711,11 @@ function renderSwitchable(vnode: any, context: Record<string, any>, parentState:
713
711
  * Internal helper: render a component def to a VNode (not HTML string).
714
712
  */
715
713
  function renderToStringInternal(componentDef: any, state: any, context: Record<string, any>, uid: string): any {
716
- const resolvedState = withResources(componentDef, state !== undefined ? state : componentDef.initialState)
717
-
718
- const componentContext = componentDef.context || {}
719
- const mergedContext: Record<string, any> = {...context}
720
- for (const key of Object.keys(componentContext)) {
721
- const contextFn = componentContext[key]
722
- if (typeof contextFn === 'function' && resolvedState != null) {
723
- try {
724
- mergedContext[key] = contextFn(resolvedState)
725
- } catch (_) {}
726
- }
727
- }
714
+ const own = state !== undefined ? state : componentDef.initialState
715
+ const resolvedState = withResources(componentDef, own, contextOf(componentDef, own, context))
716
+ const mergedContext = contextOf(componentDef, resolvedState, context)
728
717
 
729
- collectHead(componentDef, resolvedState)
718
+ collectHead(componentDef, resolvedState, mergedContext)
730
719
  let vnode: any
731
720
  try {
732
721
  vnode = componentDef({
@@ -5,6 +5,7 @@ import {enrichEventStream} from '../cycle/dom/enrichEventStream';
5
5
  import eventBusDriver from './eventDriver';
6
6
  import logDriver from './logDriver';
7
7
  import {ownedCopy} from './owned';
8
+ import {isAbort} from '../shared';
8
9
  import {renderToInnerHtml} from './ssr';
9
10
  import {_getDiagnosticsConfig, configureDiagnostics, getDiagnosticsMode, isDiagnosticsEnabled, onDiagnostic, report} from './diagnostics/index';
10
11
  import xs from './xstreamCompat';
@@ -496,9 +497,10 @@ export interface RenderResult {
496
497
  open: (sinkName: string, target?: FakeConnectionTarget) => Promise<void>;
497
498
  /**
498
499
  * PLAN-3 2-C: the server sends `data` (objects as JSON text) on the open connection(s): `message`
499
- * fires (the data JSON-parsed when it parses). `{ event, connection? }`: an SSE named event.
500
+ * fires (the data JSON-parsed when it parses). `{ event, connection? }`: an SSE named event;
501
+ * `{ id }` (PLAN-13 S-1): its SSE event id (`meta`, `resume`).
500
502
  */
501
- push: (sinkName: string, data: any, target?: FakeConnectionTarget | {event?: string; connection?: FakeConnectionTarget}) => Promise<void>;
503
+ push: (sinkName: string, data: any, target?: FakeConnectionTarget | {event?: string; id?: string | number; connection?: FakeConnectionTarget}) => Promise<void>;
502
504
  /**
503
505
  * PLAN-3 2-C: the connection(s) close without the app closing them (a connecting one fails to
504
506
  * open: `error` first): `close` fires with `{ code, reason, willReconnect }` and the fake
@@ -1273,17 +1275,27 @@ const sockKey = (sse: boolean, url: string, arg: any) => (sse ? 'e' + !!(arg &&
1273
1275
  * PLAN-3 2-C: the in-memory WebSocket / EventSource the fake's makeSocketDriver opens. The
1274
1276
  * harness drives it (t.open / t.push / t.drop); the driver's own close() is the app's close.
1275
1277
  */
1278
+ /** `url` without its query parameter `name` (PLAN-13 S-1: a resumed reconnect's id parameter) */
1279
+ const withoutParam = (url: string, name: string) =>
1280
+ url.replace(new RegExp('([?&])' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=[^&#]*&?'), '$1').replace(/[?&](#|$)/, '$1');
1276
1281
  const fakeSocketClass = (sse: boolean, made: (s: any) => void) => class {
1277
1282
  readyState = 0;
1278
1283
  sse = sse;
1279
1284
  byApp = false;
1280
1285
  k: string;
1286
+ rn?: string;
1281
1287
  url: string;
1282
1288
  ls: Record<string, any[]> = {};
1283
1289
  onopen: any; onmessage: any; onerror: any; onclose: any;
1284
1290
  constructor(url: string, arg?: any) {
1285
1291
  this.url = String(url);
1286
- this.k = sockKey(sse, this.url, arg);
1292
+ // PLAN-13 S-1: a resumed SSE reconnect's URL has the id parameter (added, or replacing the
1293
+ // declared one): its key leaves that parameter out (`rn`: its name; made() compares the
1294
+ // connections' URLs without it too)
1295
+ const id = sse && arg?.lastEventId;
1296
+ const m = id && new RegExp('[?&]([^?&=]+)=' + encodeURIComponent(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=&|#|$)').exec(this.url);
1297
+ if (m) this.rn = m[1];
1298
+ this.k = sockKey(sse, m ? withoutParam(this.url, m[1]) : this.url, arg);
1287
1299
  made(this);
1288
1300
  }
1289
1301
  send() {}
@@ -1851,7 +1863,7 @@ export function renderComponent(
1851
1863
  const w: Sock = {src: null, in$: xs.create(), conns: new Map(), sockets: [], sent: [], declaring: null};
1852
1864
  const made = (s: any) => {
1853
1865
  w.sockets.push(s);
1854
- const all = [...w.conns.values()].flatMap(m => [...m.values()]).filter(c => c.k == s.k);
1866
+ const all = [...w.conns.values()].flatMap(m => [...m.values()]).filter(c => (s.rn ? sockKey(true, withoutParam(c.url, s.rn), c.spec) : c.k) == s.k);
1855
1867
  if (w.declaring) {
1856
1868
  // a declaration opened it: the first new connection with its key (and, shared, the
1857
1869
  // other new shared ones)
@@ -1952,7 +1964,7 @@ export function renderComponent(
1952
1964
  for (const k in uses) for (const a in uses[k]?.model || {}) names.push(k + '.' + a);
1953
1965
  // G-275: the caller's initialState is owned (the dev statics freeze leaves it alone). G-289: a
1954
1966
  // copy is marked, not the caller's object
1955
- const init = initialState !== undefined ? ownedCopy(initialState) : componentDef.initialState;
1967
+ let init = initialState !== undefined ? ownedCopy(initialState) : componentDef.initialState;
1956
1968
  // G-028: with no intent, model or initialState nothing would ever emit state, so the view
1957
1969
  // never renders. Leave intent/model unset so the component falls back to the same no-op
1958
1970
  // model run() uses, and renders.
@@ -2213,6 +2225,17 @@ export function renderComponent(
2213
2225
  },
2214
2226
  };
2215
2227
  };
2228
+ // PLAN-13 F-2: a root that declares `route` starts from the test URL's route: its initialState's
2229
+ // router.current() ran at module load, before the test's URL existed. The route entry's STATE
2230
+ // reducer is applied to the initial state (its other sinks, a guard's redirect, run once when the
2231
+ // router answers the declaration), so the first render and the statics see the page the test
2232
+ // starts on: no render of '/' and no request for it
2233
+ const re = rt && typeof componentDef.route == 'string' && model[componentDef.route]
2234
+ const seed = typeof re == 'function' ? re : re && typeof re == 'object' && typeof re.STATE == 'function' && re.STATE
2235
+ if (seed && init !== undefined) {
2236
+ const v = seed(init, rt!.r.current(), noop, {state: init, previousState: init})
2237
+ if (v !== undefined && !isAbort(v)) init = v
2238
+ }
2216
2239
  let sources: any, sinks: any;
2217
2240
  try {
2218
2241
  // the root runs with the test intent, model, initial state and name, and the drivers above.
@@ -2848,11 +2871,13 @@ export function renderComponent(
2848
2871
  };
2849
2872
  const open = (name: string, target?: FakeConnectionTarget) => sockCall('open', name, target, [0], sockOpen);
2850
2873
  const push = (name: string, data: any, target?: any) => {
2851
- const o = target && typeof target == 'object' && !Array.isArray(target) && Object.keys(target).length && Object.keys(target).every(k => k == 'event' || k == 'connection') ? target : {connection: target};
2874
+ const o = target && typeof target == 'object' && !Array.isArray(target) && Object.keys(target).length && Object.keys(target).every(k => k == 'event' || k == 'connection' || k == 'id') ? target : {connection: target};
2852
2875
  const raw = typeof data == 'string' || (data && typeof data == 'object' && (data instanceof ArrayBuffer || ArrayBuffer.isView(data) || (typeof Blob != 'undefined' && data instanceof Blob))) ? data : JSON.stringify(data);
2853
2876
  const ev = o.event;
2854
2877
  return sockCall('push', name, o.connection, [1], s => {
2855
- const m = {type: ev || 'message', data: raw};
2878
+ // PLAN-13 S-1: `id` sets the event id; as in EventSource, lastEventId keeps the last one
2879
+ if (s.sse && o.id != null) s.lastId = String(o.id);
2880
+ const m: any = {type: ev || 'message', data: raw, ...(s.sse && {lastEventId: s.lastId ?? ''})};
2856
2881
  if (!ev || ev == 'message') sockFire(s, 'message', m);
2857
2882
  if (s.sse) (s.ls[ev || 'message'] || []).forEach((f: any) => { try { f(m); } catch (e) { console.error(e); } });
2858
2883
  });
@@ -13,7 +13,8 @@ import {makeReplies} from './replies';
13
13
  * falsy one stops, a changed spec restarts (an equal one keeps running).
14
14
  * - `every`: drift-free; tick n is due at start + n * every (each delay is computed from the
15
15
  * start, not chained). A late tick (a throttled background tab) coalesces the missed ones:
16
- * n jumps. Data `{ n, t }`: the tick number from 1, `t` = Date.now().
16
+ * n jumps. A delay is at most `every`, so a frozen Date.now() (a test clock that fixes the time
17
+ * while timers run) ticks once per interval. Data `{ n, t }`: the tick number from 1, `t` = Date.now().
17
18
  * - `after`: fires once, data `{ t }`. It doesn't fire again while the same spec stays declared.
18
19
  * - `frame`: every animation frame (requestAnimationFrame; setTimeout 16 ms without it), data
19
20
  * `{ t, dt }`: Date.now() and the ms since the previous frame (0 on the first).
@@ -47,7 +48,9 @@ const start = (s: any, send: (type: string, data: any) => void, e: any): (() =>
47
48
  last = ts;
48
49
  } else if (every) {
49
50
  n = Math.max(n + 1, Math.floor((t - t0) / every));
50
- later(t0 + (n + 1) * every - t);
51
+ // never more than one interval: under a frozen Date.now() (Playwright's setFixedTime: Date is
52
+ // fixed, timers run) the drift-free delay grew by one interval per tick
53
+ later(Math.min(every, t0 + (n + 1) * every - t));
51
54
  send(s.action, {n, t});
52
55
  } else {
53
56
  e.d = 1;
package/src/index.d.ts CHANGED
@@ -77,8 +77,11 @@ export type StateProp = string | Lense<any, any>
77
77
  */
78
78
  export type ElementProps<PROPS> =
79
79
  0 extends (1 & PROPS) ? PROPS
80
- : 'state' extends keyof PROPS ? WithoutViewOnlyProps<PROPS> & { state?: StateProp; resetState?: boolean }
81
- : PROPS
80
+ // PLAN-13 F-3: Switchable's own `state` (a per-page map too) is kept as it declares it
81
+ : PROPS extends { of: Record<string, any>; current: string } ? PROPS
82
+ : WithStateProp<PROPS>
83
+
84
+ type WithStateProp<PROPS> = 'state' extends keyof PROPS ? WithoutViewOnlyProps<PROPS> & { state?: StateProp; resetState?: boolean } : PROPS
82
85
 
83
86
  /** PROPS without `state`, `context`, `slots` and `uid` (keeps optionality and index signatures, unlike Omit). */
84
87
  type WithoutViewOnlyProps<PROPS> = {
@@ -1568,10 +1571,12 @@ export type Component<
1568
1571
  * actions on this instance; send with `{ to: 'room', json }` from a model entry (a connection
1569
1572
  * the same action opens or changes is declared first). Dispose closes them.
1570
1573
  */
1571
- connections?: (state: STATE & CALCULATED) => Connections;
1574
+ connections?: (state: STATE & CALCULATED, context: CONTEXT) => Connections;
1572
1575
  /**
1573
1576
  * PLAN-3: declarative reads (`makeFetchDriver()`). Each entry derives a
1574
- * request from state; falsy means idle:
1577
+ * request from state (and, PLAN-13 F-1, the context as its second argument, as every
1578
+ * declaration static: `(state, { route }) => '/api/items/' + route.params.id`; re-run when the
1579
+ * context changes); falsy means idle:
1575
1580
  * `Quote.resources = { quote: (state) => state.id && '/api/quotes/' + state.id }`.
1576
1581
  * `state.quote` is a `Resource`: `{ status: 'idle' | 'loading' | 'success' | 'error', data,
1577
1582
  * error, refreshing }`, written by the built-in RESOURCE action (idle until the first request).
@@ -1581,7 +1586,7 @@ export type Component<
1581
1586
  * `data`, `error` and `status` and sets `refreshing: true` (D78). `ok` / `error` on the
1582
1587
  * request also dispatch those actions after the write.
1583
1588
  */
1584
- resources?: { [name: string]: (state: STATE & CALCULATED) => ResourceRequest | false | null | undefined | '' | 0 };
1589
+ resources?: { [name: string]: (state: STATE & CALCULATED, context: CONTEXT) => ResourceRequest | false | null | undefined | '' | 0 };
1585
1590
  /**
1586
1591
  * The router's reply action (`makeRouter()`): `App.route = 'ROUTE'`. The driver sends this
1587
1592
  * instance ROUTE with the `Route` (`{ name, params, query, hash, path }`) once declared and on
@@ -1603,7 +1608,7 @@ export type Component<
1603
1608
  * `renderToString(App, { head: list })` for SSR (`renderHead(list)`). Like every declaration
1604
1609
  * static, it is sent with or without a model; a root needs `initialState` (SYG132).
1605
1610
  */
1606
- head?: (state: STATE & CALCULATED) => HeadValue | false | null | undefined;
1611
+ head?: (state: STATE & CALCULATED, context: CONTEXT) => HeadValue | false | null | undefined;
1607
1612
  /**
1608
1613
  * PLAN-4 GS-1: behaviors this component uses, each under its state key:
1609
1614
  * `TaskList.uses = { pager: pager({ pageSize: 10, next: Newer, prev: Older }) }` gives
@@ -1620,7 +1625,7 @@ export type Component<
1620
1625
  * fires once (`{ t }`), `frame` runs every animation frame (`{ t, dt }`). A hidden Switchable
1621
1626
  * page's timers stop unless `background: true`; dispose stops them; nothing runs during SSR.
1622
1627
  */
1623
- timers?: (state: STATE & CALCULATED) => Timers<ActionNameOf<ACTIONS>>;
1628
+ timers?: (state: STATE & CALCULATED, context: CONTEXT) => Timers<ActionNameOf<ACTIONS>>;
1624
1629
  /**
1625
1630
  * PLAN-5 B-3: browser sources derived from state, run by `makeBrowserDriver()` (registered:
1626
1631
  * `run(App, { BROWSER: makeBrowserDriver() })`; renderComponent provides a fake, `t.browser`)
@@ -1630,7 +1635,7 @@ export type Component<
1630
1635
  * restarts. A hidden Switchable page's sources stop unless `background: true`; dispose stops
1631
1636
  * them; nothing runs during SSR.
1632
1637
  */
1633
- browser?: (state: STATE & CALCULATED) => BrowserSources<ActionNameOf<ACTIONS>>;
1638
+ browser?: (state: STATE & CALCULATED, context: CONTEXT) => BrowserSources<ActionNameOf<ACTIONS>>;
1634
1639
  /**
1635
1640
  * PLAN-4 GS-5: save this root component's state and restore it at startup:
1636
1641
  * `TodoApp.persist = persist({ key: 'todo-app', pick: ['todos', 'filter'] })`. `pick` / `omit`
@@ -1753,8 +1758,8 @@ export type VirtualCollectionProps<PROPS = any, STATE = any> = {
1753
1758
  /** Where `scrollToIndex` / `scrollToId` put the row: 'auto' (default) scrolls only when it isn't in view */
1754
1759
  export type ScrollToAlign = 'auto' | 'start' | 'center' | 'end'
1755
1760
 
1756
- export type SwitchableProps<PROPS = any> = {
1757
- of: Record<string, AnyComponent>;
1761
+ export type SwitchableProps<PROPS = any, OF extends Record<string, AnyComponent> = Record<string, AnyComponent>> = {
1762
+ of: OF;
1758
1763
  current: string;
1759
1764
  /**
1760
1765
  * The current page's instance key. When it changes, the current page is disposed and created
@@ -1762,7 +1767,14 @@ export type SwitchableProps<PROPS = any> = {
1762
1767
  * show. Switching `current` alone keeps pages alive. Router recipe: `instance={state.route.path}`
1763
1768
  */
1764
1769
  instance?: string | number;
1765
- state?: string | Lense;
1770
+ /**
1771
+ * The pages' state: a key or path (`'pages'`), a lens, or (PLAN-13 F-3) a map from page names to
1772
+ * those (`{ projects: 'pages.projects', settings: byId('teams', state.teamId) }`): each listed
1773
+ * page binds its own target with a tag child's lifetime rules (another key or id makes that page
1774
+ * again, a deleted target disposes it), and the pages not listed share the owner's state. A key
1775
+ * that names no page is SYG429
1776
+ */
1777
+ state?: string | Lense | { [PAGE in keyof NoInfer<OF>]?: string | Lense };
1766
1778
  } & Omit<PROPS, 'of' | 'state' | 'current' | 'instance'>
1767
1779
 
1768
1780
  export type PortalProps = {
@@ -2261,7 +2273,7 @@ export function Collection<PROPS extends { [prop: string]: any }, STATE = any>(p
2261
2273
  * <VirtualCollection of={Row} from="rows" className="rows" estimateSize={32} />
2262
2274
  */
2263
2275
  export function VirtualCollection<PROPS extends { [prop: string]: any }, STATE = any>(props: VirtualCollectionProps<PROPS, STATE>): JSX.Element
2264
- export function Switchable<PROPS extends { [prop: string]: any }>(props: SwitchableProps<PROPS>): JSX.Element
2276
+ export function Switchable<PROPS extends { [prop: string]: any }, OF extends Record<string, AnyComponent> = Record<string, AnyComponent>>(props: SwitchableProps<PROPS, OF>): JSX.Element
2265
2277
  export function Portal(props: PortalProps): JSX.Element
2266
2278
  export function Transition(props: TransitionProps): JSX.Element
2267
2279
  export function Suspense(props: SuspenseProps): JSX.Element
@@ -2322,6 +2334,48 @@ export type ReplyRequest = {
2322
2334
  catch?: never;
2323
2335
  }
2324
2336
 
2337
+ declare const replySenderBrand: unique symbol
2338
+ /**
2339
+ * The component instance that sent a request to a driver (opaque). `senderOf(request)` reads it;
2340
+ * pass it to `reply()` to answer that instance with one of its actions.
2341
+ */
2342
+ export type ReplySender = { readonly [replySenderBrand]: true }
2343
+
2344
+ /** What makeReplies() returns: the source part that marks the driver, and the way to answer */
2345
+ export type ReplyChannel = {
2346
+ /**
2347
+ * Spread into the driver's source (`return { ...replies, select }`): the core then stamps each
2348
+ * request a component sends with its sender and delivers `reply()`s as that instance's actions.
2349
+ */
2350
+ replies: { readonly __sygnalReplies: true; replies(sender: ReplySender): Stream<{ type: string; data: any }> };
2351
+ /** Dispatch `type` with `data` on the sender (dropped when it is gone). Call it as often as needed */
2352
+ reply(sender: ReplySender, type: string, data?: any): void;
2353
+ /** Whether the sender is still mounted (a reply would reach it) */
2354
+ listening(sender: ReplySender): boolean;
2355
+ }
2356
+
2357
+ /**
2358
+ * PLAN-13 D-1: reply actions for a driver you write (the protocol makeFetchDriver,
2359
+ * driverFromAsync and the socket driver use). `onStop(sender)` runs when a sender is disposed:
2360
+ * abort what it still has in flight.
2361
+ *
2362
+ * const { replies, reply } = makeReplies((sender) => inflight.get(sender)?.abort())
2363
+ * sink$.addListener({ next: (req) => {
2364
+ * const sender = senderOf(req)
2365
+ * if (sender === undefined) return // not sent by a component
2366
+ * run(req, (f) => req.progress && reply(sender, req.progress, f))
2367
+ * .then((v) => req.ok && reply(sender, req.ok, v), (error) => req.error && reply(sender, req.error, { error, request: req }))
2368
+ * } })
2369
+ * return { ...replies }
2370
+ *
2371
+ * A name with no model entry on the sender is SYG112 in the dev entry (ok / error / progress
2372
+ * statically). Guide: https://sygnal.js.org/guide/custom-drivers/
2373
+ */
2374
+ export function makeReplies(onStop?: (sender: ReplySender) => void): ReplyChannel
2375
+
2376
+ /** The component instance that sent `request` to a driver with reply actions, or undefined (not sent by a component) */
2377
+ export function senderOf(request: object): ReplySender | undefined
2378
+
2325
2379
  /**
2326
2380
  * A request to a driverFromAsync() sink: your own fields (`value`, the args, ...) plus the
2327
2381
  * reply-action keys `ok` / `error`. Type the driver's sink with it: `{ QUOTE: { source:
@@ -2797,6 +2851,15 @@ export type SocketActions = {
2797
2851
  * hidden page's connections close, and open again as new ones when it is shown)
2798
2852
  */
2799
2853
  background?: boolean;
2854
+ /**
2855
+ * PLAN-13 S-1: a connection that stays silent this long (ms) is taken as dead (a half-open
2856
+ * socket behind a proxy): it is closed as a drop, `close` gets `{ stale: true, willReconnect }`,
2857
+ * and the reconnect policy applies. Every event counts; over fetch (an `sse` with `headers`)
2858
+ * any bytes do, heartbeat comments (`: ping`) included, while the native EventSource needs a
2859
+ * real event (a `message`, or a named event listed in `events`). Set it above the server's
2860
+ * heartbeat interval
2861
+ */
2862
+ staleAfterMs?: number;
2800
2863
  /** Not allowed: a `then` key makes the value a thenable (SYG610). Use `message` / `open` */
2801
2864
  then?: never;
2802
2865
  catch?: never;
@@ -2823,6 +2886,29 @@ export type SseConnection = SocketActions & {
2823
2886
  headers?: Record<string, string>;
2824
2887
  /** Named events → actions: `{ 'price-update': 'PRICE' }` (data JSON-parsed when it parses) */
2825
2888
  events?: Record<string, string>;
2889
+ /**
2890
+ * PLAN-13 S-1: the `message` and `events` actions get `SseEvent` (`{ data, id, event }`: the
2891
+ * event id and type) instead of the data alone
2892
+ */
2893
+ meta?: boolean;
2894
+ /**
2895
+ * PLAN-13 S-1: resume after a reconnect the driver makes (EventSource gave up: a proxy 502, a
2896
+ * non-2xx on the `headers` path) from the last event id. Over fetch (`headers`) it is sent as
2897
+ * `Last-Event-ID`; the native EventSource can't send it, so it goes on the URL:
2898
+ * `?lastEventId=<id>` (`true`) or `?<name>=<id>` (a parameter name). EventSource's own retries
2899
+ * send `Last-Event-ID` anyway
2900
+ */
2901
+ resume?: true | string;
2902
+ }
2903
+
2904
+ /** PLAN-13 S-1: an SSE event as a `meta: true` connection's actions get it */
2905
+ export type SseEvent<DATA = any> = {
2906
+ /** The data, JSON-parsed when it parses */
2907
+ data: DATA;
2908
+ /** The event's `id:` (the last one sent when this event has none; '' when there was none) */
2909
+ id: string;
2910
+ /** The event type: 'message', or the named event's name */
2911
+ event: string;
2826
2912
  }
2827
2913
 
2828
2914
  /**
@@ -2848,7 +2934,7 @@ export type SocketRequest =
2848
2934
  /** Data of a connection's `open` action */
2849
2935
  export type SocketOpen = { reconnected: boolean }
2850
2936
  /** Data of a connection's `close` action (SSE: no code / reason) */
2851
- export type SocketClose = { code?: number; reason?: string; willReconnect: boolean }
2937
+ export type SocketClose = { code?: number; reason?: string; willReconnect: boolean; /** PLAN-13 S-1: closed by staleAfterMs (nothing arrived) */ stale?: true }
2852
2938
  /** Data of a connection's `error` action: the error Event (or the constructor's exception) */
2853
2939
  export type SocketError = { error: any }
2854
2940
 
@@ -3670,9 +3756,10 @@ export interface RenderResult<STATE = any> {
3670
3756
  open: (sinkName: string, target?: FakeConnectionTarget) => Promise<void>;
3671
3757
  /**
3672
3758
  * The server sends `data` (objects as JSON text) on open connection(s): `message` fires with
3673
- * the data, JSON-parsed when it parses. `{ event, connection? }` sends an SSE named event.
3759
+ * the data, JSON-parsed when it parses. `{ event, connection? }` sends an SSE named event;
3760
+ * `{ id }` gives an SSE event its id (what `meta: true` reports and `resume` resumes from).
3674
3761
  */
3675
- push: (sinkName: string, data: any, target?: FakeConnectionTarget | { event?: string; connection?: FakeConnectionTarget }) => Promise<void>;
3762
+ push: (sinkName: string, data: any, target?: FakeConnectionTarget | { event?: string; id?: string | number; connection?: FakeConnectionTarget }) => Promise<void>;
3676
3763
  /**
3677
3764
  * Connection(s) close without the app closing them: `close` fires with `{ code, reason,
3678
3765
  * willReconnect }` (default `{ code: 1006, reason: '' }`; a connecting one fails to open: `error`
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@ export { Suspense } from "./suspense"
12
12
  export { Slot } from "./slot"
13
13
  export { lazy } from "./lazy"
14
14
  export { driverFromAsync } from "./extra/driverFactories"
15
+ // PLAN-13 D-1: the reply-action protocol, for drivers written by hand
16
+ export { makeReplies, senderOf } from './extra/replies'
15
17
  export { makeFetchDriver } from "./extra/fetchDriver"
16
18
  export { queryCache } from "./extra/queryCache"
17
19
  export { makeSocketDriver } from "./extra/socketDriver"