@hyperspan/framework 1.0.26 → 1.0.28

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.28",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -1,5 +1,63 @@
1
1
  import { Idiomorph } from './idiomorph';
2
2
  import { lazyLoadScripts } from './hyperspan-scripts.client';
3
+ import type { Hyperspan as HS } from '../../types';
4
+
5
+ const HS_ACTION_BEFORE_FETCH: HS.ActionEventName = 'hs:action:before-fetch';
6
+ const HS_ACTION_AFTER_FETCH: HS.ActionEventName = 'hs:action:after-fetch';
7
+ const HS_ACTION_BEFORE_SWAP: HS.ActionEventName = 'hs:action:before-swap';
8
+ const HS_ACTION_AFTER_SWAP: HS.ActionEventName = 'hs:action:after-swap';
9
+ const HS_ACTION_BEFORE_NAVIGATE: HS.ActionEventName = 'hs:action:before-navigate';
10
+
11
+ function dispatchActionEvent<T>(
12
+ target: EventTarget,
13
+ name: HS.ActionEventName,
14
+ detail: T,
15
+ cancelable = false
16
+ ): boolean {
17
+ return target.dispatchEvent(
18
+ new CustomEvent(name, {
19
+ detail,
20
+ bubbles: true,
21
+ cancelable,
22
+ composed: true,
23
+ })
24
+ );
25
+ }
26
+
27
+ /** No box by default (`display: contents`) — style `:has(hs-action-loading)` for loading UI. */
28
+ function ensureActionLoadingStyles() {
29
+ if (document.getElementById('hs-action-loading-style')) {
30
+ return;
31
+ }
32
+ const style = document.createElement('style');
33
+ style.id = 'hs-action-loading-style';
34
+ style.textContent = 'hs-action-loading{display:contents}';
35
+ document.head.appendChild(style);
36
+ }
37
+
38
+ class HSActionLoading extends HTMLElement {
39
+ connectedCallback() {
40
+ ensureActionLoadingStyles();
41
+ }
42
+ }
43
+
44
+ if (!customElements.get('hs-action-loading')) {
45
+ customElements.define('hs-action-loading', HSActionLoading);
46
+ }
47
+
48
+ function setActionLoading(hsActionTag: HTMLElement | null, loading: boolean) {
49
+ if (!hsActionTag) {
50
+ return;
51
+ }
52
+ const existing = hsActionTag.querySelector('hs-action-loading');
53
+ if (loading) {
54
+ if (!existing) {
55
+ hsActionTag.appendChild(document.createElement('hs-action-loading'));
56
+ }
57
+ } else if (existing) {
58
+ existing.remove();
59
+ }
60
+ }
3
61
 
4
62
  const actionFormObserver = new MutationObserver((list) => {
5
63
  list.forEach((mutation) => {
@@ -71,7 +129,24 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
71
129
  }
72
130
  }
73
131
 
74
- const hsActionTag = form.closest('hs-action');
132
+ const hsActionTag = form.closest('hs-action') as HTMLElement | null;
133
+ const eventTarget: EventTarget = hsActionTag || document;
134
+ const fetchDetail: HS.ActionFetchDetail = {
135
+ form,
136
+ action: hsActionTag,
137
+ url: formUrl,
138
+ method,
139
+ loadingElement: true,
140
+ };
141
+
142
+ if (!dispatchActionEvent(eventTarget, HS_ACTION_BEFORE_FETCH, fetchDetail, true)) {
143
+ return;
144
+ }
145
+
146
+ if (fetchDetail.loadingElement) {
147
+ setActionLoading(hsActionTag, true);
148
+ }
149
+
75
150
  const submitBtn = form.querySelector('button[type=submit],input[type=submit]');
76
151
  if (submitBtn) {
77
152
  submitBtn.setAttribute('disabled', 'disabled');
@@ -82,6 +157,18 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
82
157
  if (isFullDocument) {
83
158
  html = html.replace(/^[\s\uFEFF]*<!DOCTYPE[^>]*>/i, '');
84
159
  }
160
+
161
+ const swapDetail: HS.ActionSwapDetail = {
162
+ form,
163
+ action: hsActionTag,
164
+ html,
165
+ fullDocument: isFullDocument,
166
+ };
167
+
168
+ if (!dispatchActionEvent(eventTarget, HS_ACTION_BEFORE_SWAP, swapDetail, true)) {
169
+ return;
170
+ }
171
+
85
172
  const target = isFullDocument ? window.document : hsActionTag || form;
86
173
  const options = isFullDocument ? undefined : { morphStyle: 'innerHTML' };
87
174
 
@@ -96,10 +183,42 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
96
183
 
97
184
  activateScriptsIn(isFullDocument ? document.body : (target as ParentNode));
98
185
 
186
+ dispatchActionEvent(eventTarget, HS_ACTION_AFTER_SWAP, swapDetail);
187
+
99
188
  opts.afterResponse && opts.afterResponse();
100
189
  lazyLoadScripts();
101
190
  }
102
191
 
192
+ async function navigateTo(url: string, preferHard: boolean) {
193
+ const navigateDetail: HS.ActionNavigateDetail = {
194
+ form,
195
+ action: hsActionTag,
196
+ url,
197
+ hard: preferHard,
198
+ };
199
+
200
+ if (!dispatchActionEvent(eventTarget, HS_ACTION_BEFORE_NAVIGATE, navigateDetail, true)) {
201
+ return;
202
+ }
203
+
204
+ if (navigateDetail.hard) {
205
+ window.location.assign(navigateDetail.url);
206
+ return;
207
+ }
208
+
209
+ // Soft morph requires same-origin (CORS / Idiomorph are not safe across domains).
210
+ const resolved = new URL(navigateDetail.url, window.location.href);
211
+ if (resolved.origin !== window.location.origin) {
212
+ window.location.assign(navigateDetail.url);
213
+ return;
214
+ }
215
+
216
+ const pageRes = await fetch(navigateDetail.url, {
217
+ headers: { Accept: 'text/html' },
218
+ });
219
+ await consumeStreamingHtmlResponse(pageRes, applyResponseHtml);
220
+ }
221
+
103
222
  fetch(formUrl, { body: formData, method, headers })
104
223
  .then(async (res: Response) => {
105
224
  // Look for special header that indicates a redirect.
@@ -109,21 +228,13 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
109
228
  if (newUrl) {
110
229
  const resolved = new URL(newUrl, window.location.href);
111
230
 
112
- // Same-origin + same path: fetch updated HTML and morph in place. Cross-origin redirects
113
- // must use full navigation (CORS and Idiomorph are not safe across domains).
114
- if (
115
- resolved.origin === window.location.origin &&
116
- resolved.pathname === window.location.pathname
117
- ) {
118
- const pageRes = await fetch(resolved.href, {
119
- headers: { Accept: 'text/html' },
120
- });
121
- await consumeStreamingHtmlResponse(pageRes, applyResponseHtml);
122
- return;
123
- }
124
-
125
- // If the new URL is different, we need to redirect the user to the new URL
126
- window.location.assign(newUrl);
231
+ // Default: same-origin + same path soft morph; anything else hard navigation.
232
+ // Listeners can flip detail.hard on hs:action:before-navigate to override.
233
+ const preferHard =
234
+ resolved.origin !== window.location.origin ||
235
+ resolved.pathname !== window.location.pathname;
236
+
237
+ await navigateTo(newUrl, preferHard);
127
238
  }
128
239
  return;
129
240
  }
@@ -132,22 +243,18 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
132
243
  })
133
244
  .catch((error) => {
134
245
  console.error('[Hyperspan] Error submitting form action:', error);
246
+ })
247
+ .finally(() => {
248
+ setActionLoading(hsActionTag, false);
249
+ dispatchActionEvent(eventTarget, HS_ACTION_AFTER_FETCH, fetchDetail);
135
250
  });
136
251
  }
137
252
 
138
253
  /** Streaming async chunks use template ids ending in `_content`. */
139
254
  const STREAM_CHUNK_MARKER = /<template id="[^"]+_content">/;
140
255
 
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
- }
256
+ /** Marks the end of a streaming chunk boundary. */
257
+ const CHUNK_END = '<!--/hs:chunk-->';
151
258
 
152
259
  /** Clone a script node so the browser will execute it (preserves module type). */
153
260
  function cloneScriptForExecution(script: HTMLScriptElement): HTMLScriptElement {
@@ -207,8 +314,18 @@ function appendHtmlToBody(html: string) {
207
314
  }
208
315
 
209
316
  /**
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.
317
+ * Read a (possibly streaming) HTML response. The stream is shaped as:
318
+ * [full initial page HTML, including <slot> placeholders]
319
+ * <template id="X_content">…<!--end--></template><script>…_hsc.push({id:'X'})…</script>
320
+ * …one <template>/<script> pair per async chunk…
321
+ *
322
+ * Network reads do NOT align with these logical boundaries, so we buffer instead of assuming
323
+ * the whole initial page (or a whole chunk) arrives in a single read:
324
+ * 1. Accumulate until the first stream-chunk <template> marker appears (or the stream ends),
325
+ * then Idiomorph the everything-before-it as the initial page HTML - exactly once.
326
+ * 2. After that, append only COMPLETE chunks (each terminated by the server's <!--/hs:chunk-->
327
+ * delimiter) to document.body so their scripts run and placeholders resolve. Any partial
328
+ * trailing chunk stays buffered until the rest of it arrives.
212
329
  */
213
330
  async function consumeStreamingHtmlResponse(
214
331
  res: Response,
@@ -225,34 +342,59 @@ async function consumeStreamingHtmlResponse(
225
342
 
226
343
  const reader = body.getReader();
227
344
  const decoder = new TextDecoder();
228
- let isFirstChunk = true;
229
-
230
- function processChunk(html: string) {
231
- if (!html) {
232
- return;
345
+ let buffer = '';
346
+ let initialApplied = false;
347
+
348
+ function pump(isFinal: boolean) {
349
+ // Phase 1: split off and morph the initial page HTML (everything before the first chunk).
350
+ if (!initialApplied) {
351
+ const match = STREAM_CHUNK_MARKER.exec(buffer);
352
+ if (match) {
353
+ const initial = buffer.slice(0, match.index);
354
+ buffer = buffer.slice(match.index);
355
+ initialApplied = true;
356
+ if (initial) {
357
+ applyInitialHtml(initial);
358
+ }
359
+ } else if (isFinal) {
360
+ // No stream chunks at all - the whole response is the page HTML.
361
+ initialApplied = true;
362
+ if (buffer) {
363
+ applyInitialHtml(buffer);
364
+ }
365
+ buffer = '';
366
+ return;
367
+ } else {
368
+ // Marker not here yet; keep buffering the (possibly large) initial HTML.
369
+ return;
370
+ }
233
371
  }
234
372
 
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);
373
+ // Phase 2: flush complete chunks; hold back any partial trailing chunk.
374
+ if (isFinal) {
375
+ if (buffer) {
376
+ appendHtmlToBody(buffer);
377
+ buffer = '';
243
378
  }
244
379
  return;
245
380
  }
246
-
247
- appendHtmlToBody(html);
381
+ const lastEnd = buffer.lastIndexOf(CHUNK_END);
382
+ if (lastEnd === -1) {
383
+ return;
384
+ }
385
+ const flushEnd = lastEnd + CHUNK_END.length;
386
+ appendHtmlToBody(buffer.slice(0, flushEnd));
387
+ buffer = buffer.slice(flushEnd);
248
388
  }
249
389
 
250
390
  while (true) {
251
391
  const { done, value } = await reader.read();
252
392
  if (done) {
253
- processChunk(decoder.decode());
393
+ buffer += decoder.decode();
394
+ pump(true);
254
395
  break;
255
396
  }
256
- processChunk(decoder.decode(value, { stream: true }));
397
+ buffer += decoder.decode(value, { stream: true });
398
+ pump(false);
257
399
  }
258
400
  }
@@ -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
  }),
package/src/types.ts CHANGED
@@ -299,6 +299,56 @@ export namespace Hyperspan {
299
299
  fetch: (request: Request) => Promise<Response>;
300
300
  }
301
301
 
302
+ /**
303
+ * Client-side action lifecycle events (dispatched from `<hs-action>`, bubbles to document).
304
+ *
305
+ * - `hs:action:before-fetch` — before the action request starts (cancelable).
306
+ * - `hs:action:after-fetch` — after the action request finishes (success or error).
307
+ * - `hs:action:before-swap` — before HTML morph (cancelable). Close modals here.
308
+ * - `hs:action:after-swap` — after HTML morph.
309
+ * - `hs:action:before-navigate` — before redirect soft/hard navigation (cancelable).
310
+ * Set `detail.hard = true` for a full page load, or `false` to fetch+morph in place.
311
+ */
312
+ export type ActionEventName =
313
+ | 'hs:action:before-fetch'
314
+ | 'hs:action:after-fetch'
315
+ | 'hs:action:before-swap'
316
+ | 'hs:action:after-swap'
317
+ | 'hs:action:before-navigate';
318
+
319
+ export type ActionFetchDetail = {
320
+ form: HTMLFormElement;
321
+ /** The current `<hs-action>` element, if present. */
322
+ action: HTMLElement | null;
323
+ url: string;
324
+ method: string;
325
+ /**
326
+ * Mutable. When `true` (default), appends `<hs-action-loading>` inside `<hs-action>` for the
327
+ * duration of the request. Set to `false` in `hs:action:before-fetch` to skip it.
328
+ */
329
+ loadingElement: boolean;
330
+ };
331
+
332
+ export type ActionSwapDetail = {
333
+ form: HTMLFormElement;
334
+ /** The current `<hs-action>` element, if present. */
335
+ action: HTMLElement | null;
336
+ html: string;
337
+ fullDocument: boolean;
338
+ };
339
+
340
+ export type ActionNavigateDetail = {
341
+ form: HTMLFormElement;
342
+ /** The current `<hs-action>` element, if present. */
343
+ action: HTMLElement | null;
344
+ url: string;
345
+ /**
346
+ * Mutable. Default is soft (false) for same-origin+same-path redirects, hard (true) otherwise.
347
+ * Set to `true` for `window.location.assign`, or `false` to fetch + morph in place.
348
+ */
349
+ hard: boolean;
350
+ };
351
+
302
352
  /**
303
353
  * Client JS Module = ESM Module + Public Path + Render Script Tag
304
354
  */
@@ -324,3 +374,20 @@ export namespace Hyperspan {
324
374
  formErrors: unknown[];
325
375
  }
326
376
  }
377
+
378
+ declare global {
379
+ interface DocumentEventMap {
380
+ 'hs:action:before-fetch': CustomEvent<Hyperspan.ActionFetchDetail>;
381
+ 'hs:action:after-fetch': CustomEvent<Hyperspan.ActionFetchDetail>;
382
+ 'hs:action:before-swap': CustomEvent<Hyperspan.ActionSwapDetail>;
383
+ 'hs:action:after-swap': CustomEvent<Hyperspan.ActionSwapDetail>;
384
+ 'hs:action:before-navigate': CustomEvent<Hyperspan.ActionNavigateDetail>;
385
+ }
386
+ interface HTMLElementEventMap {
387
+ 'hs:action:before-fetch': CustomEvent<Hyperspan.ActionFetchDetail>;
388
+ 'hs:action:after-fetch': CustomEvent<Hyperspan.ActionFetchDetail>;
389
+ 'hs:action:before-swap': CustomEvent<Hyperspan.ActionSwapDetail>;
390
+ 'hs:action:after-swap': CustomEvent<Hyperspan.ActionSwapDetail>;
391
+ 'hs:action:before-navigate': CustomEvent<Hyperspan.ActionNavigateDetail>;
392
+ }
393
+ }