@hyperspan/framework 1.0.26 → 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.
@@ -1,5 +1,9 @@
1
1
  {
2
2
  "permissions": {
3
- "allow": ["Bash(bun test:*)"]
3
+ "allow": [
4
+ "Bash(bun test:*)",
5
+ "Bash(npx tsc *)",
6
+ "Bash(echo \"exit: $?\")"
7
+ ]
4
8
  }
5
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -138,16 +138,8 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
138
138
  /** Streaming async chunks use template ids ending in `_content`. */
139
139
  const STREAM_CHUNK_MARKER = /<template id="[^"]+_content">/;
140
140
 
141
- function splitInitialStreamHtml(html: string): { initial: string; streamTail: string | null } {
142
- const match = STREAM_CHUNK_MARKER.exec(html);
143
- if (!match || match.index === 0) {
144
- return { initial: html, streamTail: null };
145
- }
146
- return {
147
- initial: html.slice(0, match.index),
148
- streamTail: html.slice(match.index),
149
- };
150
- }
141
+ /** Marks the end of a streaming chunk boundary. */
142
+ const CHUNK_END = '<!--/hs:chunk-->';
151
143
 
152
144
  /** Clone a script node so the browser will execute it (preserves module type). */
153
145
  function cloneScriptForExecution(script: HTMLScriptElement): HTMLScriptElement {
@@ -207,8 +199,18 @@ function appendHtmlToBody(html: string) {
207
199
  }
208
200
 
209
201
  /**
210
- * Read a (possibly streaming) HTML response: Idiomorph the initial page HTML, then append
211
- * later stream chunks to document.body so streaming scripts run and placeholders resolve.
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.
212
214
  */
213
215
  async function consumeStreamingHtmlResponse(
214
216
  res: Response,
@@ -225,34 +227,59 @@ async function consumeStreamingHtmlResponse(
225
227
 
226
228
  const reader = body.getReader();
227
229
  const decoder = new TextDecoder();
228
- let isFirstChunk = true;
229
-
230
- function processChunk(html: string) {
231
- if (!html) {
232
- return;
230
+ let buffer = '';
231
+ let initialApplied = false;
232
+
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
+ }
233
256
  }
234
257
 
235
- if (isFirstChunk) {
236
- isFirstChunk = false;
237
- const { initial, streamTail } = splitInitialStreamHtml(html);
238
- if (initial) {
239
- applyInitialHtml(initial);
240
- }
241
- if (streamTail) {
242
- 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 = '';
243
263
  }
244
264
  return;
245
265
  }
246
-
247
- appendHtmlToBody(html);
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);
248
273
  }
249
274
 
250
275
  while (true) {
251
276
  const { done, value } = await reader.read();
252
277
  if (done) {
253
- processChunk(decoder.decode());
278
+ buffer += decoder.decode();
279
+ pump(true);
254
280
  break;
255
281
  }
256
- processChunk(decoder.decode(value, { stream: true }));
282
+ buffer += decoder.decode(value, { stream: true });
283
+ pump(false);
257
284
  }
258
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(import.meta.resolve('./client/_hs/hyperspan-streaming.client'));
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._hsc.push = function(e) {
20
- Array.prototype.push.call(window._hsc, e);
21
- if (window._hsc.length === 1) {
22
- const script = document.createElement('script');
23
- script.src = "${clientStreamingJS.publicPath}";
24
- document.body.appendChild(script);
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
  }),