@zubra/server 5.4.1 → 5.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/server",
3
- "version": "5.4.1",
3
+ "version": "5.5.0",
4
4
  "description": "Zubra on the server: render components into any template engine's pages, serve their browser modules, and give frameworks' HTML its components' shadow roots.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -21,14 +21,22 @@
21
21
  "./hono": {
22
22
  "types": "./src/adapters.d.ts",
23
23
  "default": "./src/hono.js"
24
+ },
25
+ "./edge": {
26
+ "types": "./src/edge.d.ts",
27
+ "default": "./src/edge.js"
28
+ },
29
+ "./hono-edge": {
30
+ "types": "./src/hono-edge.d.ts",
31
+ "default": "./src/hono-edge.js"
24
32
  }
25
33
  },
26
34
  "scripts": {
27
35
  "test": "node --test \"test/*.test.js\""
28
36
  },
29
37
  "peerDependencies": {
30
- "@zubra/compiler": "^5.4.0",
31
- "@zubra/runtime": "^5.4.0"
38
+ "@zubra/compiler": "^5.5.0",
39
+ "@zubra/runtime": "^5.5.0"
32
40
  },
33
41
  "devDependencies": {
34
42
  "@minify-html/node": "0.18.1",
@@ -36,6 +44,7 @@
36
44
  "@zubra/compiler": "file:../compiler",
37
45
  "@zubra/runtime": "file:../runtime",
38
46
  "dompurify": "3.4.17",
47
+ "esbuild": "0.28.2",
39
48
  "express": "5.3.0",
40
49
  "fastify": "5.12.8",
41
50
  "hono": "4.13.17",
package/src/core.js ADDED
@@ -0,0 +1,520 @@
1
+ // The part of @zubra/server that needs no host: it takes the compiler's `request` and the
2
+ // components' paths and sources, and does everything else: rendering, CSS, browser modules, a
3
+ // page's head and scripts, streaming, and shadow roots for frameworks' HTML. It imports nothing
4
+ // from Node and never looks at `import.meta`, so it runs in Cloudflare Workers and Deno as well
5
+ // as Node. `index.js` is this plus the file system (finding the components, serving the runtime
6
+ // from disk); `edge.js` is this plus a map of sources and the compiler's WebAssembly module.
7
+
8
+ const SOURCE_EXTENSION = /\.zb(?:\.[jt]sx)?$/;
9
+ const escapeAttribute = (text) => String(text).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
10
+ const TYPES = { '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json' };
11
+
12
+ export { SOURCE_EXTENSION, TYPES };
13
+
14
+ /**
15
+ * `request(body)`: the compiler (`{ ok, output, error }`). `files`: a Map from component name
16
+ * (the path without its extension, "/"-separated) to the path its source is known by.
17
+ * `sourcesOf(path)`: that component's source and the sources of the components it imports, by
18
+ * path. `where`: how an error names the place the components came from. `assets(rest)`: what the
19
+ * host serves under `<base>/` beyond the components' modules (`{ status, type, body }` or null).
20
+ * `needs(name, code)`: the host's check on a compiled module (a package it needs being installed).
21
+ */
22
+ export function createCore({ request, files, sourcesOf: readSources, cache = true, devtools = false, base, imports = {}, syncImports = {}, needs = (_name, code) => code, where = 'the components', assets = () => null }) {
23
+ const memo = new Map();
24
+ const once = (key, make) => {
25
+ if (!cache || !memo.has(key)) memo.set(key, make());
26
+ return memo.get(key);
27
+ };
28
+ const pathOf = (name) => {
29
+ const path = files.get(name) ?? files.get(byTag().get(name));
30
+ if (!path) throw new Error(`@zubra/server: no component "${name}" in ${where} (have: ${[...files.keys()].join(', ')})`);
31
+ return path;
32
+ };
33
+ const sourcesOf = (path) => once(`sources:${path}`, () => readSources(path));
34
+ const call = (path, body) => {
35
+ const response = request({ path, files: sourcesOf(path), ...body });
36
+ if (!response.ok) throw new Error(response.error);
37
+ return response.output;
38
+ };
39
+
40
+ /** Each component's interface: `{ tag, shadow, form, props: [{ name, attribute, type, default, model }] }`. */
41
+ const describe = (name) => once(`interface:${name}`, () => JSON.parse(call(pathOf(name), { emit: 'interface' })));
42
+ const byTag = () => once('tags', () => new Map([...files.keys()].map((name) => [describe(name).tag, name])));
43
+ const nameOf = (nameOrTag) => (files.has(nameOrTag) ? nameOrTag : byTag().get(nameOrTag));
44
+
45
+ const render = (name, props = {}, slots = {}, pending = []) =>
46
+ call(pathOf(name), { emit: 'html', props, slots, slotSource: 'html', partial: true, ...(pending.length ? { pending } : {}) });
47
+ const css = (name) => once(`css:${name}`, () => call(pathOf(name), { emit: 'css' }));
48
+ const js = (name) => once(`js:${name}`, () => {
49
+ const path = pathOf(name);
50
+ const response = request({ emit: 'js', runtime: '@zubra/runtime', devtools, path, files: sourcesOf(path) });
51
+ if (!response.ok) throw new Error(response.error);
52
+ return needs(name, response.output);
53
+ });
54
+
55
+ const importMap = () => `<script type="importmap">${JSON.stringify({ imports: { '@zubra/runtime': `${base}/runtime/index.js`, '@zubra/runtime/spread': `${base}/runtime/spread.js`, '@zubra/runtime/primitives': `${base}/runtime/primitives.js`, '@zubra/runtime/motion': `${base}/runtime/motion.js`, ...syncImports, ...imports } })}</script>`;
56
+ const head = (names) => {
57
+ // A component that needs a package that isn't installed fails here, while the page is built,
58
+ // with the reason, rather than later in the browser as an unresolved import.
59
+ for (const name of names) js(name);
60
+ const styles = names.map(css).join('\n');
61
+ return `${styles ? `<style data-zb-components>${styles}</style>\n` : ''}${importMap()}`;
62
+ };
63
+ const scripts = (names, async = false) => (names.length ? `<script type="module"${async ? ' async' : ''}>${names.map((name) => `import '${base}/components/${name}.js';`).join(' ')}</script>` : '');
64
+
65
+ /** What a request for `pathname` gets: `{ status, type, body }`, or null if it isn't ours. */
66
+ function serve(pathname) {
67
+ if (!pathname.startsWith(`${base}/`)) return null;
68
+ const rest = decodeURIComponent(pathname.slice(base.length + 1));
69
+ if (rest.startsWith('components/') && rest.endsWith('.js')) {
70
+ const name = rest.slice('components/'.length, -3);
71
+ if (!files.has(name)) return { status: 404, type: 'text/plain', body: 'Not found' };
72
+ return { status: 200, type: TYPES['.js'], body: js(name) };
73
+ }
74
+ // The runtime and @zubra/sync are the host's to serve (from disk on Node, from static assets elsewhere).
75
+ return assets(rest);
76
+ }
77
+ const cacheControl = cache ? 'public, max-age=3600' : 'no-cache';
78
+
79
+ /** Fetch-style servers: a Response for our paths, or null. */
80
+ async function fetchHandler(request) {
81
+ const served = serve(new URL(request.url).pathname);
82
+ if (!served) return null;
83
+ return new Response(served.body, { status: served.status, headers: { 'content-type': served.type, 'cache-control': cacheControl } });
84
+ }
85
+
86
+ /** One page: render components into it, then `finish()` it (or use `head()` and `scripts()`). */
87
+ function page() {
88
+ const used = [];
89
+ const use = (name) => {
90
+ if (!used.includes(name)) used.push(name);
91
+ };
92
+ // Streaming (3.3): a prop given as a promise renders as pending (its `zb-await` shows its
93
+ // pending content, in an empty region), and `stream()` sends the resolved region after the
94
+ // page, with the value, for the element to hydrate.
95
+ const pendingRenders = [];
96
+ let streamed = 0;
97
+ return {
98
+ used,
99
+ render(name, props = {}, slots) {
100
+ const key = nameOf(name) ?? name;
101
+ use(key);
102
+ const promises = Object.entries(props).filter(([, value]) => value && typeof value.then === 'function');
103
+ if (!promises.length) return render(key, props, slots);
104
+ const given = Object.fromEntries(Object.entries(props).filter(([, value]) => !(value && typeof value.then === 'function')));
105
+ const names = promises.map(([name]) => name);
106
+ const id = String(streamed++);
107
+ const shell = withStreamId(render(key, given, slots, names), id);
108
+ const entry = { key, id, given, slots, promises: new Map(promises), shell, count: 0 };
109
+ // Each pending region is a named range (`<?start name>`…`<?end>`) that a streamed
110
+ // `<template for>` replaces (out-of-order streaming).
111
+ entry.names = pendingRegions(shell).map(() => `zb-${id}-${entry.count++}`);
112
+ pendingRenders.push(entry);
113
+ // Until the values arrive, the element (should it start first) holds a promise for
114
+ // each, set before it upgrades, so its `zb-await` shows the pending content.
115
+ return `${named(shell, entry.names)}<script>(${HOLD_IN_BROWSER})(${JSON.stringify(id)},${JSON.stringify(names)})</script>`;
116
+ },
117
+ /** Whether a rendered component is waiting on a promise prop (then `stream()`, not `finish()`). */
118
+ get pending() {
119
+ return pendingRenders.length > 0;
120
+ },
121
+ /**
122
+ * The page as a stream: `finish(html)` first, then, as each promise prop settles, the
123
+ * resolved regions, each in a `<template for>` (which the browser puts in place itself
124
+ * where it streams out of order), and a short inline script that hands the element the
125
+ * value, and puts the regions in place where the browser didn't.
126
+ * `streamResponse(html)` is a web `Response`; `chunks(html)` the strings it is made of
127
+ * (Node's `stream(html)` turns them into a Readable).
128
+ */
129
+ chunks,
130
+ streamResponse(html, init = {}) {
131
+ return new Response(webStream(chunks(html)), { ...init, headers: { 'content-type': 'text/html; charset=utf-8', ...init.headers } });
132
+ },
133
+ /** The components' CSS and the import map, for the page's `<head>`. */
134
+ head: () => head(used),
135
+ /** The module script that makes the components interactive, for the end of `<body>`. */
136
+ scripts: () => scripts(used),
137
+ /** A whole page with the head and scripts put in (before `</head>` and `</body>`). */
138
+ finish(html) {
139
+ return insertBefore(insertBefore(html, '</head>', head(used)), '</body>', scripts(used));
140
+ },
141
+ };
142
+
143
+ async function* chunks(html) {
144
+ // The shell: as the ordinary page, but with the module script `async`, so the elements
145
+ // start as soon as it has loaded instead of when the stream ends (a module script is
146
+ // deferred until the document is complete). A region that arrives after an element has
147
+ // started reaches it as a property change.
148
+ yield insertBefore(insertBefore(html, '</head>', head(used)), '</body>', scripts(used, true));
149
+ const waiting = pendingRenders.splice(0).flatMap((entry) =>
150
+ [...entry.promises].map(([name, promise]) => promise.then(
151
+ (value) => ({ entry, name, value }),
152
+ (error) => ({ entry, name, error: error instanceof Error ? error.message : String(error) }),
153
+ )));
154
+ // In the order they settle.
155
+ const open = new Set(waiting.map((promise) => {
156
+ const tagged = promise.then((result) => ({ result, tagged }));
157
+ return tagged;
158
+ }));
159
+ while (open.size) {
160
+ const { result, tagged } = await Promise.race(open);
161
+ open.delete(tagged);
162
+ const { entry, name, value, error } = result;
163
+ if (error !== undefined) {
164
+ yield swapScript(entry.id, null, { [name]: { error } });
165
+ continue;
166
+ }
167
+ entry.given[name] = value;
168
+ entry.promises.delete(name);
169
+ const resolved = withStreamId(render(entry.key, entry.given, entry.slots, [...entry.promises.keys()]), entry.id);
170
+ const swaps = new Map(regionSwaps(entry.shell, resolved).map((swap) => [swap.index, swap.html]));
171
+ entry.shell = resolved;
172
+ // The regions still pending keep their names; one that resolved is replaced, along with
173
+ // its name, by the pending regions inside it.
174
+ const regions = [];
175
+ entry.names = entry.names.flatMap((region, index) => {
176
+ if (!swaps.has(index)) return [region];
177
+ const html = swaps.get(index);
178
+ const inner = pendingRegions(html).map(() => `zb-${entry.id}-${entry.count++}`);
179
+ regions.push({ name: region, html: named(html, inner) });
180
+ return inner;
181
+ });
182
+ yield swapScript(entry.id, regions, { [name]: { value } });
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Shadow roots for a framework's HTML: each shadow-DOM component's host gets its
189
+ * server-rendered template as a declarative shadow root (read from the host's `zb-props`
190
+ * JSON, or its attributes), so it shows before JavaScript; the browser attaches it while
191
+ * parsing, before any framework hydrates. Form-associated components with a `name` also get a
192
+ * hidden input with their value, so the form submits without JavaScript (the element removes
193
+ * it when it starts). Light-DOM components are left alone unless `lightDom: true` (3.3): then
194
+ * an empty light-DOM host gets its rendered children, for frameworks that leave nodes they
195
+ * didn't render alone while hydrating (Solid; React and Vue through their wrappers do this
196
+ * themselves). Not for Svelte, which claims every node.
197
+ */
198
+ /** The components by tag (shadow-DOM ones, or all with `lightDom`), and a pattern for their hosts' start tags. */
199
+ function shadowHosts(lightDom = false) {
200
+ const shadowTags = new Map();
201
+ for (const name of files.keys()) {
202
+ const info = describe(name);
203
+ if (info.shadow || lightDom) shadowTags.set(info.tag, { name, info });
204
+ }
205
+ const pattern = shadowTags.size ? new RegExp(`<(${[...shadowTags.keys()].map((tag) => tag.replace(/[-]/g, '\\-')).join('|')})(\\s[^>]*)?>`, 'g') : null;
206
+ return { shadowTags, pattern };
207
+ }
208
+
209
+ function injectShadowRoots(html, { forms = true, lightDom = false } = {}) {
210
+ const { shadowTags, pattern } = shadowHosts(lightDom);
211
+ if (!pattern) return html;
212
+ let out = '';
213
+ let last = 0;
214
+ for (const match of html.matchAll(pattern)) {
215
+ const end = match.index + match[0].length;
216
+ const { name, info } = shadowTags.get(match[1]);
217
+ // Already has one (rendered by zubra, or a second pass); a light-DOM host must be empty.
218
+ if (info.shadow ? /^\s*<template\s[^>]*shadowroot/i.test(html.slice(end, end + 200)) : !new RegExp(`^\\s*</${match[1]}>`).test(html.slice(end, end + 200))) continue;
219
+ const props = propsFrom(match[2] ?? '', info);
220
+ let rendered;
221
+ try {
222
+ rendered = render(name, props);
223
+ } catch {
224
+ continue; // leave it to the browser rather than break the page
225
+ }
226
+ const template = info.shadow ? shadowTemplate(rendered) : lightContent(rendered, match[1]);
227
+ if (!template) continue;
228
+ out += html.slice(last, end) + template + (forms ? fallbackInput(match[2] ?? '', info, props) : '');
229
+ last = end;
230
+ }
231
+ return out + html.slice(last);
232
+ }
233
+
234
+
235
+ return {
236
+ files,
237
+ pathOf,
238
+ serve,
239
+ cacheControl,
240
+ shadowHosts,
241
+ injectShadowRoots,
242
+ page,
243
+ importMap,
244
+ fetch: fetchHandler,
245
+ module: (name) => js(nameOf(name) ?? name),
246
+ names: [...files.keys()],
247
+ describe: (name) => describe(nameOf(name) ?? name),
248
+ render: (name, props, slots) => render(nameOf(name) ?? name, props, slots),
249
+ head: (names = [...files.keys()]) => head(names),
250
+ scripts: (names = [...files.keys()]) => scripts(names),
251
+ };
252
+ }
253
+
254
+ /** An async iterable of strings as a web ReadableStream of UTF-8 bytes. */
255
+ function webStream(chunks) {
256
+ const encoder = new TextEncoder();
257
+ const iterator = chunks[Symbol.asyncIterator]();
258
+ return new ReadableStream({
259
+ async pull(controller) {
260
+ const { value, done } = await iterator.next();
261
+ if (done) controller.close();
262
+ else controller.enqueue(encoder.encode(value));
263
+ },
264
+ async cancel() {
265
+ await iterator.return?.();
266
+ },
267
+ });
268
+ }
269
+
270
+ function insertBefore(html, marker, text) {
271
+ if (!text) return html;
272
+ const at = html.lastIndexOf(marker);
273
+ return at < 0 ? html + text : html.slice(0, at) + text + html.slice(at);
274
+ }
275
+
276
+ // ---- reading a host tag ---------------------------------------------------------------------
277
+
278
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", '#34': '"' };
279
+ const decode = (text) => text.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, name) => {
280
+ if (ENTITIES[name.toLowerCase()]) return ENTITIES[name.toLowerCase()];
281
+ if (name[0] === '#') return String.fromCodePoint(name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : Number(name.slice(1)));
282
+ return whole;
283
+ });
284
+
285
+ /** An opening tag's attributes: `{ name: value }` (a bare attribute is `""`). */
286
+ export function parseAttributes(text) {
287
+ const attributes = {};
288
+ const pattern = /([^\s"'>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
289
+ for (const match of text.matchAll(pattern)) {
290
+ attributes[match[1].toLowerCase()] = decode(match[2] ?? match[3] ?? match[4] ?? '');
291
+ }
292
+ return attributes;
293
+ }
294
+
295
+ /** The props a host tag carries: `zb-props` JSON first, then each prop's attribute by its type. */
296
+ export function propsFrom(attributeText, info) {
297
+ const attributes = parseAttributes(attributeText);
298
+ let props = {};
299
+ if (attributes['zb-props']) {
300
+ try {
301
+ props = JSON.parse(attributes['zb-props']);
302
+ } catch {}
303
+ }
304
+ for (const prop of info.props) {
305
+ if (prop.name in props || !(prop.attribute in attributes)) continue;
306
+ const raw = attributes[prop.attribute];
307
+ const kind = Array.isArray(prop.default) || (prop.default && typeof prop.default === 'object') ? 'json' : prop.type;
308
+ if (kind === 'number') {
309
+ const number = Number(raw);
310
+ if (raw.trim() !== '' && Number.isFinite(number)) props[prop.name] = number;
311
+ } else if (kind === 'boolean') {
312
+ props[prop.name] = raw !== 'false';
313
+ } else if (kind === 'json' || /\[\]$|^\{/.test(prop.type)) {
314
+ if (raw === '[object Object]') continue; // Svelte's SSR: the real value comes later
315
+ try {
316
+ props[prop.name] = JSON.parse(raw);
317
+ } catch {}
318
+ } else {
319
+ props[prop.name] = raw;
320
+ }
321
+ }
322
+ return props;
323
+ }
324
+
325
+ /** The `<template shadowrootmode>…</template>` at the start of a rendered host's content. */
326
+ function shadowTemplate(rendered) {
327
+ const start = rendered.search(/<template\s[^>]*shadowrootmode/i);
328
+ if (start < 0) return null;
329
+ const tags = /<template\b[^>]*>|<\/template>/gi;
330
+ tags.lastIndex = start;
331
+ let depth = 0;
332
+ for (const match of rendered.matchAll(tags)) {
333
+ if (match.index < start) continue;
334
+ depth += match[0][1] === '/' ? -1 : 1;
335
+ if (depth === 0) return rendered.slice(start, match.index + match[0].length);
336
+ }
337
+ return null;
338
+ }
339
+
340
+ /** The host's start tag with `data-zb-stream="<id>"`, so the stream's scripts can find it. */
341
+ function withStreamId(html, id) {
342
+ return html.replace(/^(\s*<[a-z][\w.-]*)/, `$1 data-zb-stream="${id}"`);
343
+ }
344
+
345
+ /**
346
+ * The pending regions of `shell` that `resolved` has rendered: `[{ index, html }]`, where
347
+ * `index` counts the shell's top-level `<?zb [??>` regions in order and `html` is the resolved
348
+ * region (markers included). The two renders differ only inside those regions (and the host's
349
+ * start tag, which is compared past).
350
+ */
351
+ function regionSwaps(shell, resolved) {
352
+ const skipTag = (html) => html.match(/^\s*<[a-z][\w.-]*(?:\s[^>]*)?>/)?.[0].length ?? 0;
353
+ let a = skipTag(shell);
354
+ let b = skipTag(resolved);
355
+ const swaps = [];
356
+ let index = 0;
357
+ const PENDING = '<?zb [??>';
358
+ const regionEnd = (html, start) => {
359
+ // From the region's opening marker to its matching `<?zb ]?>`.
360
+ let depth = 0;
361
+ for (const match of html.slice(start).matchAll(/<\?zb \[[?!]?\?>|<\?zb \]\?>/g)) {
362
+ depth += match[0] === '<?zb ]?>' ? -1 : 1;
363
+ if (depth === 0) return start + match.index + match[0].length;
364
+ }
365
+ return html.length;
366
+ };
367
+ while (a < shell.length && b < resolved.length) {
368
+ if (shell.startsWith(PENDING, a)) {
369
+ const endA = regionEnd(shell, a);
370
+ const endB = regionEnd(resolved, b);
371
+ const region = resolved.slice(b, endB);
372
+ if (!region.startsWith(PENDING)) swaps.push({ index, html: region });
373
+ index++;
374
+ a = endA;
375
+ b = endB;
376
+ continue;
377
+ }
378
+ if (shell[a] !== resolved[b]) break; // not a difference this function understands
379
+ a++;
380
+ b++;
381
+ }
382
+ return swaps;
383
+ }
384
+
385
+ /**
386
+ * The top-level pending regions of `html` (`<?zb [??>` to its `<?zb ]?>`, not inside another
387
+ * pending region), as `[start, end]` offsets: the ones `regionSwaps` counts.
388
+ */
389
+ function pendingRegions(html) {
390
+ const found = [];
391
+ let depth = 0;
392
+ let start = -1;
393
+ let outer = 0;
394
+ for (const match of html.matchAll(/<\?zb (\[[?!]?|\])\?>/g)) {
395
+ if (match[1] === ']') {
396
+ depth--;
397
+ if (start >= 0 && depth === outer) {
398
+ found.push([start, match.index + match[0].length]);
399
+ start = -1;
400
+ }
401
+ continue;
402
+ }
403
+ if (match[1] === '[?' && start < 0) {
404
+ start = match.index;
405
+ outer = depth;
406
+ }
407
+ depth++;
408
+ }
409
+ return found;
410
+ }
411
+
412
+ /** `html` with its top-level pending regions in named ranges, for `<template for>`. */
413
+ function named(html, names) {
414
+ let out = html;
415
+ pendingRegions(html).reverse().forEach(([start, end], i, all) => {
416
+ const name = names[all.length - 1 - i];
417
+ out = `${out.slice(0, start)}<?start name="${name}"?>${out.slice(start, end)}<?end?>${out.slice(end)}`;
418
+ });
419
+ return out;
420
+ }
421
+
422
+ /** The chunk for one settled promise: each resolved region in a `<template for>`, and the script. */
423
+ function swapScript(id, regions, values) {
424
+ const templates = (regions ?? []).map((region) => `<template for="${region.name}">${region.html}</template>`).join('');
425
+ const script = `<script>(${SWAP_IN_BROWSER})(${JSON.stringify(id)},${JSON.stringify(values)})</script>`;
426
+ return `${templates}${script}\n`;
427
+ }
428
+
429
+ // The two scripts below run in the browser, inlined into the page. They are kept as text, not as
430
+ // functions stringified with toString(): a bundler rewrites a function's body for the server it
431
+ // builds (esbuild's keep-names, which Wrangler turns on, wraps `var kind = function…` in a
432
+ // `__name()` helper that exists only in the bundle), and the page would then throw.
433
+ //
434
+ // Runs in the browser, inlined, right after a streamed host: a promise per pending prop, as a
435
+ // property the element captures when it upgrades, resolved by the swap script later.
436
+ const HOLD_IN_BROWSER = String.raw`function (id, names) {
437
+ var host = document.querySelector('[data-zb-stream="' + id + '"]');
438
+ var held = (window.__zubraStream = window.__zubraStream || {});
439
+ for (var i = 0; i < names.length; i++) {
440
+ (function (name) {
441
+ host[name] = new Promise(function (resolve, reject) { held[id + ':' + name] = { resolve: resolve, reject: reject }; });
442
+ })(names[i]);
443
+ }
444
+ }`;
445
+
446
+ // Runs in the browser, inlined: hand the element the values (as properties, captured when it
447
+ // upgrades), and, before it starts, put the resolved regions in place where the browser didn't:
448
+ // browsers without out-of-order streaming leave each `<template for>` as an element, and those
449
+ // with it don't reach into a shadow root. Once the element has started it renders the regions
450
+ // itself (it removed their names, so the browser has nothing to replace).
451
+ const SWAP_IN_BROWSER = String.raw`function (id, values) {
452
+ var host = document.querySelector('[data-zb-stream="' + id + '"]');
453
+ var script = document.currentScript;
454
+ var templates = [];
455
+ for (var node = script && script.previousElementSibling; node && node.tagName === 'TEMPLATE' && node.hasAttribute('for'); node = node.previousElementSibling) templates.push(node);
456
+ // A <?start name> or <?end>: a processing instruction, or the comment it parses as.
457
+ var kind = function (node) {
458
+ return node.nodeType === 7 ? node.target : node.nodeType === 8 ? (/^\?(start|end)\b/.exec(node.data) || [])[1] : null;
459
+ };
460
+ var started = host && host.matches(':state(hydrated), [data-hydrated]');
461
+ for (var i = 0; i < templates.length; i++) {
462
+ var template = templates[i];
463
+ template.remove();
464
+ if (!host || started) continue;
465
+ var name = 'name="' + template.getAttribute('for') + '"';
466
+ var start = null;
467
+ var roots = host.shadowRoot ? [host.shadowRoot, host] : [host];
468
+ for (var r = 0; r < roots.length && !start; r++) {
469
+ var walker = document.createTreeWalker(roots[r], NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_PROCESSING_INSTRUCTION);
470
+ for (var at = walker.nextNode(); at; at = walker.nextNode()) {
471
+ if (kind(at) === 'start' && at.data.replace(/^\?start |\?$/g, '') === name) { start = at; break; }
472
+ }
473
+ }
474
+ var end = start && start.nextSibling;
475
+ for (var depth = 0; end; end = end.nextSibling) {
476
+ if (kind(end) === 'start') depth++;
477
+ else if (kind(end) === 'end' && depth-- === 0) break;
478
+ }
479
+ if (!end) continue;
480
+ var range = document.createRange();
481
+ range.setStartBefore(start);
482
+ range.setEndAfter(end);
483
+ range.deleteContents();
484
+ range.insertNode(template.content);
485
+ }
486
+ var held = window.__zubraStream || {};
487
+ for (var name in values) {
488
+ var hold = held[id + ':' + name];
489
+ if (values[name].error !== undefined) {
490
+ var rejected = Promise.reject(new Error(values[name].error));
491
+ rejected.catch(function () {});
492
+ if (hold) hold.reject(new Error(values[name].error));
493
+ else host[name] = rejected;
494
+ } else {
495
+ if (hold) hold.resolve(values[name].value);
496
+ host[name] = values[name].value;
497
+ }
498
+ delete held[id + ':' + name];
499
+ }
500
+ }`;
501
+
502
+ /** A rendered light-DOM host's children: between its start tag and its end tag. */
503
+ function lightContent(rendered, tag) {
504
+ const open = rendered.match(/^\s*<[a-z][\w.-]*(?:\s[^>]*)?>/);
505
+ const close = rendered.lastIndexOf(`</${tag}>`);
506
+ if (!open || close < open[0].length) return null;
507
+ return rendered.slice(open[0].length, close);
508
+ }
509
+
510
+ /** A form-associated component's value, as the form would submit it, in a hidden input. */
511
+ function fallbackInput(attributeText, info, props) {
512
+ if (!info.form) return '';
513
+ const name = parseAttributes(attributeText).name;
514
+ const models = info.props.filter((prop) => prop.model);
515
+ if (!name || models.length !== 1) return '';
516
+ const value = models[0].name in props ? props[models[0].name] : models[0].default;
517
+ if (value == null || value === false || value === '') return '';
518
+ const text = value === true ? 'on' : typeof value === 'object' ? JSON.stringify(value) : String(value);
519
+ return `<input type="hidden" name="${escapeAttribute(name)}" value="${escapeAttribute(text)}" data-zb-fallback>`;
520
+ }
package/src/edge.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { Compiler } from '@zubra/compiler/wasm';
2
+ import type { ComponentInfo, Page, PropInfo } from './index.js';
3
+
4
+ export type { ComponentInfo, PropInfo };
5
+
6
+ /** A page on a host without Node: as Node's, minus `stream()` (a Node Readable); use `streamResponse()`. */
7
+ export type EdgePage = Omit<Page, 'stream'>;
8
+
9
+ export interface EdgeZubra {
10
+ /** The components given, by name. */
11
+ readonly names: string[];
12
+ describe(name: string): ComponentInfo;
13
+ render(name: string, props?: Record<string, unknown>, slots?: Record<string, string>): string;
14
+ page(): EdgePage;
15
+ /** A Response for the components' browser modules under the prefix, or null for any other request. */
16
+ fetch(request: Request): Promise<Response | null>;
17
+ importMap(): string;
18
+ head(names?: string[]): string;
19
+ scripts(names?: string[]): string;
20
+ injectShadowRoots(html: string, options?: { forms?: boolean; lightDom?: boolean }): string;
21
+ /** A component's browser module (JavaScript). */
22
+ module(name: string): string;
23
+ }
24
+
25
+ export interface EdgeOptions {
26
+ /** The compiler's WebAssembly module (`import wasm from '@zubra/compiler/zubra.wasm'` in a Worker). */
27
+ wasm?: WebAssembly.Module;
28
+ /** Or an already made compiler (`createCompiler(wasm)` from `@zubra/compiler/wasm`). */
29
+ compiler?: Compiler;
30
+ /** `{ name: source }`: a name is the path without its extension (`forms/field`), or with it for `.zb.tsx`/`.zb.jsx`. */
31
+ components: Record<string, string>;
32
+ /** Where the browser modules are served (default `/_zubra`). */
33
+ prefix?: string;
34
+ /** Keep compiled output (default true). */
35
+ cache?: boolean;
36
+ /** Compile for the Zubra DevTools (default false: the runtime build they read isn't served from here). */
37
+ devtools?: boolean;
38
+ /** More import map entries; use it to point `@zubra/runtime` at a CDN, or at your static assets. */
39
+ imports?: Record<string, string>;
40
+ }
41
+
42
+ export declare function createZubra(options: EdgeOptions): EdgeZubra;
43
+ export declare function parseAttributes(text: string): Record<string, string>;
44
+ export declare function propsFrom(attributeText: string, info: ComponentInfo): Record<string, unknown>;