@oomfware/jsx 0.1.1

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.
@@ -0,0 +1,504 @@
1
+ /**
2
+ * streaming JSX renderer
3
+ *
4
+ * architecture:
5
+ * - segment tree: we build a tree of segments (static text, composites, suspense
6
+ * boundaries) then serialize to HTML
7
+ * - suspense: components can throw promises via use(), caught at Suspense boundaries
8
+ * which render fallback immediately and stream resolved content later
9
+ * - head hoisting: <title>, <meta>, <link>, <style> found outside <head> are
10
+ * collected and injected into <head> during finalization
11
+ */
12
+
13
+ import { Fragment } from '../jsx-runtime.ts';
14
+
15
+ import { currentFrame, popContextFrame, pushContextFrame } from './context.ts';
16
+ import { Suspense, type SuspenseProps } from './suspense.ts';
17
+ import type { Component, JSXElement, JSXNode } from './types.ts';
18
+
19
+ const HEAD_ELEMENTS = new Set(['title', 'meta', 'link', 'style']);
20
+ const SELF_CLOSING_TAGS = new Set([
21
+ 'area',
22
+ 'base',
23
+ 'br',
24
+ 'col',
25
+ 'embed',
26
+ 'hr',
27
+ 'img',
28
+ 'input',
29
+ 'link',
30
+ 'meta',
31
+ 'param',
32
+ 'source',
33
+ 'track',
34
+ 'wbr',
35
+ ]);
36
+ /** props that shouldn't be rendered as HTML attributes */
37
+ const FRAMEWORK_PROPS = new Set(['children', 'dangerouslySetInnerHTML']);
38
+
39
+ // #region Segment types
40
+
41
+ type Segment =
42
+ | {
43
+ readonly kind: 'static';
44
+ readonly html: string;
45
+ }
46
+ | {
47
+ readonly kind: 'composite';
48
+ readonly parts: readonly Segment[];
49
+ }
50
+ | {
51
+ readonly kind: 'suspense';
52
+ readonly id: string;
53
+ readonly fallback: Segment;
54
+ pending?: Promise<void>;
55
+ content: Segment | null;
56
+ };
57
+
58
+ function staticSeg(html: string): Segment {
59
+ return { kind: 'static', html };
60
+ }
61
+
62
+ function compositeSeg(parts: Segment[]): Segment {
63
+ return { kind: 'composite', parts };
64
+ }
65
+
66
+ const EMPTY_SEGMENT = staticSeg('');
67
+
68
+ // #endregion
69
+
70
+ export interface RenderOptions {
71
+ onError?: (error: unknown) => void;
72
+ }
73
+
74
+ interface RenderContext {
75
+ headElements: string[];
76
+ idsByPath: Map<string, number>;
77
+ insideHead: boolean;
78
+ insideSvg: boolean;
79
+ onError: (error: unknown) => void;
80
+ pendingSuspense: Array<{ id: string; promise: Promise<Segment> }>;
81
+ }
82
+
83
+ /**
84
+ * renders JSX to a readable stream
85
+ * @param node JSX node to render
86
+ * @param options render options
87
+ * @returns readable stream of UTF-8 encoded HTML
88
+ */
89
+ export function renderToStream(node: JSXNode, options?: RenderOptions): ReadableStream<Uint8Array> {
90
+ const encoder = new TextEncoder();
91
+ const onError = options?.onError ?? ((error) => console.error(error));
92
+ const context: RenderContext = {
93
+ headElements: [],
94
+ idsByPath: new Map(),
95
+ insideHead: false,
96
+ insideSvg: false,
97
+ onError,
98
+ pendingSuspense: [],
99
+ };
100
+ return new ReadableStream({
101
+ async start(controller) {
102
+ try {
103
+ const root = buildSegment(node, context, '');
104
+ await resolveBlocking(root);
105
+ const html = serializeSegment(root);
106
+ const finalHtml = finalizeHtml(html, context);
107
+ controller.enqueue(encoder.encode(finalHtml));
108
+ // stream pending suspense boundaries as they resolve
109
+ if (context.pendingSuspense.length > 0) {
110
+ await streamPendingSuspense(context, controller, encoder);
111
+ }
112
+ controller.close();
113
+ } catch (error) {
114
+ onError(error);
115
+ controller.error(error);
116
+ }
117
+ },
118
+ });
119
+ }
120
+
121
+ /**
122
+ * renders JSX to a string (non-streaming)
123
+ * @param node JSX node to render
124
+ * @param options render options
125
+ * @returns promise resolving to HTML string
126
+ */
127
+ export async function renderToString(node: JSXNode, options?: RenderOptions): Promise<string> {
128
+ const stream = renderToStream(node, options);
129
+ const reader = stream.getReader();
130
+ const decoder = new TextDecoder();
131
+ let html = '';
132
+ while (true) {
133
+ const { done, value } = await reader.read();
134
+ if (done) break;
135
+ html += decoder.decode(value);
136
+ }
137
+ return html;
138
+ }
139
+
140
+ /**
141
+ * renders JSX to a streaming Response
142
+ * @param node JSX node to render
143
+ * @param init optional ResponseInit (status, headers, etc.)
144
+ * @returns Response with streaming HTML body
145
+ */
146
+ export function render(node: JSXNode, init?: ResponseInit): Response {
147
+ const stream = renderToStream(node);
148
+
149
+ // @ts-expect-error: not sure why.
150
+ const headers = new Headers(init?.headers);
151
+ if (!headers.has('Content-Type')) {
152
+ headers.set('Content-Type', 'text/html; charset=utf-8');
153
+ }
154
+
155
+ return new Response(stream, { ...init, headers });
156
+ }
157
+
158
+ // #region Segment building
159
+
160
+ function isJSXElement(node: unknown): node is JSXElement {
161
+ return typeof node === 'object' && node !== null && 'type' in node && 'props' in node;
162
+ }
163
+
164
+ function isHeadElement(tag: string): boolean {
165
+ return HEAD_ELEMENTS.has(tag);
166
+ }
167
+
168
+ function buildSegment(node: JSXNode, context: RenderContext, path: string): Segment {
169
+ // primitives
170
+ if (typeof node === 'string' || typeof node === 'number' || typeof node === 'bigint') {
171
+ return staticSeg(escapeHtml(node, false));
172
+ }
173
+ if (node === null || node === undefined || typeof node === 'boolean') {
174
+ return EMPTY_SEGMENT;
175
+ }
176
+ // iterables (arrays, generators, etc.)
177
+ if (typeof node === 'object' && Symbol.iterator in node) {
178
+ const parts: Segment[] = [];
179
+ for (const child of node) {
180
+ parts.push(buildSegment(child, context, path));
181
+ }
182
+ return compositeSeg(parts);
183
+ }
184
+ // JSX elements
185
+ if (isJSXElement(node)) {
186
+ const { type, props } = node;
187
+ // Fragment
188
+ if (type === Fragment) {
189
+ const children = (props as { children?: JSXNode }).children;
190
+ return children != null ? buildSegment(children, context, path) : EMPTY_SEGMENT;
191
+ }
192
+ // intrinsic elements (HTML tags)
193
+ if (typeof type === 'string') {
194
+ const tag = type;
195
+
196
+ if (tag === 'head') {
197
+ return buildHeadElementSegment(tag, props as Record<string, unknown>, context, path);
198
+ }
199
+
200
+ if (!context.insideHead && isHeadElement(tag)) {
201
+ // hoist to <head>
202
+ const elementSeg = buildElementSegment(tag, props as Record<string, unknown>, context, path);
203
+ context.headElements.push(serializeSegment(elementSeg));
204
+ return EMPTY_SEGMENT;
205
+ }
206
+
207
+ return buildElementSegment(tag, props as Record<string, unknown>, context, path);
208
+ }
209
+ // function components
210
+ if (typeof type === 'function') {
211
+ // Suspense boundary
212
+ if (type === Suspense) {
213
+ return buildSuspenseSegment(props as unknown as SuspenseProps, context, path);
214
+ }
215
+ return buildComponentSegment(type, props as Record<string, unknown>, context, path);
216
+ }
217
+ }
218
+ return EMPTY_SEGMENT;
219
+ }
220
+
221
+ // #endregion
222
+
223
+ // #region Element building
224
+
225
+ function buildElementSegment(
226
+ tag: string,
227
+ props: Record<string, unknown>,
228
+ context: RenderContext,
229
+ path: string,
230
+ ): Segment {
231
+ const currentIsSvg = context.insideSvg || tag === 'svg';
232
+ const attrs = renderAttributes(props);
233
+ // self-closing tags
234
+ if (SELF_CLOSING_TAGS.has(tag)) {
235
+ return staticSeg(`<${tag}${attrs}>`);
236
+ }
237
+ // dangerouslySetInnerHTML
238
+ const innerHTML = props.dangerouslySetInnerHTML as { __html: string } | undefined;
239
+ if (innerHTML) {
240
+ return staticSeg(`<${tag}${attrs}>${innerHTML.__html}</${tag}>`);
241
+ }
242
+ // normal element with children
243
+ const open = staticSeg(`<${tag}${attrs}>`);
244
+ const previousInsideSvg = context.insideSvg;
245
+ context.insideSvg = tag === 'foreignObject' ? false : currentIsSvg;
246
+ const children =
247
+ props.children != null ? buildSegment(props.children as JSXNode, context, path) : EMPTY_SEGMENT;
248
+ context.insideSvg = previousInsideSvg;
249
+ const close = staticSeg(`</${tag}>`);
250
+ return compositeSeg([open, children, close]);
251
+ }
252
+
253
+ function buildHeadElementSegment(
254
+ tag: string,
255
+ props: Record<string, unknown>,
256
+ context: RenderContext,
257
+ path: string,
258
+ ): Segment {
259
+ const attrs = renderAttributes(props);
260
+ const previousInsideHead = context.insideHead;
261
+ context.insideHead = true;
262
+ const open = staticSeg(`<${tag}${attrs}>`);
263
+ const children =
264
+ props.children != null ? buildSegment(props.children as JSXNode, context, path) : EMPTY_SEGMENT;
265
+ context.insideHead = previousInsideHead;
266
+ const close = staticSeg(`</${tag}>`);
267
+ return compositeSeg([open, children, close]);
268
+ }
269
+
270
+ function renderAttributes(props: Record<string, unknown>): string {
271
+ let attrs = '';
272
+ for (const key in props) {
273
+ if (FRAMEWORK_PROPS.has(key)) continue;
274
+ const value = props[key];
275
+ if (value === undefined || value === null || value === false) continue;
276
+ if (typeof value === 'function') continue;
277
+ // style object -> string
278
+ if (key === 'style' && typeof value === 'object') {
279
+ const styleStr = serializeStyle(value as Record<string, string | number>);
280
+ if (styleStr) {
281
+ attrs += ` style="${escapeHtml(styleStr, true)}"`;
282
+ }
283
+ continue;
284
+ }
285
+ if (value === true) {
286
+ attrs += ` ${key}`;
287
+ } else {
288
+ attrs += ` ${key}="${escapeHtml(value, true)}"`;
289
+ }
290
+ }
291
+ return attrs;
292
+ }
293
+
294
+ function serializeStyle(style: Record<string, string | number>): string {
295
+ const parts: string[] = [];
296
+ for (const [key, value] of Object.entries(style)) {
297
+ if (value == null) continue;
298
+ parts.push(`${key}:${value}`);
299
+ }
300
+ return parts.join(';');
301
+ }
302
+
303
+ // #endregion
304
+
305
+ // #region Component building
306
+
307
+ function buildComponentSegment(
308
+ type: Component,
309
+ props: Record<string, unknown>,
310
+ ctx: RenderContext,
311
+ path: string,
312
+ ): Segment {
313
+ // call component
314
+ const result = type(props);
315
+ // if component called provide(), push frame before rendering children
316
+ const hadFrame = currentFrame !== null;
317
+ pushContextFrame();
318
+ try {
319
+ return buildSegment(result, ctx, path);
320
+ } finally {
321
+ popContextFrame(hadFrame);
322
+ }
323
+ }
324
+
325
+ function buildSuspenseSegment(props: SuspenseProps, ctx: RenderContext, path: string): Segment {
326
+ // generate unique id for this suspense boundary
327
+ const nextIndex = (ctx.idsByPath.get(path) ?? 0) + 1;
328
+ ctx.idsByPath.set(path, nextIndex);
329
+ const id = path ? `${path}-${nextIndex}` : `${nextIndex}`;
330
+ const suspenseId = `s${id}`;
331
+
332
+ try {
333
+ // try to render children synchronously
334
+ const content = buildSegment(props.children, ctx, suspenseId);
335
+ // no suspension - return content directly (no boundary needed)
336
+ return content;
337
+ } catch (thrown) {
338
+ // check if it's a promise (suspension)
339
+ if (thrown instanceof Promise) {
340
+ // render fallback
341
+ const fallback = buildSegment(props.fallback, ctx, suspenseId);
342
+
343
+ // create suspense segment
344
+ const seg: Segment = {
345
+ kind: 'suspense',
346
+ id: suspenseId,
347
+ fallback,
348
+ content: null,
349
+ };
350
+
351
+ // set up promise to re-render children when resolved
352
+ const pending = thrown.then(() => {
353
+ // re-render children after promise resolves
354
+ seg.content = buildSegment(props.children, ctx, suspenseId);
355
+ });
356
+ seg.pending = pending;
357
+
358
+ // track for streaming
359
+ ctx.pendingSuspense.push({
360
+ id: suspenseId,
361
+ promise: pending.then(() => seg.content!),
362
+ });
363
+
364
+ return seg;
365
+ }
366
+ // not a promise - re-throw
367
+ throw thrown;
368
+ }
369
+ }
370
+
371
+ // #endregion
372
+
373
+ // #region Serialization
374
+
375
+ /** resolve all blocking suspense boundaries */
376
+ async function resolveBlocking(segment: Segment): Promise<void> {
377
+ if (segment.kind === 'suspense') {
378
+ if (segment.pending) {
379
+ await segment.pending;
380
+ segment.pending = undefined;
381
+ }
382
+ if (segment.content) {
383
+ await resolveBlocking(segment.content);
384
+ }
385
+ return;
386
+ }
387
+ if (segment.kind === 'composite') {
388
+ for (const part of segment.parts) {
389
+ await resolveBlocking(part);
390
+ }
391
+ }
392
+ }
393
+
394
+ /** serialize segment tree to HTML string */
395
+ function serializeSegment(seg: Segment): string {
396
+ if (seg.kind === 'static') {
397
+ return seg.html;
398
+ }
399
+ if (seg.kind === 'composite') {
400
+ return seg.parts.map(serializeSegment).join('');
401
+ }
402
+ // suspense - always render fallback; resolved content streams in template
403
+ return `<!--$s:${seg.id}-->${serializeSegment(seg.fallback)}<!--/$s:${seg.id}-->`;
404
+ }
405
+
406
+ // #endregion
407
+
408
+ // #region Streaming
409
+
410
+ /** suspense runtime function name */
411
+ const SR = '$sr';
412
+ /** suspense runtime - injected once, swaps template content with fallback */
413
+ const SUSPENSE_RUNTIME = `<script>${SR}=(t,i,s,e)=>{i="$s:"+t.dataset.suspense;s=document.createTreeWalker(document,128);while(e=s.nextNode())if(e.data===i){while(e.nextSibling?.data!=="/"+i)e.nextSibling.remove();e.nextSibling.replaceWith(...t.content.childNodes);e.remove();break}t.remove()}</script>`;
414
+
415
+ async function streamPendingSuspense(
416
+ context: RenderContext,
417
+ controller: ReadableStreamDefaultController<Uint8Array>,
418
+ encoder: TextEncoder,
419
+ ): Promise<void> {
420
+ let runtimeInjected = false;
421
+ const processed = new Set<string>();
422
+ while (true) {
423
+ const batch = context.pendingSuspense.filter(({ id }) => !processed.has(id));
424
+ if (batch.length === 0) break;
425
+ await Promise.all(
426
+ batch.map(async ({ id, promise }) => {
427
+ processed.add(id);
428
+ try {
429
+ const resolvedSegment = await promise;
430
+ await resolveBlocking(resolvedSegment);
431
+ const html = serializeSegment(resolvedSegment);
432
+ // inject runtime once before first resolution
433
+ const runtime = runtimeInjected ? '' : SUSPENSE_RUNTIME;
434
+ runtimeInjected = true;
435
+ // stream template + call to swap function
436
+ controller.enqueue(
437
+ encoder.encode(
438
+ `${runtime}<template data-suspense="${id}">${html}</template>` +
439
+ `<script>${SR}(document.currentScript.previousElementSibling)</script>`,
440
+ ),
441
+ );
442
+ } catch (error) {
443
+ context.onError(error);
444
+ }
445
+ }),
446
+ );
447
+ }
448
+ }
449
+
450
+ // #endregion
451
+
452
+ // #region Utilities
453
+
454
+ const ATTR_REGEX = /[&"]/g;
455
+ const CONTENT_REGEX = /[&<]/g;
456
+
457
+ function escapeHtml(value: unknown, isAttr: boolean): string {
458
+ const str = String(value ?? '');
459
+ const pattern = isAttr ? ATTR_REGEX : CONTENT_REGEX;
460
+ pattern.lastIndex = 0;
461
+
462
+ let escaped = '';
463
+ let last = 0;
464
+
465
+ while (pattern.test(str)) {
466
+ const i = pattern.lastIndex - 1;
467
+ const ch = str[i];
468
+ escaped += str.substring(last, i) + (ch === '&' ? '&amp;' : ch === '"' ? '&quot;' : '&lt;');
469
+ last = i + 1;
470
+ }
471
+
472
+ return escaped + str.substring(last);
473
+ }
474
+
475
+ function finalizeHtml(html: string, context: RenderContext): string {
476
+ const hasHtmlRoot = html.trimStart().toLowerCase().startsWith('<html');
477
+ // inject hoisted head elements
478
+ if (context.headElements.length > 0) {
479
+ const headContent = context.headElements.join('');
480
+ if (hasHtmlRoot) {
481
+ const headCloseIndex = html.indexOf('</head>');
482
+ if (headCloseIndex !== -1) {
483
+ // inject before existing </head>
484
+ html = html.slice(0, headCloseIndex) + headContent + html.slice(headCloseIndex);
485
+ } else {
486
+ // no existing head, inject after <html>
487
+ const htmlOpenMatch = html.match(/<html[^>]*>/);
488
+ if (htmlOpenMatch && htmlOpenMatch.index !== undefined) {
489
+ const insertIndex = htmlOpenMatch.index + htmlOpenMatch[0].length;
490
+ html = html.slice(0, insertIndex) + `<head>${headContent}</head>` + html.slice(insertIndex);
491
+ }
492
+ }
493
+ } else {
494
+ // no HTML root, prepend head
495
+ html = `<head>${headContent}</head>${html}`;
496
+ }
497
+ }
498
+ if (hasHtmlRoot) {
499
+ html = '<!doctype html>' + html;
500
+ }
501
+ return html;
502
+ }
503
+
504
+ // #endregion