@tiaforge-ui/sinter 0.0.0-stage → 0.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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -2
  3. package/dist/cache.d.ts +5 -0
  4. package/dist/cache.js +95 -0
  5. package/dist/check/index.d.ts +82 -0
  6. package/dist/check/index.js +156 -0
  7. package/dist/check/openapi-types.d.ts +6 -0
  8. package/dist/check/openapi-types.js +84 -0
  9. package/dist/check/shadow.d.ts +47 -0
  10. package/dist/check/shadow.js +245 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +67 -0
  13. package/dist/compiler/check-unit.d.ts +97 -0
  14. package/dist/compiler/check-unit.js +3 -0
  15. package/dist/compiler/compile.d.ts +62 -0
  16. package/dist/compiler/compile.js +1133 -0
  17. package/dist/compiler/diagnostic-docs.d.ts +10 -0
  18. package/dist/compiler/diagnostic-docs.js +137 -0
  19. package/dist/compiler/diagnostics.d.ts +56 -0
  20. package/dist/compiler/diagnostics.js +76 -0
  21. package/dist/compiler/expr.d.ts +89 -0
  22. package/dist/compiler/expr.js +838 -0
  23. package/dist/compiler/index.d.ts +21 -0
  24. package/dist/compiler/index.js +106 -0
  25. package/dist/compiler/openapi.d.ts +40 -0
  26. package/dist/compiler/openapi.js +177 -0
  27. package/dist/compiler/vocabulary.d.ts +12 -0
  28. package/dist/compiler/vocabulary.js +38 -0
  29. package/dist/dev.d.ts +10 -0
  30. package/dist/dev.js +92 -0
  31. package/dist/index.d.ts +7 -0
  32. package/dist/index.js +4 -0
  33. package/dist/ir.d.ts +127 -0
  34. package/dist/ir.js +121 -0
  35. package/dist/lsp/quickfix.d.ts +15 -0
  36. package/dist/lsp/quickfix.js +58 -0
  37. package/dist/lsp/rpc.d.ts +31 -0
  38. package/dist/lsp/rpc.js +120 -0
  39. package/dist/lsp/server.d.ts +36 -0
  40. package/dist/lsp/server.js +281 -0
  41. package/dist/lsp/spec-location.d.ts +9 -0
  42. package/dist/lsp/spec-location.js +42 -0
  43. package/dist/lsp/typescript.d.ts +77 -0
  44. package/dist/lsp/typescript.js +242 -0
  45. package/dist/lsp/workspace.d.ts +56 -0
  46. package/dist/lsp/workspace.js +227 -0
  47. package/dist/optimistic.d.ts +3 -0
  48. package/dist/optimistic.js +15 -0
  49. package/dist/paginate.d.ts +3 -0
  50. package/dist/paginate.js +184 -0
  51. package/dist/persist.d.ts +3 -0
  52. package/dist/persist.js +55 -0
  53. package/dist/project.d.ts +36 -0
  54. package/dist/project.js +100 -0
  55. package/dist/runtime/app.d.ts +125 -0
  56. package/dist/runtime/app.js +1183 -0
  57. package/dist/runtime/config.d.ts +53 -0
  58. package/dist/runtime/config.js +118 -0
  59. package/dist/runtime/env.d.ts +18 -0
  60. package/dist/runtime/env.js +23 -0
  61. package/dist/runtime/helpers.d.ts +9 -0
  62. package/dist/runtime/helpers.js +55 -0
  63. package/dist/runtime/request.d.ts +23 -0
  64. package/dist/runtime/request.js +93 -0
  65. package/dist/runtime/security.d.ts +12 -0
  66. package/dist/runtime/security.js +34 -0
  67. package/dist/runtime/store.d.ts +95 -0
  68. package/dist/runtime/store.js +142 -0
  69. package/dist/sse.d.ts +7 -0
  70. package/dist/sse.js +206 -0
  71. package/dist/testing/index.d.ts +71 -0
  72. package/dist/testing/index.js +345 -0
  73. package/dist/vite.d.ts +11 -0
  74. package/dist/vite.js +94 -0
  75. package/dist/ws.d.ts +18 -0
  76. package/dist/ws.js +57 -0
  77. package/docs/diagnostics.md +273 -0
  78. package/html-custom-data.json +234 -0
  79. package/llms.txt +531 -0
  80. package/package.json +90 -4
@@ -0,0 +1,345 @@
1
+ // @tiaforge-ui/sinter/testing: compile in memory with the real compiler, mount
2
+ // into the test DOM, and drive requests, streams and time deterministically.
3
+ // No test-runner imports: works with Vitest or anything else, on jsdom or happy-dom.
4
+ import { compileParts } from '../compiler/compile.js';
5
+ import { formatDiagnostic } from '../compiler/diagnostics.js';
6
+ import { mount } from '../runtime/app.js';
7
+ import { config } from '../runtime/config.js';
8
+ import { $k } from '../runtime/security.js';
9
+ import * as builtins from '../runtime/helpers.js';
10
+ import cache from '../cache.js';
11
+ import dev from '../dev.js';
12
+ import optimistic from '../optimistic.js';
13
+ import paginate from '../paginate.js';
14
+ import persist from '../persist.js';
15
+ import sse from '../sse.js';
16
+ const FEATURES = { dev, sse, optimistic, paginate, persist, cache };
17
+ function isResponseSpec(v) {
18
+ if (!v || typeof v !== 'object' || Array.isArray(v))
19
+ return false;
20
+ const keys = Object.keys(v);
21
+ return typeof v.status === 'number' && keys.every((k) => k === 'status' || k === 'body' || k === 'headers');
22
+ }
23
+ function toResponse(v) {
24
+ if (v instanceof Response)
25
+ return v;
26
+ if (isResponseSpec(v)) {
27
+ const headers = new Headers(v.headers);
28
+ let body = null;
29
+ if (v.body !== undefined && v.body !== null) {
30
+ if (typeof v.body === 'string') {
31
+ body = v.body;
32
+ if (!headers.has('Content-Type'))
33
+ headers.set('Content-Type', 'text/plain');
34
+ }
35
+ else {
36
+ body = JSON.stringify(v.body);
37
+ if (!headers.has('Content-Type'))
38
+ headers.set('Content-Type', 'application/json');
39
+ }
40
+ }
41
+ return new Response(v.status === 204 ? null : body, { status: v.status, headers });
42
+ }
43
+ if (v === undefined || v === null)
44
+ return new Response(null, { status: 204 });
45
+ return new Response(JSON.stringify(v), { status: 200, headers: { 'Content-Type': 'application/json' } });
46
+ }
47
+ /** Request middleware answering `'METHOD /path'` routes. Responses are held until `settle()`. */
48
+ export function mockRequests(routes) {
49
+ const calls = [];
50
+ const errors = [];
51
+ const queue = [];
52
+ const table = Object.entries(routes).map(([k, v]) => {
53
+ const [method, path] = k.trim().split(/\s+/, 2);
54
+ return { method: method.toUpperCase(), path, value: v, key: k };
55
+ });
56
+ const mw = (async (req) => {
57
+ const url = new URL(req.url);
58
+ const text = req.method === 'GET' || req.method === 'HEAD' ? '' : await req.clone().text();
59
+ const ct = req.headers.get('Content-Type') ?? '';
60
+ let body = null;
61
+ if (text) {
62
+ if (ct.includes('json')) {
63
+ try {
64
+ body = JSON.parse(text);
65
+ }
66
+ catch {
67
+ body = text;
68
+ }
69
+ }
70
+ else if (ct.includes('multipart/form-data')) {
71
+ try {
72
+ body = await req.clone().formData();
73
+ }
74
+ catch {
75
+ body = text;
76
+ }
77
+ }
78
+ else
79
+ body = text;
80
+ }
81
+ const headers = {};
82
+ req.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
83
+ const call = { method: req.method, url: url.pathname + url.search, path: url.pathname, body, headers };
84
+ calls.push(call);
85
+ const route = table.find((r) => r.method === req.method && (r.path.includes('?') ? r.path === call.url : r.path === url.pathname));
86
+ if (!route) {
87
+ const err = new Error(`unmatched request: ${req.method} ${call.url}\nregistered routes:\n${table.map((r) => ` ${r.key}`).join('\n') || ' (none)'}`);
88
+ errors.push(err);
89
+ throw err;
90
+ }
91
+ return new Promise((resolve, reject) => {
92
+ const onAbort = () => reject(req.signal.reason ?? new DOMException('aborted', 'AbortError'));
93
+ if (req.signal.aborted)
94
+ return onAbort();
95
+ req.signal.addEventListener('abort', onAbort, { once: true });
96
+ queue.push(async () => {
97
+ req.signal.removeEventListener('abort', onAbort);
98
+ if (req.signal.aborted)
99
+ return;
100
+ try {
101
+ const v = typeof route.value === 'function' ? await route.value(call) : route.value;
102
+ resolve(toResponse(structuredClone(v)));
103
+ }
104
+ catch (e) {
105
+ reject(e);
106
+ }
107
+ });
108
+ });
109
+ });
110
+ Object.defineProperties(mw, {
111
+ calls: { value: calls },
112
+ errors: { value: errors },
113
+ pending: { get: () => queue.length },
114
+ deliver: {
115
+ value: async () => {
116
+ const batch = queue.splice(0);
117
+ for (const fn of batch)
118
+ await fn();
119
+ return batch.length;
120
+ },
121
+ },
122
+ });
123
+ return mw;
124
+ }
125
+ export function mockStream() {
126
+ let onEvent = null;
127
+ let onState = null;
128
+ let connected = false;
129
+ return {
130
+ get connected() { return connected; },
131
+ emit(name, payload) {
132
+ if (!connected)
133
+ throw new Error(`mockStream: cannot emit \`${name}\` while disconnected`);
134
+ // through JSON, as over the wire: the page never shares the test's object
135
+ onEvent?.(name, JSON.parse(JSON.stringify(payload ?? null)));
136
+ },
137
+ drop() {
138
+ if (!connected)
139
+ return;
140
+ connected = false;
141
+ onState?.('error');
142
+ },
143
+ transport() {
144
+ return {
145
+ connect(_url, ev, st) {
146
+ onEvent = ev;
147
+ onState = st;
148
+ connected = true;
149
+ queueMicrotask(() => { if (connected && onState === st)
150
+ st('open'); });
151
+ },
152
+ close() { connected = false; onEvent = null; onState = null; },
153
+ };
154
+ },
155
+ };
156
+ }
157
+ let current = null;
158
+ const realSetTimeout = globalThis.setTimeout.bind(globalThis);
159
+ const tick = () => new Promise((r) => realSetTimeout(r, 0));
160
+ const PAGE = '/test/page.html';
161
+ export async function render(html, options = {}) {
162
+ if (current) {
163
+ current.destroy();
164
+ current = null;
165
+ }
166
+ // compile with the real compiler
167
+ const helpers = options.helpers ?? {};
168
+ const manifest = { module: null, names: [], namespaces: {} };
169
+ for (const [k, v] of Object.entries(helpers)) {
170
+ if (typeof v === 'function')
171
+ manifest.names.push(k);
172
+ else if (v && typeof v === 'object')
173
+ manifest.namespaces[k] = { module: k, members: Object.keys(v) };
174
+ }
175
+ const files = options.files ?? {};
176
+ const parts = compileParts(html, {
177
+ file: PAGE, root: '/test', dev: true, helpers: manifest, moduleSrc: 'about:blank',
178
+ readFile: (p) => {
179
+ const rel = p.startsWith('/test/') ? p.slice('/test/'.length) : p;
180
+ const hit = files[rel] ?? files[`./${rel}`] ?? files[p];
181
+ if (hit === undefined)
182
+ throw new Error(`no such file: ${p}`);
183
+ return hit;
184
+ },
185
+ });
186
+ const errors = parts.diagnostics.filter((d) => d.severity === 'error');
187
+ if (errors.length)
188
+ throw new Error(`sinter: build failed\n${errors.map(formatDiagnostic).join('\n')}`);
189
+ // function table, with helpers and namespace members in scope
190
+ const names = ['$k'];
191
+ const values = [$k];
192
+ for (const h of parts.helpers.builtin) {
193
+ names.push(h);
194
+ values.push(builtins[h]);
195
+ }
196
+ for (const h of parts.helpers.user) {
197
+ names.push(h);
198
+ values.push(helpers[h]);
199
+ }
200
+ for (const [ns, members] of Object.entries(parts.namespaces)) {
201
+ for (const m of members) {
202
+ names.push(`${ns}$${m}`);
203
+ values.push(helpers[ns][m]);
204
+ }
205
+ }
206
+ // eslint-disable-next-line no-new-func -- tests only; pages load a compiled module
207
+ const fns = new Function(...names, `"use strict"; return [\n${parts.fns.join(',\n')}\n];`)(...values);
208
+ // the page
209
+ const doc = document;
210
+ const inner = /<html[^>]*>([\s\S]*)<\/html>/i.exec(parts.html)?.[1] ?? parts.html;
211
+ doc.documentElement.innerHTML = inner.replace(/<script type="module" src="about:blank"><\/script>/, '');
212
+ // environment: fake clock, recorded navigation, in-memory storage
213
+ let now = options.now instanceof Date ? options.now.getTime() : options.now ?? Date.parse('2026-01-01T09:00:00Z');
214
+ let href = options.url ? new URL(options.url, 'http://localhost/').href : 'http://localhost/';
215
+ const timers = [];
216
+ let nextTimer = 1;
217
+ const navigations = [];
218
+ const store = options.storage ?? memoryStorage(), session = options.session ?? memoryStorage();
219
+ const winListeners = new Map();
220
+ const env = {
221
+ now: () => now,
222
+ setTimeout: (fn, ms, kind = 'timeout') => { const t = { id: nextTimer++, at: now + ms, fn, kind }; timers.push(t); return t.id; },
223
+ clearTimeout: (id) => { const i = timers.findIndex((t) => t.id === id); if (i >= 0)
224
+ timers.splice(i, 1); },
225
+ queueMicrotask: (fn) => queueMicrotask(fn),
226
+ navigate: (url) => { navigations.push(url); },
227
+ fetch: (req) => Promise.reject(new Error(`unexpected network request: ${req.method} ${req.url}; pass \`requests: mockRequests({...})\``)),
228
+ storage: () => store,
229
+ session: () => session,
230
+ href: () => href,
231
+ replaceState: (url) => { href = new URL(url, href).href; },
232
+ random: () => 0.5,
233
+ listen: (type, fn) => {
234
+ if (!winListeners.has(type))
235
+ winListeners.set(type, new Set());
236
+ winListeners.get(type).add(fn);
237
+ return () => winListeners.get(type)?.delete(fn);
238
+ },
239
+ };
240
+ config.env = env;
241
+ if (options.locale !== undefined) {
242
+ let locale = options.locale;
243
+ config.locale = { get: () => locale, set: (l) => { locale = l; } };
244
+ }
245
+ const requests = options.requests;
246
+ const app = mount(doc, {
247
+ ir: parts.ir, fns, env,
248
+ features: parts.features.map((f) => FEATURES[f]),
249
+ request: [...config.request, ...(requests ? [requests] : [])],
250
+ transport: (url) => {
251
+ const path = new URL(url, href).pathname;
252
+ const stream = options.streams?.[url] ?? options.streams?.[path];
253
+ if (stream)
254
+ return stream.transport();
255
+ // a transport from configure() (e.g. a WebSocket or SignalR adapter over a fake connection)
256
+ const configured = config.transport?.(url);
257
+ if (configured)
258
+ return configured;
259
+ throw new Error(`no mock stream for ${url}; pass \`streams: { '${url}': mockStream() }\``);
260
+ },
261
+ });
262
+ current = app;
263
+ const check = () => {
264
+ if (requests?.errors.length)
265
+ throw requests.errors[0];
266
+ };
267
+ const flush = async () => {
268
+ for (let i = 0; i < 50; i++) {
269
+ for (let j = 0; j < 10; j++)
270
+ await Promise.resolve();
271
+ if (!app.isDirty)
272
+ break;
273
+ app.flush();
274
+ }
275
+ check();
276
+ };
277
+ const runDue = (until, kinds) => {
278
+ for (;;) {
279
+ const due = timers.filter((t) => t.at <= until && (!kinds || kinds.has(t.kind))).sort((a, b) => a.at - b.at || a.id - b.id)[0];
280
+ if (!due)
281
+ return;
282
+ timers.splice(timers.indexOf(due), 1);
283
+ if (due.at > now)
284
+ now = due.at;
285
+ due.fn();
286
+ }
287
+ };
288
+ const settle = async () => {
289
+ for (let round = 0; round < 1000; round++) {
290
+ await flush();
291
+ await tick();
292
+ if (requests && requests.pending) {
293
+ await requests.deliver();
294
+ continue;
295
+ }
296
+ const debounce = timers.filter((t) => t.kind === 'debounce').sort((a, b) => a.at - b.at)[0];
297
+ if (debounce) {
298
+ runDue(debounce.at, new Set(['debounce']));
299
+ continue;
300
+ }
301
+ if (app.req.inflight > 0 || app.busy > 0 || app.isDirty)
302
+ continue;
303
+ await flush();
304
+ return;
305
+ }
306
+ throw new Error('settle(): requests did not finish after 1000 rounds');
307
+ };
308
+ const advance = async (ms) => {
309
+ const until = now + ms;
310
+ for (;;) {
311
+ await flush();
312
+ const before = timers.length;
313
+ runDue(until);
314
+ if (timers.length === before || !timers.some((t) => t.at <= until))
315
+ break;
316
+ }
317
+ now = until;
318
+ await flush();
319
+ };
320
+ await flush();
321
+ return {
322
+ root: doc.body, app, flush, settle, advance, navigations,
323
+ url: () => href,
324
+ window: async (type) => {
325
+ const e = new Event(type);
326
+ if (type === 'pageshow')
327
+ Object.defineProperty(e, 'persisted', { value: true });
328
+ for (const fn of winListeners.get(type) ?? [])
329
+ fn(e);
330
+ await flush();
331
+ },
332
+ };
333
+ }
334
+ /** An in-memory `Storage`, e.g. to share `session` between two render() calls. */
335
+ export function memoryStorage() {
336
+ const m = new Map();
337
+ return {
338
+ get length() { return m.size; },
339
+ key: (i) => [...m.keys()][i] ?? null,
340
+ getItem: (k) => m.get(k) ?? null,
341
+ setItem: (k, v) => { m.set(k, String(v)); },
342
+ removeItem: (k) => { m.delete(k); },
343
+ clear: () => m.clear(),
344
+ };
345
+ }
package/dist/vite.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Plugin } from 'vite';
2
+ import { type ProjectOptions } from './project.js';
3
+ export interface SinterOptions extends ProjectOptions {
4
+ /** run `sinter check` at the end of `vite build` and fail it on type errors (needs TypeScript) */
5
+ typecheck?: boolean;
6
+ }
7
+ export default function sinter(options?: SinterOptions): Plugin & {
8
+ api: {
9
+ options: SinterOptions;
10
+ };
11
+ };
package/dist/vite.js ADDED
@@ -0,0 +1,94 @@
1
+ // Vite adapter: a thin wrapper around compile(). Every HTML entry is compiled
2
+ // in transformIndexHtml; its page module is served as a virtual module.
3
+ import { relative, sep } from 'node:path';
4
+ import { compile, formatDiagnostic } from './compiler/index.js';
5
+ import { project } from './project.js';
6
+ const PREFIX = '/@sinter/';
7
+ export default function sinter(options = {}) {
8
+ let root = process.cwd();
9
+ let dev = false;
10
+ let server = null;
11
+ const modules = new Map();
12
+ const watched = new Set();
13
+ const units = new Map();
14
+ const watch = (file) => {
15
+ if (watched.has(file))
16
+ return;
17
+ watched.add(file);
18
+ server?.watcher.add(file);
19
+ };
20
+ return {
21
+ name: 'sinter',
22
+ enforce: 'pre',
23
+ api: { options },
24
+ configResolved(config) {
25
+ root = config.root;
26
+ dev = config.command === 'serve';
27
+ },
28
+ configureServer(s) {
29
+ server = s;
30
+ // an HTML, partial or helpers change triggers a full reload in v0.1
31
+ s.watcher.on('change', (file) => {
32
+ if (watched.has(file))
33
+ s.ws.send({ type: 'full-reload' });
34
+ });
35
+ },
36
+ resolveId(id) {
37
+ if (id.startsWith(PREFIX))
38
+ return '\0' + id;
39
+ return null;
40
+ },
41
+ load(id) {
42
+ if (!id.startsWith('\0' + PREFIX))
43
+ return null;
44
+ const code = modules.get(id.slice(1));
45
+ if (code === undefined)
46
+ throw new Error(`sinter: page module ${id.slice(1)} requested before its HTML was compiled`);
47
+ return code;
48
+ },
49
+ async buildEnd(error) {
50
+ if (error || !options.typecheck || dev || !units.size)
51
+ return;
52
+ const { check } = await import('./check/index.js');
53
+ const p = project(root, options);
54
+ const r = check({ root, units: [...units.values()], helpers: p.helpers, types: p.types, openapi: p.openapi });
55
+ units.clear();
56
+ if (r.diagnostics.length)
57
+ this.error(`sinter check: ${r.diagnostics.length} type error(s)\n${r.diagnostics.map(formatDiagnostic).join('\n')}`);
58
+ },
59
+ transformIndexHtml: {
60
+ order: 'pre',
61
+ handler(html, ctx) {
62
+ const file = ctx.filename;
63
+ const rel = relative(root, file).split(sep).join('/');
64
+ const moduleSrc = `${PREFIX}${rel}.js`;
65
+ const p = project(root, options);
66
+ if (p.diagnostics.length)
67
+ throw new Error(`sinter: ${p.diagnostics.map(formatDiagnostic).join('\n')}`);
68
+ const out = compile(html, {
69
+ file, root, dev, helpers: p.helpers, moduleSrc, config: p.config, readFile: p.readFile,
70
+ check: !!options.typecheck && !dev, openapi: p.openapi,
71
+ });
72
+ if (out.check)
73
+ units.set(file, out.check);
74
+ for (const f of out.files)
75
+ watch(f);
76
+ if (p.helpersFile)
77
+ watch(p.helpersFile);
78
+ const errors = out.diagnostics.filter((d) => d.severity === 'error');
79
+ for (const d of out.diagnostics)
80
+ if (d.severity === 'warning')
81
+ console.warn(formatDiagnostic(d));
82
+ if (errors.length) {
83
+ const err = new Error(`sinter: ${errors.length} error(s)\n${errors.map(formatDiagnostic).join('\n')}`);
84
+ err.id = errors[0].file;
85
+ err.loc = { file: errors[0].file, line: errors[0].line, column: errors[0].col };
86
+ throw err;
87
+ }
88
+ modules.set(moduleSrc, out.module);
89
+ server?.moduleGraph.getModuleById('\0' + moduleSrc) && server.moduleGraph.invalidateModule(server.moduleGraph.getModuleById('\0' + moduleSrc));
90
+ return out.html;
91
+ },
92
+ },
93
+ };
94
+ }
package/dist/ws.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { Transport } from './runtime/app.js';
2
+ export interface WebSocketTransportOptions {
3
+ /** subprotocols for the WebSocket constructor */
4
+ protocols?: string | string[];
5
+ /**
6
+ * Turns one text message into `[event name, payload]`, or `null` to skip it. Default: the
7
+ * message is JSON `{"event": "<name>", "data": <payload>}`.
8
+ */
9
+ decode?: (message: string) => [name: string, payload: unknown] | null;
10
+ }
11
+ /** The default message format: `{"event": "contact_updated", "data": {…}}`. */
12
+ export declare function decodeMessage(message: string): [string, unknown] | null;
13
+ /**
14
+ * One WebSocket per stream. Delivers the mapped event names and those islands listen to later;
15
+ * other messages are dropped, as EventSource drops events nobody listens for. A close, clean or
16
+ * not, reports `error`: `<sn-sync>` then runs the session probe, backs off and reconnects.
17
+ */
18
+ export declare function webSocketTransport(options?: WebSocketTransportOptions): Transport;
package/dist/ws.js ADDED
@@ -0,0 +1,57 @@
1
+ // WebSocket transport for <sn-sync>. Opt in from the config module:
2
+ // configure({ transport: (url) => url.startsWith('/ws/') ? webSocketTransport() : undefined })
3
+ // The stream URL stays an http(s) URL in the HTML (the compiler's URL rules apply); the
4
+ // transport opens it as ws(s). Server to client only: actions still go over HTTP.
5
+ import { config } from './runtime/config.js';
6
+ /** The default message format: `{"event": "contact_updated", "data": {…}}`. */
7
+ export function decodeMessage(message) {
8
+ let m;
9
+ try {
10
+ m = JSON.parse(message);
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ if (!m || typeof m !== 'object' || typeof m.event !== 'string')
16
+ return null;
17
+ return [m.event, m.data ?? null];
18
+ }
19
+ /**
20
+ * One WebSocket per stream. Delivers the mapped event names and those islands listen to later;
21
+ * other messages are dropped, as EventSource drops events nobody listens for. A close, clean or
22
+ * not, reports `error`: `<sn-sync>` then runs the session probe, backs off and reconnects.
23
+ */
24
+ export function webSocketTransport(options = {}) {
25
+ const decode = options.decode ?? decodeMessage;
26
+ let ws = null;
27
+ const names = new Set();
28
+ return {
29
+ connect(url, onEvent, onState, initial) {
30
+ const u = new URL(url, config.env.href());
31
+ u.protocol = u.protocol === 'http:' ? 'ws:' : u.protocol === 'https:' ? 'wss:' : u.protocol;
32
+ names.clear();
33
+ for (const n of initial)
34
+ names.add(n);
35
+ const s = ws = new WebSocket(u.href, options.protocols);
36
+ s.onopen = () => { if (ws === s)
37
+ onState('open'); };
38
+ s.onclose = () => { if (ws === s) {
39
+ ws = null;
40
+ onState('error');
41
+ } };
42
+ s.onmessage = (e) => {
43
+ if (ws !== s || typeof e.data !== 'string')
44
+ return;
45
+ const m = decode(e.data);
46
+ if (m && names.has(m[0]))
47
+ onEvent(m[0], m[1]);
48
+ };
49
+ },
50
+ listen(name) { names.add(name); },
51
+ close() {
52
+ const s = ws;
53
+ ws = null; // handlers check this first: nothing is reported after close()
54
+ s?.close();
55
+ },
56
+ };
57
+ }