sygnal 6.5.0 → 7.0.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +2 -0
  3. package/dist/astro/index.cjs.js +423 -27
  4. package/dist/astro/index.cjs.js.map +1 -1
  5. package/dist/astro/index.mjs +423 -27
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs.map +1 -1
  9. package/dist/devtools.cjs.js +141 -3
  10. package/dist/devtools.cjs.js.map +1 -1
  11. package/dist/devtools.esm.js +141 -4
  12. package/dist/devtools.esm.js.map +1 -1
  13. package/dist/diagnostics.cjs.js +169 -14
  14. package/dist/diagnostics.cjs.js.map +1 -1
  15. package/dist/diagnostics.esm.js +168 -15
  16. package/dist/diagnostics.esm.js.map +1 -1
  17. package/dist/guide/accessibility.md +2 -0
  18. package/dist/guide/ai-chat.md +6 -6
  19. package/dist/guide/behaviors.md +1 -1
  20. package/dist/guide/element-commands.md +4 -2
  21. package/dist/guide/forms-reference.md +89 -3
  22. package/dist/guide/forms.md +8 -8
  23. package/dist/guide/http.md +77 -7
  24. package/dist/guide/migrating-to-7.md +314 -0
  25. package/dist/guide/recipes/ai-summarize.md +2 -1
  26. package/dist/guide/recipes/i18n.md +1 -1
  27. package/dist/guide/ui/tabs.md +5 -4
  28. package/dist/guide/ui/tooltip.md +24 -0
  29. package/dist/index.cjs.js +781 -320
  30. package/dist/index.cjs.js.map +1 -1
  31. package/dist/index.d.ts +319 -43
  32. package/dist/index.esm.js +781 -320
  33. package/dist/index.esm.js.map +1 -1
  34. package/dist/sygnal.min.js +1 -1
  35. package/dist/sygnal.min.js.map +1 -1
  36. package/dist/ui.cjs.js +96 -11
  37. package/dist/ui.cjs.js.map +1 -1
  38. package/dist/ui.esm.js +96 -12
  39. package/dist/ui.esm.js.map +1 -1
  40. package/dist/vike/config/package.json +1 -1
  41. package/dist/vike/onRenderClient.cjs.js +63 -43
  42. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  43. package/dist/vike/onRenderClient.mjs +63 -43
  44. package/dist/vike/onRenderClient.mjs.map +1 -1
  45. package/dist/vike/onRenderHtml.cjs.js +21 -25
  46. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  47. package/dist/vike/onRenderHtml.mjs +21 -25
  48. package/dist/vike/onRenderHtml.mjs.map +1 -1
  49. package/dist/vite/plugin.cjs.js +410 -27
  50. package/dist/vite/plugin.cjs.js.map +1 -1
  51. package/dist/vite/plugin.mjs +410 -27
  52. package/dist/vite/plugin.mjs.map +1 -1
  53. package/llms.txt +17 -16
  54. package/package.json +1 -1
  55. package/src/astro/index.d.ts +8 -0
  56. package/src/astro/index.ts +18 -0
  57. package/src/core/actions.ts +28 -21
  58. package/src/core/define.ts +2 -0
  59. package/src/core/hooks.ts +8 -2
  60. package/src/core/hosts/collection.ts +4 -3
  61. package/src/core/instance.ts +60 -16
  62. package/src/core/runtime.ts +5 -1
  63. package/src/core/view.ts +2 -0
  64. package/src/cycle/dom/utils.ts +4 -2
  65. package/src/devtools.d.ts +13 -0
  66. package/src/devtools.ts +3 -0
  67. package/src/extra/ai/chat/behavior.ts +3 -3
  68. package/src/extra/ai/commandBar.ts +3 -3
  69. package/src/extra/ai/compose/behavior.ts +3 -3
  70. package/src/extra/ai/transports/shared.ts +8 -2
  71. package/src/extra/behaviors.ts +21 -0
  72. package/src/extra/devGraph.ts +111 -0
  73. package/src/extra/devMcp.ts +1 -1
  74. package/src/extra/devtools.ts +18 -0
  75. package/src/extra/diagnostics/checks/fetch.ts +21 -1
  76. package/src/extra/diagnostics/checks/forms.ts +4 -3
  77. package/src/extra/diagnostics/checks/index.ts +2 -1
  78. package/src/extra/diagnostics/checks/inspect.ts +68 -6
  79. package/src/extra/diagnostics/checks/next.ts +3 -0
  80. package/src/extra/diagnostics/checks/public.d.ts +133 -12
  81. package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
  82. package/src/extra/diagnostics/codes.ts +19 -0
  83. package/src/extra/fetchDriver.ts +112 -7
  84. package/src/extra/fetchEventSource.ts +113 -0
  85. package/src/extra/form.ts +68 -26
  86. package/src/extra/formHelpers.ts +3 -1
  87. package/src/extra/lens.ts +3 -2
  88. package/src/extra/queryCache.ts +4 -3
  89. package/src/extra/socketDriver.ts +10 -4
  90. package/src/extra/testing.ts +68 -11
  91. package/src/index.d.ts +199 -30
  92. package/src/index.ts +1 -0
  93. package/src/shared.ts +3 -0
  94. package/src/ui/dialog.ts +3 -2
  95. package/src/ui/tooltips.ts +92 -0
  96. package/src/ui.d.ts +35 -0
  97. package/src/ui.ts +1 -0
  98. package/src/vike/onRenderClient.ts +44 -46
  99. package/src/vike/onRenderHtml.ts +5 -26
  100. package/src/vike/shell.ts +33 -0
  101. package/src/vite/graph.ts +255 -0
  102. package/src/vite/mcp.ts +3 -3
  103. package/src/vite/plugin.d.ts +19 -0
  104. package/src/vite/plugin.ts +144 -25
@@ -9,8 +9,11 @@ import {validateWith} from './standardSchema';
9
9
  * declarations in src/index.d.ts and docs/guide/drivers.md. Summary:
10
10
  *
11
11
  * - Request (sink value): `{ url, ok?, error?, key?, category?, method?, headers?, query?, json?,
12
- * body?, latest?, timeoutMs?, parse?, init? }` or a URL string. `json` is stringified with a JSON
13
- * content-type; method defaults to POST with a body/json, else GET. `query` is appended as a
12
+ * multipart?, body?, latest?, timeoutMs?, parse?, init? }` or a URL string. `json` is stringified
13
+ * with a JSON content-type; PLAN-11 U-5: `multipart` (an object) becomes a FormData body (a
14
+ * File / Blob a file part, an array repeats the name, null / undefined skipped, an object JSON,
15
+ * anything else a string), the content-type left to the browser (its boundary); method defaults
16
+ * to POST with a body/json/multipart, else GET. `query` is appended as a
14
17
  * query string (null/undefined values skipped, arrays repeat the key, before any #fragment).
15
18
  * Other fetch options (credentials, mode, cache, ...) go under `init` (R4-5); other top-level
16
19
  * keys are the app's own: not sent, but returned on `request`.
@@ -87,6 +90,15 @@ import {validateWith} from './standardSchema';
87
90
  * the request itself) into requests sent again in parallel and combined, at most 3 deep. One
88
91
  * failure fails the request once (`error.status` / `error.body`). No cache entry sharing, no
89
92
  * per-part retry. The test fake (`_whole`) sends and answers it as one request. decide() uses it.
93
+ * - PLAN-11 U-1 (D555): `progress: 'ACTION'` replies to the sender with each progress step,
94
+ * `progress: true` (or a string with no sender) sends it down `progress(category?)`:
95
+ * `{ phase: 'upload' | 'download', loaded, total, fraction, category, request }` (`total` /
96
+ * `fraction` null when the length is unknown). Such a request goes over XMLHttpRequest (the
97
+ * only transport with upload progress in every engine; S-1), turned back into a Response so
98
+ * parsing, status, retry and timeouts are the same. Steps are throttled to one per 50 ms per
99
+ * phase, keeping the first and the last. Not for resources or `batch`; never shares a fetch.
100
+ * No XMLHttpRequest (Node, a service worker): plain fetch, no steps. The transport is
101
+ * `options.XMLHttpRequest`, else the global one, read at request time.
90
102
  * - dispose / sink completion aborts everything in flight.
91
103
  * - `fetch` is read at request time (options.fetch, else globalThis.fetch), so test stubs work.
92
104
  * - PLAN-3 5-1 (H-9): renderComponent's HTTP fake runs this driver over an in-memory fetch;
@@ -177,6 +189,70 @@ const mergeHeaders = (...all: any[]) => {
177
189
  return out;
178
190
  };
179
191
 
192
+ // PLAN-11 U-1: statuses whose Response must have a null body
193
+ const NULL_BODY = [101, 103, 204, 205, 304];
194
+ const abortError = () => typeof DOMException == 'function'
195
+ ? new DOMException('The operation was aborted.', 'AbortError')
196
+ : Object.assign(new Error('The operation was aborted.'), {name: 'AbortError'});
197
+ /**
198
+ * PLAN-11 U-1 (S-1): a fetch-shaped call over XMLHttpRequest that reports progress:
199
+ * `on(phase, loaded, total)` (total 0 when unknown), at most every 50 ms per phase, always the
200
+ * first and the final step. Resolves with a real Response (blob body, parsed headers); network
201
+ * errors and timeouts reject with a TypeError, an abort with an AbortError, like fetch.
202
+ */
203
+ export const xhrFetch = (X: any, url: string, init: any, on: (phase: string, loaded: number, total: number) => void) => new Promise<any>((res, rej) => {
204
+ const x = new X();
205
+ const last: any = {};
206
+ const step = (phase: string, loaded: number, total: number, final?: any) => {
207
+ const l = last[phase], now = Date.now();
208
+ if (l && (l.loaded === loaded || (!final && now - l.at < 50 && !(total && loaded >= total)))) return;
209
+ last[phase] = {loaded, at: now};
210
+ on(phase, loaded, total);
211
+ };
212
+ const ev = (phase: string) => (e: any) => step(phase, e.loaded, e.lengthComputable ? e.total : 0);
213
+ x.open(init.method || 'GET', url);
214
+ x.responseType = 'blob';
215
+ const h = init.headers || {};
216
+ Object.keys(h).forEach(k => x.setRequestHeader(k, h[k]));
217
+ if (init.credentials == 'include') x.withCredentials = true;
218
+ const sig = init.signal;
219
+ if (sig) {
220
+ if (sig.aborted) return rej(abortError());
221
+ sig.addEventListener?.('abort', () => x.abort(), {once: true});
222
+ }
223
+ if (init.body != null && x.upload) {
224
+ x.upload.onprogress = ev('upload');
225
+ x.upload.onload = (e: any) => step('upload', e.loaded, e.lengthComputable ? e.total : e.loaded, 1);
226
+ }
227
+ x.onprogress = ev('download');
228
+ x.onload = () => {
229
+ const size = x.response?.size ?? last.download?.loaded ?? 0;
230
+ step('download', size, size, 1);
231
+ const headers: any = typeof Headers == 'function' ? new Headers() : undefined;
232
+ String(x.getAllResponseHeaders?.() || '').trim().split(/[\r\n]+/).forEach((line: string) => {
233
+ const i = line.indexOf(':');
234
+ if (i > 0) try { headers?.append(line.slice(0, i).trim(), line.slice(i + 1).trim()); } catch (_) {}
235
+ });
236
+ try {
237
+ res(new Response(NULL_BODY.includes(x.status) ? null : x.response, {status: x.status, statusText: x.statusText, headers}));
238
+ } catch (e) { rej(e); }
239
+ };
240
+ x.onerror = x.ontimeout = () => rej(new TypeError(`Network request failed: ${url}`));
241
+ x.onabort = () => rej(abortError());
242
+ x.send(init.body ?? null);
243
+ });
244
+
245
+ /** PLAN-11 U-5: a request's `multipart` object as FormData (see the header) */
246
+ export const toFormData = (parts: any) => {
247
+ const fd = new FormData();
248
+ Object.keys(parts || {}).forEach(k => [].concat(parts[k]).forEach((v: any) => {
249
+ if (v == null) return;
250
+ if (typeof Blob != 'undefined' && v instanceof Blob) fd.append(k, v, (v as any).name ?? 'blob');
251
+ else fd.append(k, typeof v == 'object' ? JSON.stringify(v) : String(v));
252
+ }));
253
+ return fd;
254
+ };
255
+
180
256
  const IDEMPOTENT = /^(GET|HEAD)$/i;
181
257
  // D80: an invalidate value matches a request by tag, URL prefix ('/…') or predicate
182
258
  export const hit = (x: any, q: any) => {
@@ -199,6 +275,13 @@ export function makeFetchDriver(options: any = {}) {
199
275
  // q (the request), timer, f (the fetch it waits on), ok / ko (its outcomes) }
200
276
  const inflight = new Map<number, any>();
201
277
  const subs = new Set<{l: any; sel: any; err: boolean; ns: any[]}>();
278
+ // PLAN-11 U-1: progress(category?) listeners (not held while nobody listens)
279
+ const psubs = new Set<{l: any; sel: any; ns: any[]}>();
280
+ const emitProgress = (v: any) => psubs.forEach(s => {
281
+ let m = false;
282
+ try { m = matches(s, v); } catch (_) {}
283
+ if (m) try { s.l.next(v); } catch (e) { console.error('[Sygnal] makeFetchDriver: a listener threw', e); }
284
+ });
202
285
  // R4-4: replies held until the first select() listener, failures until the first errors()
203
286
  // listener (or the next macrotask, when they're logged if nothing listens)
204
287
  const started = [false, false];
@@ -246,6 +329,10 @@ export function makeFetchDriver(options: any = {}) {
246
329
  return {
247
330
  select: make(false),
248
331
  errors: make(true),
332
+ progress: (sel?: any) => {
333
+ let sub: any;
334
+ return xs.create<any>({start: l => { psubs.add((sub = {l, sel, ns})); }, stop: () => { psubs.delete(sub); }});
335
+ },
249
336
  isolateSource: (_: any, scope: any) => source(ns.concat(scope)),
250
337
  isolateSink: (sink$: any, scope: any) => sink$.map((req: any) => tagRequest(req, scope)),
251
338
  // PLAN-4.6: the same per value (the core scopes a request without a stream per instance)
@@ -351,7 +438,7 @@ export function makeFetchDriver(options: any = {}) {
351
438
  const send = (req: any, sender = senderOf(req), rn?: string, after = -1, R?: any, pf?: any): any => {
352
439
  if (typeof req == 'string') req = {url: req};
353
440
  if (!req || typeof req != 'object' || disposed || !allowed(req, 'makeFetchDriver')) return;
354
- const {url, category, method, headers, query, json, body, latest, timeoutMs, parse, abort, init, ok, error, key, resources, refresh, invalidates, updates, retry, validate, prefetch} = req;
441
+ const {url, category, method, headers, query, json, body, latest, timeoutMs, parse, abort, init, ok, error, key, resources, refresh, invalidates, updates, retry, validate, prefetch, progress} = req;
355
442
  if ('invalidate' in req) return inval(req.invalidate);
356
443
  if (prefetch) return C && send(prefetch, 0, rn, -1, R, 1);
357
444
  if (resources || refresh) {
@@ -389,7 +476,9 @@ export function makeFetchDriver(options: any = {}) {
389
476
  }
390
477
  const scope = scopeKey(req);
391
478
  // reply actions: keyed per (sender, key ?? ok ?? error); a resource per (sender, '\0' + name)
392
- const rk = rn !== undefined ? '\0' + rn : !pf && sender !== undefined && (ok || error) ? key ?? ok ?? error : undefined;
479
+ // PLAN-11 U-1: a progress action is a reply action too (abort on dispose, its own lane)
480
+ const pr = !pf && rn === undefined && !req.batch && progress;
481
+ const rk = rn !== undefined ? '\0' + rn : !pf && sender !== undefined && (ok || error || typeof pr == 'string') ? key ?? ok ?? error ?? pr : undefined;
393
482
  if (sender !== undefined && (typeof abort == 'string' || (abort && key !== undefined)))
394
483
  return cancel(r => r.sender === sender && r.rk === (key ?? abort));
395
484
  // per (scope, category): another component's requests are never cancelled
@@ -398,12 +487,16 @@ export function makeFetchDriver(options: any = {}) {
398
487
 
399
488
  const base = options.init;
400
489
  // a request's URL and fetch() init (the batch's sub-requests inherit the request's fields)
401
- const prep = ({url, method, headers, query, json, body, init: own}: any) => {
490
+ const prep = ({url, method, headers, query, json, multipart, body, init: own}: any) => {
402
491
  const h = mergeHeaders(options.headers, base?.headers, own?.headers, headers);
403
492
  let b = body ?? own?.body;
404
493
  if (json !== undefined) {
405
494
  b = JSON.stringify(json);
406
495
  if (!Object.keys(h).some(k => k.toLowerCase() == 'content-type')) h['content-type'] = 'application/json';
496
+ } else if (multipart !== undefined) {
497
+ b = toFormData(multipart);
498
+ // the browser writes multipart/form-data with its boundary; a content-type set here would break it
499
+ Object.keys(h).forEach(k => { if (k.toLowerCase() == 'content-type') delete h[k]; });
407
500
  }
408
501
  return [withQuery((options.baseUrl || '') + (url ?? ''), query), {...pickInit(base), ...pickInit(own), method: method || own?.method || base?.method || (b !== undefined ? 'POST' : 'GET'), headers: h, body: b}];
409
502
  };
@@ -413,7 +506,8 @@ export function makeFetchDriver(options: any = {}) {
413
506
  // parts, at most 3 deep), their parsed bodies merged by `combine` into one reply. The test
414
507
  // fake (_whole) answers such a request as one
415
508
  const {batch, split, combine = (x: any) => x} = options._whole ? {} as any : req;
416
- const k = C?.key(req, rn !== undefined || pf);
509
+ // PLAN-11 U-1: a progress request never shares a fetch (each sender gets its own steps)
510
+ const k = pr ? undefined : C?.key(req, rn !== undefined || pf);
417
511
  const id = ++seq;
418
512
  const r: any = {category, scope, sender, rk};
419
513
  inflight.set(id, r);
@@ -449,7 +543,18 @@ export function makeFetchDriver(options: any = {}) {
449
543
  const parser = parserOf(parse || options.parse);
450
544
  // the global fetch is called as a method: a detached window.fetch throws "Illegal invocation"
451
545
  const g: any = globalThis;
452
- const doFetch = options.fetch || (typeof g.fetch == 'function' && ((u: string, i: any) => g.fetch(u, i)));
546
+ let doFetch = options.fetch || (typeof g.fetch == 'function' && ((u: string, i: any) => g.fetch(u, i)));
547
+ // PLAN-11 U-1: a progress request over XMLHttpRequest (renderComponent's fake: `_progress`)
548
+ const X = options.XMLHttpRequest ?? g.XMLHttpRequest;
549
+ if (pr && (options._progress || typeof X == 'function')) {
550
+ const step = (phase: string, loaded: number, total: number) => {
551
+ if (!inflight.has(id) || disposed) return;
552
+ const t = total > 0 ? total : null;
553
+ const v = {phase, loaded, total: t, fraction: t ? Math.min(1, loaded / t) : null, category, request: req};
554
+ typeof pr == 'string' && sender !== undefined ? reply(sender, pr, v) : emitProgress(v);
555
+ };
556
+ doFetch = options._progress ? (u: string, i: any) => options._progress(u, i, step, req) : (u: string, i: any) => xhrFetch(X, u, i, step);
557
+ }
453
558
  // the outcome, to every request waiting on this fetch (the failure once, after the last attempt)
454
559
  const end = (e: any, x?: any, status?: number) => {
455
560
  done(F);
@@ -0,0 +1,113 @@
1
+ import {sse} from './ai/transports/shared';
2
+
3
+ /*
4
+ * PLAN-11 E-1: an EventSource over fetch, for server-sent events that need request headers
5
+ * (`Authorization`), which the native EventSource can't send. makeSocketDriver uses it for an
6
+ * `sse` connection with `headers`. It behaves as EventSource does where the driver relies on it:
7
+ *
8
+ * - connects on construction (a microtask later, so handlers can be set first); `readyState`
9
+ * CONNECTING (0) → OPEN (1) on a 2xx `text/event-stream` response;
10
+ * - `onmessage` and `addEventListener(type)` get `{ type, data, lastEventId }`;
11
+ * - the stream ending or a network error: `error`, readyState CONNECTING, a reconnect after
12
+ * `retry` ms (the stream's `retry:` field, default 3000) with `Last-Event-ID`;
13
+ * - a non-2xx status or another content type: `error` with readyState CLOSED (no retry; the
14
+ * driver's reconnect policy decides);
15
+ * - `close()` aborts the request and stops retrying.
16
+ */
17
+
18
+ const NAMES = ['CONNECTING', 'OPEN', 'CLOSED'];
19
+
20
+ export class FetchEventSource {
21
+ static CONNECTING = 0;
22
+ static OPEN = 1;
23
+ static CLOSED = 2;
24
+ readyState = 0;
25
+ url: string;
26
+ withCredentials: boolean;
27
+ onopen: any = null;
28
+ onmessage: any = null;
29
+ onerror: any = null;
30
+ private ls = new Map<string, Set<(e: any) => void>>();
31
+ private meta: {id?: string; retry?: number} = {id: '', retry: 3000};
32
+ private ctl: any;
33
+ private timer: any;
34
+ private init: any;
35
+
36
+ constructor(url: string, init: {withCredentials?: boolean; headers?: Record<string, string>; fetch?: any} = {}) {
37
+ this.url = url;
38
+ this.withCredentials = !!init.withCredentials;
39
+ this.init = init;
40
+ queueMicrotask(() => this.connect());
41
+ }
42
+
43
+ addEventListener(type: string, f: (e: any) => void) {
44
+ let s = this.ls.get(type);
45
+ if (!s) this.ls.set(type, (s = new Set()));
46
+ s.add(f);
47
+ }
48
+ removeEventListener(type: string, f: (e: any) => void) { this.ls.get(type)?.delete(f); }
49
+
50
+ close() {
51
+ this.readyState = 2;
52
+ clearTimeout(this.timer);
53
+ try { this.ctl?.abort(); } catch (_) {}
54
+ }
55
+
56
+ private emit(type: string, ev: any) {
57
+ if (type == 'message') this.onmessage?.(ev);
58
+ else if (type == 'open') this.onopen?.(ev);
59
+ else if (type == 'error') this.onerror?.(ev);
60
+ this.ls.get(type)?.forEach(f => { try { f(ev); } catch (e) { console.error(e); } });
61
+ }
62
+
63
+ private async connect() {
64
+ if (this.readyState == 2) return;
65
+ const g: any = globalThis;
66
+ const f = this.init.fetch || (typeof g.fetch == 'function' && ((u: string, i: any) => g.fetch(u, i)));
67
+ if (typeof f != 'function') return this.fail(new Error('fetch is not available in this environment'));
68
+ const ctl: any = this.ctl = typeof AbortController == 'function' ? new AbortController() : {};
69
+ const headers: Record<string, string> = {accept: 'text/event-stream', ...this.init.headers};
70
+ if (this.meta.id) headers['last-event-id'] = this.meta.id;
71
+ let res: any;
72
+ try {
73
+ res = await f(this.url, {headers, credentials: this.withCredentials ? 'include' : 'same-origin', cache: 'no-store', signal: ctl.signal});
74
+ } catch (error) {
75
+ return this.retry(error);
76
+ }
77
+ if (this.readyState == 2 || this.ctl !== ctl) return;
78
+ const type = res.headers?.get?.('content-type') || '';
79
+ if (!(res.status >= 200 && res.status < 300) || !/^text\/event-stream/i.test(type) || !res.body) {
80
+ try { res.body?.cancel?.(); } catch (_) {}
81
+ return this.fail(Object.assign(new Error(`SSE ${res.status}${type ? ' ' + type : ''}: ${this.url}`), {status: res.status}));
82
+ }
83
+ this.readyState = 1;
84
+ this.emit('open', {type: 'open'});
85
+ try {
86
+ for await (const fr of sse(res, this.meta)) {
87
+ if (this.readyState == 2 || this.ctl !== ctl) return;
88
+ this.emit(fr.event, {type: fr.event, data: fr.data, lastEventId: this.meta.id});
89
+ }
90
+ } catch (error) {
91
+ if (this.readyState == 2 || this.ctl !== ctl) return;
92
+ return this.retry(error);
93
+ }
94
+ if (this.readyState != 2 && this.ctl === ctl) this.retry();
95
+ }
96
+
97
+ /** a drop: error, CONNECTING, try again after `retry` ms (unless the error handler closed it) */
98
+ private retry(error?: any) {
99
+ if (this.readyState == 2) return;
100
+ this.readyState = 0;
101
+ this.emit('error', {type: 'error', error});
102
+ if (this.readyState == 2) return;
103
+ this.timer = setTimeout(() => this.connect(), this.meta.retry);
104
+ }
105
+ /** a failed connection: error, CLOSED */
106
+ private fail(error: any) {
107
+ this.readyState = 2;
108
+ this.emit('error', {type: 'error', error});
109
+ }
110
+
111
+ /** the state's name (debugging) */
112
+ get state() { return NAMES[this.readyState]; }
113
+ }
package/src/extra/form.ts CHANGED
@@ -13,7 +13,12 @@
13
13
  * field.
14
14
  *
15
15
  * Options: `values` (the start values; D239: or a function of the host's state, called when the
16
- * form starts), `submit` (host action), `check` (async per-field checks through a driver: a
16
+ * form starts), `submit` (host action), `request` (PLAN-11 F-3: the form sends the valid submit
17
+ * itself on the `http` sink: a request, or `(data, hostState) => request`, completed with the
18
+ * body (`multipart: data` when a top-level value is a File / Blob or an array of them, else
19
+ * `json: data`, unless it sets json / multipart / body), `progress: '<key>.PROGRESS'` (unless set)
20
+ * and its own `ok: '<key>.DONE'`, `error: '<key>.ERRORS'`; with `submit` too, that host action is
21
+ * still dispatched (for state), and should send no request), `check` (async per-field checks through a driver: a
17
22
  * request with a reply action, `latest`), `show` ('blur' default: a schema error shows once its
18
23
  * field was blurred; 'input'; 'submit'), `form` (selector), `http` (the driver sink of the
19
24
  * checks, 'HTTP'), `resetOnShow` (D239), `tool` (PLAN-6 A-3, experimental: `formTool({ … })` from
@@ -32,6 +37,8 @@
32
37
  * before that (a child's first render) the fields come from calling it with a blank state.
33
38
  *
34
39
  * Slice (state.form): values, initial, errors (all current schema errors by name), touched,
40
+ * progress (PLAN-11 F-2: the submit's last progress step or null), resets (counts RESET and
41
+ * resetOnShow starts: key a file input with it, since a file input can't be cleared by state),
35
42
  * server (server errors), remote (async check results: '' passed), pending (name -> value being
36
43
  * checked), submitting, submitted, submitCount, queued (a submit waits for a check or an async
37
44
  * schema), validating (an async schema runs), validated (the schema has answered since the start
@@ -41,11 +48,14 @@
41
48
  *
42
49
  * Actions: form.CHANGE ({ name, value, item? }: G-376: a checkbox gives `checked`, and `item`, its
43
50
  * value, which toggles membership when the field is an array; <select multiple> an array of the
44
- * selected values; type=file is ignored), form.SET ({ values }: G-647, field name -> value, several
51
+ * selected values; PLAN-11 F-2: a file input its File, `multiple` an array of them, none null),
52
+ * form.SET ({ values }: G-647, field name -> value, several
45
53
  * fields changed at once as if typed: one validation, `touched` as for CHANGE, unknown names
46
54
  * skipped; e.g. an AI fill), form.BLUR (name), form.SUBMIT, form.ADD ({ field,
47
55
  * value }), form.REMOVE ({ field, id }), form.ERRORS (an error reply or a map: server errors),
48
- * form.DONE (saved), form.RESET (values?); internal: form.RESULT (an async schema's result),
56
+ * form.DONE (saved), form.RESET (values?), form.PROGRESS (PLAN-11 F-2: a progress step of the
57
+ * submit request, `progress: 'form.PROGRESS'`: kept as `progress` while submitting, cleared by
58
+ * DONE / ERRORS / RESET); internal: form.RESULT (an async schema's result),
49
59
  * form.CHECKED_<name> (a check's reply; a failed check passes: the server checks on submit),
50
60
  * form.VALIDATE (the first validation, when the host starts: G-375; `valid` is false until the
51
61
  * schema answered; with resetOnShow also each time the form element appears, D239).
@@ -70,13 +80,20 @@
70
80
  * run when the host is created.
71
81
  */
72
82
  import xs from './xstreamCompat'
73
- import {defineBehavior} from './behaviors'
83
+ import {defineBehavior, was} from './behaviors'
74
84
  import {ABORT} from '../shared'
75
85
  import {isStandardSchema} from './standardSchema'
76
- import {checkForm, fieldNames, getField, hasField, setField, replyErrors, focusInvalid} from './formHelpers'
86
+ import {checkForm, fieldNames, getField, hasField, setField, replyErrors, focusInvalid, isFile} from './formHelpers'
87
+
88
+ // PLAN-11 F-3: a submit's data goes as multipart when a top-level value is a file (or files)
89
+ const hasFile = (d: any) => !!d && typeof d == 'object' && Object.values(d).some(x => isFile(x) || Array.isArray(x) && x.some(isFile))
77
90
 
78
91
  const dev = (...a: any[]): any => (globalThis as any).__SYGNAL_DIAGNOSTICS__?.form?.(...a)
79
92
  const keys = (o: any) => Object.keys(o).filter(k => o[k])
93
+ // PLAN-11 F-2: values compared as JSON, a File by identity-ish fields (JSON gives every File {})
94
+ const fileIds = new WeakMap<object, number>()
95
+ let fileSeq = 0
96
+ const json = (v: any) => JSON.stringify(v, (_k, x) => isFile(x) ? `\0file:${fileIds.get(x) ?? (fileIds.set(x, ++fileSeq), fileSeq)}` : x)
80
97
  const drop = (o: any, p: string) => {
81
98
  const r: any = {}
82
99
  for (const k in o) if (k != p && !k.startsWith(p + '.')) r[k] = o[k]
@@ -85,7 +102,7 @@ const drop = (o: any, p: string) => {
85
102
 
86
103
  export const form = (schema: any, o: any = {}): any => {
87
104
  isStandardSchema(schema) || dev(231, schema)
88
- const {values: vo = {}, submit, check: checks = {}, show = 'blur', http = 'HTTP', form: sel = 'form', resetOnShow: again} = o
105
+ const {values: vo = {}, submit, request: rq, check: checks = {}, show = 'blur', http = 'HTTP', form: sel = 'form', resetOnShow: again} = o
89
106
  // D239: `values` can be a function of the host's state, called when the form starts (and each
90
107
  // show, resetOnShow). Before that (the host's first render, before its first action) the
91
108
  // fields come from calling it with a blank state: every read gives '' in the result
@@ -137,10 +154,11 @@ export const form = (schema: any, o: any = {}): any => {
137
154
  : Object.keys(s.pending).length ? {s: {...s, queued: true}}
138
155
  : {s: req ? {...s, queued: false, submitting: true} : done({...s, queued: false}), send: 1}
139
156
  }
140
- const base = (vals: any) => ({initial: vals, values: vals, touched: {}, server: {}, remote: {}, pending: {},
141
- submitting: false, submitted: false, submitCount: 0, queued: false, errors: {}, validated: false})
142
- const fresh = (vals: any) => edit(base(vals), vals)
143
- const done = (s: any) => ({...s, submitting: false, submitted: true, initial: s.values, server: {}, touched: {}})
157
+ const base = (vals: any, resets = 0) => ({initial: vals, values: vals, touched: {}, server: {}, remote: {}, pending: {},
158
+ submitting: false, submitted: false, submitCount: 0, queued: false, errors: {}, validated: false, progress: null, resets})
159
+ // a start over (RESET, resetOnShow): `resets` counts them, so a file input keyed by it is made anew
160
+ const fresh = (vals: any, s?: any) => edit(base(vals, s ? (s.resets || 0) + 1 : 0), vals)
161
+ const done = (s: any) => ({...s, submitting: false, submitted: true, initial: s.values, server: {}, touched: {}, progress: null})
144
162
  const group = (cur: any, d: any) => {
145
163
  if (!Array.isArray(cur) || !('item' in d)) return d.value
146
164
  const r = cur.filter(x => x !== d.item)
@@ -149,9 +167,9 @@ export const form = (schema: any, o: any = {}): any => {
149
167
 
150
168
  // fields changed as typed ones ([name, value] pairs; unknown names skipped, SYG230): their
151
169
  // server errors, check results and running checks go, `touched` as show 'input' says; one validation
152
- const put = (s: any, d: any[]) => {
170
+ const put = (s: any, d: any[], quiet?: any) => {
153
171
  let vals = s.values, server = drop(s.server, ''), remote = s.remote, pending = s.pending, touched = s.touched
154
- for (const [n, y] of d) if (known(s, n)) {
172
+ for (const [n, y] of d) if (known(s, n, quiet)) {
155
173
  vals = setField(vals, n, y), server = drop(server, n), remote = drop(remote, n), pending = drop(pending, n)
156
174
  if (show == 'input') touched = {...touched, [n]: true}
157
175
  }
@@ -161,7 +179,9 @@ export const form = (schema: any, o: any = {}): any => {
161
179
  const steps: Record<string, (s: any, d: any, k: string, h?: any) => any> = {
162
180
  // G-376: a checkbox (`item`: its value) on an array field is one of a group: checked adds
163
181
  // its value, unchecked removes it
164
- CHANGE: (s, d) => put(s, d?.name ? [[d.name, group(getField(s.values, d.name), d)]] : []),
182
+ // PLAN-11 F-2: a file input whose name isn't in values is left alone, quietly (an app that reads
183
+ // its files in its own intent, as before 6.7)
184
+ CHANGE: (s, d) => put(s, d?.name ? [[d.name, group(getField(s.values, d.name), d)]] : [], d?.file),
165
185
  BLUR: (s, n, k) => {
166
186
  if (!known(s, n, 1)) return null
167
187
  const t = show == 'submit' || s.touched[n] ? s.touched : {...s.touched, [n]: true}, x = {...s, touched: t}
@@ -192,16 +212,20 @@ export const form = (schema: any, o: any = {}): any => {
192
212
  SET: (s, d) => put(s, Object.entries(d?.values || {})),
193
213
  ERRORS: (s, d) => {
194
214
  const e = replyErrors(d, s.values)
195
- return {s: {...s, server: e, submitting: false, queued: false}, focus: focusInvalid(e, sel)}
215
+ return {s: {...s, server: e, submitting: false, queued: false, progress: null}, focus: focusInvalid(e, sel)}
196
216
  },
197
217
  DONE: (s) => ({s: done(s)}),
198
- RESET: (s, d) => fresh(d && typeof d == 'object' ? d : s.initial),
218
+ // PLAN-11 F-2: a progress step of the submit's request (`progress: 'form.PROGRESS'`), while it is pending
219
+ PROGRESS: (s, d) => s.submitting && d && typeof d == 'object'
220
+ ? {s: {...s, progress: {phase: d.phase, loaded: d.loaded, total: d.total ?? null, fraction: d.fraction ?? null}}} : null,
221
+ // PLAN-11: only a plain object is new values (a click event from `DOM.click('.reset')` is not)
222
+ RESET: (s, d) => fresh(d && typeof d == 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(d)) ? d : s.initial, s),
199
223
  // G-375: the first validation, when the host starts (form() doesn't validate at module load).
200
224
  // G-575: a slice kept in parent state from a host that unmounted mid-submit starts unstuck.
201
225
  // D239: with resetOnShow (also each time the form element appears again), or a `values`
202
226
  // function not called yet, the slice starts over from the start values (h: the host state)
203
- VALIDATE: (s, _, _k, h) => again || fn && s.values === values ? fresh(fn ? vo(h) : vo)
204
- : edit({...s, submitting: false, queued: false, pending: {}}, s.values),
227
+ VALIDATE: (s, _, _k, h) => again || fn && s.values === values ? fresh(fn ? vo(h) : vo, again && s)
228
+ : edit({...s, submitting: false, queued: false, pending: {}, progress: null}, s.values),
205
229
  }
206
230
  // a check's reply (ok and error: an error reply carries an Error and passes)
207
231
  for (const f of checked) steps['CHECKED_' + f] = (s, d, k) => {
@@ -210,6 +234,14 @@ export const form = (schema: any, o: any = {}): any => {
210
234
  return s.queued ? attempt(n, k) : {s: n}
211
235
  }
212
236
 
237
+ // PLAN-11 F-3: the `request` option's request for a valid submit (data: the schema's output)
238
+ const send = (vals: any, h: any, k: string) => {
239
+ const data = v(vals).value, q = typeof rq == 'function' ? rq(data, h) : rq
240
+ if (!q || q === ABORT) return ABORT
241
+ const own = 'json' in q || 'multipart' in q || 'body' in q
242
+ return {...(!own && (hasFile(data) ? {multipart: data} : {json: data})), progress: `${k}.PROGRESS`, ...q, ok: `${k}.DONE`, error: `${k}.ERRORS`}
243
+ }
244
+
213
245
  const model: any = {}
214
246
  for (const a in steps) {
215
247
  let last: any[] = []
@@ -219,19 +251,27 @@ export const form = (schema: any, o: any = {}): any => {
219
251
  // VALIDATE: on the host's state (HOST), for a `values` function; the slice's own steps on it
220
252
  ...a == 'VALIDATE' ? {HOST: (h: any, d: any, _n: any, _p: any, _o: any, k: string) => { const x = out(h[k], d, k, h); return x ? {...h, [k]: x.s} : h }}
221
253
  : {STATE: (s: any, d: any, _n: any, _p: any, _o: any, k: string) => out(s, d, k)?.s || ABORT},
222
- EFFECT: (s: any, d: any, next: any, _p: any, _o: any, k: string) => {
254
+ // outputs share STATE's transition: computed from the slice before the action
255
+ EFFECT: (s: any, d: any, next: any, p: any, _o: any, k: string) => {
256
+ s = was(s, p, k)
223
257
  const x = out(s, d, k), w = x?.wait
224
258
  if (x?.send) {
225
259
  const r = v(x.s.values).value
226
260
  dev(233, s.values, r)
227
261
  // G-577: a submit named after one of the form's own actions goes to that one
228
262
  submit in steps && dev(234, submit, k, b)
229
- next(submit, r, 0)
263
+ // PLAN-11 F-3: with `request`, `submit` is optional (dispatched for state when given)
264
+ if (submit !== undefined || !rq) next(submit, r, 0)
230
265
  }
231
266
  w && Promise.resolve(v(w.values)).then(() => next('RESULT', w, 0))
232
267
  },
233
- ELEMENT: (s: any, d: any, _n: any, _p: any, _o: any, k: string) => out(s, d, k)?.focus || ABORT,
234
- ...(checked.length && {[http]: (s: any, d: any, _n: any, _p: any, _o: any, k: string) => out(s, d, k)?.req || ABORT}),
268
+ ELEMENT: (s: any, d: any, _n: any, p: any, _o: any, k: string) => out(was(s, p, k), d, k)?.focus || ABORT,
269
+ // the checks' requests, and (PLAN-11 F-3) the `request` option's for a valid submit; the
270
+ // step from the slice before the action (7.0: outputs get the updated one), the host state as committed
271
+ ...((checked.length || rq) && {[http]: (s: any, d: any, _n: any, p: any, _o: any, k: string) => {
272
+ const x = out(was(s, p, k), d, k)
273
+ return x?.req || (x?.send && rq ? send(x.s.values, p?.state, k) : ABORT)
274
+ }}),
235
275
  }
236
276
  }
237
277
 
@@ -248,9 +288,11 @@ export const form = (schema: any, o: any = {}): any => {
248
288
  VALIDATE: again ? xs.merge(xs.of(0), f.elements().filter((e: any) => { const r = e.length && !on; on = e.length; return r })) : xs.of(0),
249
289
  // G-376: a checkbox (native, or a hyphenated tag with a boolean `checked`: a
250
290
  // form-associated custom checkbox / switch) gives `checked` and its value as `item`; a
251
- // <select multiple> the selected values; type=file is left alone; others give `value`
252
- CHANGE: f.events('input').filter(({target: t}: any) => t.type != 'file').map(({target: t}: any) =>
253
- t.type == 'checkbox' || /-/.test(t.tagName) && typeof t.checked == 'boolean' ? {name: t.name, value: t.checked, item: t.value}
291
+ // <select multiple> the selected values; PLAN-11 F-2: a file input its File (`multiple`:
292
+ // an array of them; none: null); others give `value`
293
+ CHANGE: f.events('input').map(({target: t}: any) =>
294
+ t.type == 'file' ? {name: t.name, value: t.multiple || t.getAttribute?.('multiple') != null ? [...(t.files || [])] : t.files?.[0] ?? null, file: true}
295
+ : t.type == 'checkbox' || /-/.test(t.tagName) && typeof t.checked == 'boolean' ? {name: t.name, value: t.checked, item: t.value}
254
296
  : {name: t.name, value: t.type == 'select-multiple' ? [...t.selectedOptions].map((o: any) => o.value) : t.value}),
255
297
  BLUR: f.events('focusout').map((e: any) => e.target.name),
256
298
  SUBMIT: f.events('submit', {preventDefault: true}),
@@ -269,7 +311,7 @@ export const form = (schema: any, o: any = {}): any => {
269
311
  },
270
312
  // G-382: an async schema re-validating keeps the last answer's validity (no flicker)
271
313
  valid: (s: any) => s.validated && !keys(s.errors).length && !keys(s.remote).length,
272
- dirty: (s: any) => JSON.stringify(s.values) != JSON.stringify(s.initial),
314
+ dirty: (s: any) => json(s.values) != json(s.initial),
273
315
  error: (s: any) => s.server[''] || (s.submitCount && s.errors['']) || '',
274
316
  },
275
317
  })({...o, values}), merge = b.merge
@@ -277,7 +319,7 @@ export const form = (schema: any, o: any = {}): any => {
277
319
  o.tool?.$?.(b, v)
278
320
  b.merge = (c: any, k: string) => {
279
321
  const e = c.model?.[submit]
280
- req = !!e && typeof e == 'object' && http in e
322
+ req = !!rq || !!e && typeof e == 'object' && http in e
281
323
  merge(c, k)
282
324
  }
283
325
  return b
@@ -21,6 +21,8 @@ export type FieldErrors = Record<string, string>
21
21
  export type FormCheck = {errors: FieldErrors; value: any}
22
22
 
23
23
  const isRow = (r: any) => r?.id != null
24
+ /** PLAN-11 F-2: a File / Blob is a field value (a leaf), not an object of fields */
25
+ export const isFile = (v: any) => typeof Blob != 'undefined' && v instanceof Blob
24
26
  // the key of segment `s` in `v`: an array's row with that id, else the index unless the element
25
27
  // there is a row (G-380: a row is named by its id only; -1, no element), an object's key
26
28
  const key = (v: any, s: string): any => {
@@ -70,7 +72,7 @@ export const setField = (values: any, name: string, value: any): any => {
70
72
  * array-level error such as "at least one address"); a row's `id` is not a field
71
73
  */
72
74
  export const fieldNames = (v: any, at = '', row?: boolean): string[] => {
73
- if (!v || typeof v != 'object') return at ? [at] : []
75
+ if (!v || typeof v != 'object' || isFile(v)) return at ? [at] : []
74
76
  const a = Array.isArray(v), out = a ? [at] : []
75
77
  for (const k in v) {
76
78
  const x = (v as any)[k]
package/src/extra/lens.ts CHANGED
@@ -5,11 +5,12 @@
5
5
  * is gone); set: the array with that element replaced (the state unchanged when it is gone).
6
6
  * Side-effect free.
7
7
  */
8
- export const byId = (field: string, id: any, idKey = 'id') => ({
8
+ import {BINDING} from '../shared'
9
+ export const byId = (field: string, id: any, idKey = 'id') => Object.defineProperty({
9
10
  get: (s: any) => (Array.isArray(s?.[field]) ? s[field].find((x: any) => x?.[idKey] === id) : undefined),
10
11
  set: (s: any, v: any) => {
11
12
  const a = s?.[field]
12
13
  if (!Array.isArray(a) || !a.some((x: any) => x?.[idKey] === id)) return s
13
14
  return {...s, [field]: a.map((x: any) => (x?.[idKey] === id ? v : x))}
14
15
  },
15
- })
16
+ }, BINDING, {value: [field, id, idKey]})
@@ -43,9 +43,10 @@ export function queryCache(o: any = {}): any {
43
43
  // the key of a cacheable request (else undefined); `res`: a resource's
44
44
  const key = (q: any, res?: any) => {
45
45
  if (typeof q == 'string') q = {url: q};
46
- const {method, json, body, parse, query} = q;
47
- const m = (method || (json !== undefined || body !== undefined ? 'POST' : 'GET')).toUpperCase();
48
- const b = json !== undefined ? JSON.stringify(json) : body ?? '';
46
+ const {method, json, body, parse, query, multipart} = q;
47
+ const m = (method || (json !== undefined || body !== undefined || multipart !== undefined ? 'POST' : 'GET')).toUpperCase();
48
+ // PLAN-11 U-5: a multipart request is never cached (its body isn't a string)
49
+ const b = json !== undefined ? JSON.stringify(json) : multipart !== undefined ? multipart : body ?? '';
49
50
  if (q.cache === false || typeof b != 'string' || typeof parse == 'function' || parse == 'response' ||
50
51
  !(q.cache || q.staleTime != null || (res && IDEMPOTENT.test(m)))) return;
51
52
  return [m, withQuery(q.url ?? '', query, 1), b, parse].join(' ').trim();
@@ -2,6 +2,7 @@ import xs, {Stream} from 'xstream';
2
2
  import {senderOf, keepSender, allowed, makeReplies} from './replies';
3
3
  import {error as logError} from './diagnostics/legacy';
4
4
  import {backoff} from './backoff';
5
+ import {FetchEventSource} from './fetchEventSource';
5
6
 
6
7
  /*
7
8
  * makeSocketDriver(options?) (PLAN-3 §1.3): WebSocket and server-sent events, declared per
@@ -11,7 +12,7 @@ import {backoff} from './backoff';
11
12
  * - Sink values:
12
13
  * - `{ connections: { [name]: spec | falsy } }`: the sender's whole set of connections. Diffed
13
14
  * per (sender, name): a new name opens, a removed or falsy one closes, a changed URL /
14
- * protocols / withCredentials / share closes and reopens, anything else (action names,
15
+ * protocols / withCredentials / headers / share closes and reopens, anything else (action names,
15
16
  * reconnect) only rebinds.
16
17
  * - `{ to: name, json? | text? | binary? }`: send on the sender's connection `name` (json is
17
18
  * stringified). Queued while (re)connecting, bounded (`queueLimit`, oldest dropped). A send
@@ -36,6 +37,10 @@ import {backoff} from './backoff';
36
37
  * - A disposed sender's connections close (and their retries stop); app dispose closes all.
37
38
  * - No WebSocket / EventSource (SSR): nothing opens, nothing is reported.
38
39
  * - `WebSocket` / `EventSource` are read at connect time: options.WebSocket ?? globalThis.WebSocket.
40
+ * - PLAN-11 E-1: an `sse` connection with `headers` (data: a token in it comes from state, and a
41
+ * changed header reconnects) is read with FetchEventSource (fetch, `options.fetch` if given),
42
+ * since EventSource can't send headers. An `options.EventSource` (a polyfill, the test fake)
43
+ * still wins and gets the headers in its init.
39
44
  */
40
45
 
41
46
  const SCOPE = '__sygnalScope';
@@ -118,11 +123,12 @@ export function makeSocketDriver(options: any = {}) {
118
123
 
119
124
  const connect = (t: any) => {
120
125
  t.timer = 0;
121
- const C = t.sse ? options.EventSource ?? g.EventSource : options.WebSocket ?? g.WebSocket;
126
+ const hdr = t.sse && t.spec.headers && typeof t.spec.headers == 'object' ? t.spec.headers : undefined;
127
+ const C = t.sse ? options.EventSource ?? (hdr && typeof g.fetch == 'function' ? FetchEventSource : g.EventSource) : options.WebSocket ?? g.WebSocket;
122
128
  if (typeof C != 'function') return;
123
129
  let s: any;
124
130
  try {
125
- s = t.sse ? new C(t.url, {withCredentials: !!t.spec.withCredentials}) : new C(t.url, t.spec.protocols);
131
+ s = t.sse ? new C(t.url, {withCredentials: !!t.spec.withCredentials, ...(hdr && {headers: hdr, fetch: options.fetch})}) : new C(t.url, t.spec.protocols);
126
132
  } catch (error) {
127
133
  // a URL the constructor rejects never opens: no retry
128
134
  each(t, e => deliver(e, 'error', {error}));
@@ -201,7 +207,7 @@ export function makeSocketDriver(options: any = {}) {
201
207
  const sse = typeof spec.sse == 'string';
202
208
  const url = resolve(sse ? spec.sse : spec.socket, sse);
203
209
  // the connection's identity: a change reconnects, anything else only rebinds
204
- const key = (spec.share === false ? 'u' : '') + (sse ? 'e' + !!spec.withCredentials : 's' + JSON.stringify(spec.protocols)) + url;
210
+ const key = (spec.share === false ? 'u' : '') + (sse ? 'e' + !!spec.withCredentials + (spec.headers ? JSON.stringify(spec.headers) : '') : 's' + JSON.stringify(spec.protocols)) + url;
205
211
  if (old && old.key === key) {
206
212
  old.spec = spec;
207
213
  if (old.t) listen(old.t);