@hyperspan/framework 1.0.25 → 1.0.27
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
|
@@ -94,6 +94,8 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
activateScriptsIn(isFullDocument ? document.body : (target as ParentNode));
|
|
98
|
+
|
|
97
99
|
opts.afterResponse && opts.afterResponse();
|
|
98
100
|
lazyLoadScripts();
|
|
99
101
|
}
|
|
@@ -126,13 +128,7 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
126
128
|
return;
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
|
|
130
|
-
// No content = DO NOTHING (redirect or something else happened)
|
|
131
|
-
if (!content) {
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
applyResponseHtml(content);
|
|
131
|
+
await consumeStreamingHtmlResponse(res, applyResponseHtml);
|
|
136
132
|
})
|
|
137
133
|
.catch((error) => {
|
|
138
134
|
console.error('[Hyperspan] Error submitting form action:', error);
|
|
@@ -142,45 +138,79 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
142
138
|
/** Streaming async chunks use template ids ending in `_content`. */
|
|
143
139
|
const STREAM_CHUNK_MARKER = /<template id="[^"]+_content">/;
|
|
144
140
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
141
|
+
/** Marks the end of a streaming chunk boundary. */
|
|
142
|
+
const CHUNK_END = '<!--/hs:chunk-->';
|
|
143
|
+
|
|
144
|
+
/** Clone a script node so the browser will execute it (preserves module type). */
|
|
145
|
+
function cloneScriptForExecution(script: HTMLScriptElement): HTMLScriptElement {
|
|
146
|
+
const executable = document.createElement('script');
|
|
147
|
+
if (script.src) {
|
|
148
|
+
executable.src = script.src;
|
|
149
|
+
} else if (script.textContent) {
|
|
150
|
+
executable.textContent = script.textContent;
|
|
149
151
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
if (script.type) {
|
|
153
|
+
executable.type = script.type;
|
|
154
|
+
}
|
|
155
|
+
for (const attr of script.getAttributeNames()) {
|
|
156
|
+
if (attr === 'src' || attr === 'type') {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
executable.setAttribute(attr, script.getAttribute(attr) || '');
|
|
160
|
+
}
|
|
161
|
+
return executable;
|
|
154
162
|
}
|
|
155
163
|
|
|
156
|
-
/**
|
|
164
|
+
/** Run scripts inserted by Idiomorph (innerHTML/morph does not execute them). */
|
|
165
|
+
function activateScriptsIn(root: ParentNode) {
|
|
166
|
+
root.querySelectorAll('script').forEach((script) => {
|
|
167
|
+
if (script.closest('template[id$="_content"]')) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
script.replaceWith(cloneScriptForExecution(script));
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Append streamed HTML to body and run top-level chunk scripts (e.g. window._hsc.push). */
|
|
157
175
|
function appendHtmlToBody(html: string) {
|
|
158
176
|
if (!html) {
|
|
159
177
|
return;
|
|
160
178
|
}
|
|
161
179
|
|
|
162
|
-
const
|
|
163
|
-
|
|
180
|
+
const container = document.createElement('template');
|
|
181
|
+
container.innerHTML = html;
|
|
164
182
|
const scripts: HTMLScriptElement[] = [];
|
|
165
|
-
|
|
166
|
-
|
|
183
|
+
|
|
184
|
+
for (const node of Array.from(container.content.childNodes)) {
|
|
185
|
+
if (node.nodeName === 'SCRIPT') {
|
|
186
|
+
scripts.push(node as HTMLScriptElement);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const script of scripts) {
|
|
167
191
|
script.remove();
|
|
168
|
-
}
|
|
169
|
-
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
document.body.appendChild(container.content);
|
|
195
|
+
|
|
170
196
|
for (const script of scripts) {
|
|
171
|
-
|
|
172
|
-
if (script.src) {
|
|
173
|
-
executable.src = script.src;
|
|
174
|
-
} else {
|
|
175
|
-
executable.textContent = script.textContent;
|
|
176
|
-
}
|
|
177
|
-
document.body.appendChild(executable);
|
|
197
|
+
document.body.appendChild(cloneScriptForExecution(script));
|
|
178
198
|
}
|
|
179
199
|
}
|
|
180
200
|
|
|
181
201
|
/**
|
|
182
|
-
* Read a (possibly streaming) HTML response
|
|
183
|
-
*
|
|
202
|
+
* Read a (possibly streaming) HTML response. The stream is shaped as:
|
|
203
|
+
* [full initial page HTML, including <slot> placeholders]
|
|
204
|
+
* <template id="X_content">…<!--end--></template><script>…_hsc.push({id:'X'})…</script>
|
|
205
|
+
* …one <template>/<script> pair per async chunk…
|
|
206
|
+
*
|
|
207
|
+
* Network reads do NOT align with these logical boundaries, so we buffer instead of assuming
|
|
208
|
+
* the whole initial page (or a whole chunk) arrives in a single read:
|
|
209
|
+
* 1. Accumulate until the first stream-chunk <template> marker appears (or the stream ends),
|
|
210
|
+
* then Idiomorph the everything-before-it as the initial page HTML - exactly once.
|
|
211
|
+
* 2. After that, append only COMPLETE chunks (each terminated by the server's <!--/hs:chunk-->
|
|
212
|
+
* delimiter) to document.body so their scripts run and placeholders resolve. Any partial
|
|
213
|
+
* trailing chunk stays buffered until the rest of it arrives.
|
|
184
214
|
*/
|
|
185
215
|
async function consumeStreamingHtmlResponse(
|
|
186
216
|
res: Response,
|
|
@@ -197,34 +227,59 @@ async function consumeStreamingHtmlResponse(
|
|
|
197
227
|
|
|
198
228
|
const reader = body.getReader();
|
|
199
229
|
const decoder = new TextDecoder();
|
|
200
|
-
let
|
|
230
|
+
let buffer = '';
|
|
231
|
+
let initialApplied = false;
|
|
201
232
|
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
|
|
233
|
+
function pump(isFinal: boolean) {
|
|
234
|
+
// Phase 1: split off and morph the initial page HTML (everything before the first chunk).
|
|
235
|
+
if (!initialApplied) {
|
|
236
|
+
const match = STREAM_CHUNK_MARKER.exec(buffer);
|
|
237
|
+
if (match) {
|
|
238
|
+
const initial = buffer.slice(0, match.index);
|
|
239
|
+
buffer = buffer.slice(match.index);
|
|
240
|
+
initialApplied = true;
|
|
241
|
+
if (initial) {
|
|
242
|
+
applyInitialHtml(initial);
|
|
243
|
+
}
|
|
244
|
+
} else if (isFinal) {
|
|
245
|
+
// No stream chunks at all - the whole response is the page HTML.
|
|
246
|
+
initialApplied = true;
|
|
247
|
+
if (buffer) {
|
|
248
|
+
applyInitialHtml(buffer);
|
|
249
|
+
}
|
|
250
|
+
buffer = '';
|
|
251
|
+
return;
|
|
252
|
+
} else {
|
|
253
|
+
// Marker not here yet; keep buffering the (possibly large) initial HTML.
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
205
256
|
}
|
|
206
257
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
if (streamTail) {
|
|
214
|
-
appendHtmlToBody(streamTail);
|
|
258
|
+
// Phase 2: flush complete chunks; hold back any partial trailing chunk.
|
|
259
|
+
if (isFinal) {
|
|
260
|
+
if (buffer) {
|
|
261
|
+
appendHtmlToBody(buffer);
|
|
262
|
+
buffer = '';
|
|
215
263
|
}
|
|
216
264
|
return;
|
|
217
265
|
}
|
|
218
|
-
|
|
219
|
-
|
|
266
|
+
const lastEnd = buffer.lastIndexOf(CHUNK_END);
|
|
267
|
+
if (lastEnd === -1) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const flushEnd = lastEnd + CHUNK_END.length;
|
|
271
|
+
appendHtmlToBody(buffer.slice(0, flushEnd));
|
|
272
|
+
buffer = buffer.slice(flushEnd);
|
|
220
273
|
}
|
|
221
274
|
|
|
222
275
|
while (true) {
|
|
223
276
|
const { done, value } = await reader.read();
|
|
224
277
|
if (done) {
|
|
225
|
-
|
|
278
|
+
buffer += decoder.decode();
|
|
279
|
+
pump(true);
|
|
226
280
|
break;
|
|
227
281
|
}
|
|
228
|
-
|
|
282
|
+
buffer += decoder.decode(value, { stream: true });
|
|
283
|
+
pump(false);
|
|
229
284
|
}
|
|
230
|
-
}
|
|
285
|
+
}
|
|
@@ -33,7 +33,12 @@ async function waitForContent(
|
|
|
33
33
|
function renderStreamChunk(chunk: { id: string }) {
|
|
34
34
|
const slotId = chunk.id;
|
|
35
35
|
const slotEl = document.getElementById(slotId);
|
|
36
|
-
const templateEl = document.getElementById(`${slotId}_content`) as HTMLTemplateElement;
|
|
36
|
+
const templateEl = document.getElementById(`${slotId}_content`) as HTMLTemplateElement | null;
|
|
37
|
+
|
|
38
|
+
// Content template is gone (e.g. this chunk was already rendered and re-queued). Nothing to do.
|
|
39
|
+
if (!templateEl) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
37
42
|
|
|
38
43
|
if (slotEl) {
|
|
39
44
|
// Content AND slot are present - let's insert the content into the slot
|
|
@@ -72,6 +77,8 @@ declare global {
|
|
|
72
77
|
push: (e: { id: string }) => void;
|
|
73
78
|
forEach: (callback: (e: { id: string }) => void) => void;
|
|
74
79
|
};
|
|
80
|
+
_hscInit?: boolean;
|
|
81
|
+
_hscLoading?: boolean;
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
package/src/layout.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { JS_IMPORT_MAP, buildClientJS } from './client/js';
|
|
|
3
3
|
import { CSS_PUBLIC_PATH, CSS_ROUTE_MAP } from './client/css';
|
|
4
4
|
import type { Hyperspan as HS } from './types';
|
|
5
5
|
|
|
6
|
-
const clientStreamingJS = await buildClientJS(
|
|
6
|
+
const clientStreamingJS = await buildClientJS(
|
|
7
|
+
import.meta.resolve('./client/_hs/hyperspan-streaming.client')
|
|
8
|
+
);
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Output the importmap for the client so we can use ESModules on the client to load JS files on demand
|
|
@@ -16,14 +18,18 @@ export function hyperspanScriptTags() {
|
|
|
16
18
|
<script id="hyperspan-streaming-script">
|
|
17
19
|
// [Hyperspan] Streaming - Load the client streaming JS module only when the first chunk is loaded
|
|
18
20
|
window._hsc = window._hsc || [];
|
|
19
|
-
window.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
if (!window._hscInit) {
|
|
22
|
+
window._hscInit = true;
|
|
23
|
+
window._hsc.push = function (e) {
|
|
24
|
+
Array.prototype.push.call(window._hsc, e);
|
|
25
|
+
if (!window._hscLoading) {
|
|
26
|
+
window._hscLoading = true;
|
|
27
|
+
const script = document.createElement('script');
|
|
28
|
+
script.src = '${clientStreamingJS.publicPath}';
|
|
29
|
+
document.body.appendChild(script);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
27
33
|
</script>
|
|
28
34
|
`;
|
|
29
35
|
}
|
|
@@ -36,10 +42,8 @@ export function hyperspanStyleTags(context: HS.Context) {
|
|
|
36
42
|
const cssImports = context.route.cssImports ?? CSS_ROUTE_MAP.get(context.route.path) ?? [];
|
|
37
43
|
|
|
38
44
|
for (const cssFile of cssImports) {
|
|
39
|
-
styleTags.push(html`
|
|
40
|
-
<link rel="stylesheet" href="${CSS_PUBLIC_PATH}/${cssFile}" />
|
|
41
|
-
`);
|
|
45
|
+
styleTags.push(html` <link rel="stylesheet" href="${CSS_PUBLIC_PATH}/${cssFile}" /> `);
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
return styleTags;
|
|
45
|
-
}
|
|
49
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -538,12 +538,14 @@ export async function returnHTMLResponse(
|
|
|
538
538
|
return new StreamResponse(
|
|
539
539
|
renderStream(routeContent as HSHtml, {
|
|
540
540
|
renderChunk: (chunk) => {
|
|
541
|
+
// Trailing <!--/hs:chunk--> marks the end of a streaming chunk boundary.
|
|
541
542
|
return html`
|
|
542
543
|
<template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
|
|
543
544
|
<script>
|
|
544
545
|
window._hsc = window._hsc || [];
|
|
545
546
|
window._hsc.push({ id: '${chunk.id}' });
|
|
546
547
|
</script>
|
|
548
|
+
<!--/hs:chunk-->
|
|
547
549
|
`;
|
|
548
550
|
},
|
|
549
551
|
}),
|