@hyperspan/framework 1.0.0-alpha.3 → 1.0.0-alpha.4

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": "@hyperspan/framework",
3
- "version": "1.0.0-alpha.3",
3
+ "version": "1.0.0-alpha.4",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -10,8 +10,8 @@
10
10
  },
11
11
  "exports": {
12
12
  ".": {
13
- "types": "./src/server.ts",
14
- "default": "./src/server.ts"
13
+ "types": "./src/index.ts",
14
+ "default": "./src/index.ts"
15
15
  },
16
16
  "./server": {
17
17
  "types": "./src/server.ts",
package/src/actions.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { html, HSHtml } from '@hyperspan/html';
2
- import { createRoute, parsePath, returnHTMLResponse } from './server';
2
+ import { createRoute, returnHTMLResponse } from './server';
3
3
  import * as z from 'zod/v4';
4
4
  import type { Hyperspan as HS } from './types';
5
5
  import { assetHash } from './utils';
6
+ import * as actionsClient from './client/_hs/hyperspan-actions.client';
7
+ import { renderClientJS } from './client/js';
6
8
 
7
9
  /**
8
10
  * Actions = Form + route handler
@@ -16,45 +18,15 @@ import { assetHash } from './utils';
16
18
  * 4. All validation and save logic is run on the server
17
19
  * 5. Replaces form content in place with HTML response content from server via the Idiomorph library
18
20
  * 6. Handles any Exception thrown on server as error displayed back to user on the page
19
- */;
20
- type HSActionResponse = HSHtml | void | null | Promise<HSHtml | void | null> | Response | Promise<Response>;
21
- export type HSFormHandler<T extends z.ZodTypeAny> = (
22
- c: HS.Context,
23
- { data, error }: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
24
- ) => HSActionResponse;
25
- export interface HSAction<T extends z.ZodTypeAny> {
26
- _kind: 'hsAction';
27
- _name: string;
28
- _path(): string;
29
- _form: null | HSFormHandler<T>;
30
- form(form: HSFormHandler<T>): HSAction<T>;
31
- post(
32
- handler: (
33
- c: HS.Context,
34
- { data }: { data?: Partial<z.infer<T>> }
35
- ) => HSActionResponse
36
- ): HSAction<T>;
37
- error(
38
- handler: (
39
- c: HS.Context,
40
- { data, error }: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
41
- ) => HSActionResponse
42
- ): HSAction<T>;
43
- render(
44
- c: HS.Context,
45
- props?: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
46
- ): HSActionResponse;
47
- middleware: (middleware: Array<HS.MiddlewareFunction>) => HSAction<T>;
48
- fetch(request: Request): Response | Promise<Response>;
49
- }
50
-
51
- export function createAction<T extends z.ZodTypeAny>(params: { name: string; schema?: T }) {
21
+ */
22
+ export function createAction<T extends z.ZodTypeAny>(params: { name: string; schema?: T }): HS.Action<T> {
52
23
  const { name, schema } = params;
24
+ const path = `/__actions/${assetHash(name)}`;
53
25
 
54
- let _handler: Parameters<HSAction<T>['post']>[0] | null = null;
55
- let _errorHandler: Parameters<HSAction<T>['error']>[0] | null = null;
26
+ let _handler: Parameters<HS.Action<T>['post']>[0] | null = null;
27
+ let _errorHandler: Parameters<HS.Action<T>['errorHandler']>[0] | null = null;
56
28
 
57
- const route = createRoute()
29
+ const route = createRoute({ path, name })
58
30
  .get((c: HS.Context) => api.render(c))
59
31
  .post(async (c: HS.Context) => {
60
32
  // Parse form data
@@ -99,18 +71,18 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
99
71
  return await returnHTMLResponse(c, () => api.render(c, { data, error }), { status: 400 });
100
72
  });
101
73
 
102
- const api: HSAction<T> = {
74
+ const api: HS.Action<T> = {
103
75
  _kind: 'hsAction',
104
- _name: name,
76
+ _config: route._config,
105
77
  _path() {
106
- return `/__actions/${assetHash(name)}`;
78
+ return path;
107
79
  },
108
80
  _form: null,
109
81
  /**
110
82
  * Form to render
111
83
  * This will be wrapped in a <hs-action> web component and submitted via fetch()
112
84
  */
113
- form(form: HSFormHandler<T>) {
85
+ form(form: HS.ActionFormHandler<T>) {
114
86
  api._form = form;
115
87
  return api;
116
88
  },
@@ -125,32 +97,21 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
125
97
  return api;
126
98
  },
127
99
  /**
128
- * Cusotm error handler if you want to display something other than the default
100
+ * Get form renderer method
129
101
  */
130
- error(handler) {
102
+ render(c: HS.Context, props?: HS.ActionProps<T>) {
103
+ const formContent = api._form ? api._form(c, props || {}) : null;
104
+ return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>${renderClientJS(actionsClient)}` : null;
105
+ },
106
+ errorHandler(handler) {
131
107
  _errorHandler = handler;
132
108
  return api;
133
109
  },
134
- /**
135
- * Add middleware specific to this route
136
- */
137
- middleware(middleware) {
110
+ middleware(middleware: Array<HS.MiddlewareFunction>) {
138
111
  route.middleware(middleware);
139
112
  return api;
140
113
  },
141
- /**
142
- * Get form renderer method
143
- */
144
- render(c: HS.Context, props?: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }) {
145
- const formContent = api._form ? api._form(c, props || {}) : null;
146
- return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>` : null;
147
- },
148
- /**
149
- * Run action route handler
150
- */
151
- fetch(request: Request) {
152
- return route.fetch(request);
153
- },
114
+ fetch: route.fetch,
154
115
  };
155
116
 
156
117
  return api;
@@ -0,0 +1,98 @@
1
+ import { Idiomorph } from './idiomorph';
2
+ import { lazyLoadScripts } from './hyperspan-scripts.client';
3
+
4
+ const actionFormObserver = new MutationObserver((list) => {
5
+ list.forEach((mutation) => {
6
+ mutation.addedNodes.forEach((node) => {
7
+ if (node && ('closest' in node || node instanceof HTMLFormElement)) {
8
+ bindHSActionForm(
9
+ (node as HTMLElement).closest('hs-action') as HSAction,
10
+ node instanceof HTMLFormElement
11
+ ? node
12
+ : ((node as HTMLElement | HTMLFormElement).querySelector('form') as HTMLFormElement)
13
+ );
14
+ }
15
+ });
16
+ });
17
+ });
18
+
19
+ /**
20
+ * Server action component to handle the client-side form submission and HTML replacement
21
+ */
22
+ class HSAction extends HTMLElement {
23
+ constructor() {
24
+ super();
25
+ }
26
+
27
+ connectedCallback() {
28
+ actionFormObserver.observe(this, { childList: true, subtree: true });
29
+ bindHSActionForm(this, this.querySelector('form') as HTMLFormElement);
30
+ }
31
+ }
32
+ window.customElements.define('hs-action', HSAction);
33
+
34
+ /**
35
+ * Bind the form inside an hs-action element to the action URL and submit handler
36
+ */
37
+ function bindHSActionForm(hsActionElement: HSAction, form: HTMLFormElement) {
38
+ if (!hsActionElement || !form) {
39
+ return;
40
+ }
41
+
42
+ form.setAttribute('action', hsActionElement.getAttribute('url') || '');
43
+ const submitHandler = (e: Event) => {
44
+ e.preventDefault();
45
+ formSubmitToRoute(e, form as HTMLFormElement, {
46
+ afterResponse: () => bindHSActionForm(hsActionElement, form),
47
+ });
48
+ form.removeEventListener('submit', submitHandler);
49
+ };
50
+ form.addEventListener('submit', submitHandler);
51
+ }
52
+
53
+ /**
54
+ * Submit form data to route and replace contents with response
55
+ */
56
+ type TFormSubmitOptons = { afterResponse: () => any };
57
+ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
58
+ const formData = new FormData(form);
59
+ const formUrl = form.getAttribute('action') || '';
60
+ const method = form.getAttribute('method')?.toUpperCase() || 'POST';
61
+ const headers = {
62
+ Accept: 'text/html',
63
+ 'X-Request-Type': 'partial',
64
+ };
65
+
66
+ const hsActionTag = form.closest('hs-action');
67
+ const submitBtn = form.querySelector('button[type=submit],input[type=submit]');
68
+ if (submitBtn) {
69
+ submitBtn.setAttribute('disabled', 'disabled');
70
+ }
71
+
72
+ fetch(formUrl, { body: formData, method, headers })
73
+ .then((res: Response) => {
74
+ // Look for special header that indicates a redirect.
75
+ // fetch() automatically follows 3xx redirects, so we need to handle this manually to redirect the user to the full page
76
+ if (res.headers.has('X-Redirect-Location')) {
77
+ const newUrl = res.headers.get('X-Redirect-Location');
78
+ if (newUrl) {
79
+ window.location.assign(newUrl);
80
+ }
81
+ return '';
82
+ }
83
+
84
+ return res.text();
85
+ })
86
+ .then((content: string) => {
87
+ // No content = DO NOTHING (redirect or something else happened)
88
+ if (!content) {
89
+ return;
90
+ }
91
+
92
+ const target = content.includes('<html') ? window.document.body : hsActionTag || form;
93
+
94
+ Idiomorph.morph(target, content);
95
+ opts.afterResponse && opts.afterResponse();
96
+ lazyLoadScripts();
97
+ });
98
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Intersection observer for lazy loading <script> tags
3
+ */
4
+ const lazyLoadScriptObserver = new IntersectionObserver(
5
+ (entries, observer) => {
6
+ entries
7
+ .filter((entry) => entry.isIntersecting)
8
+ .forEach((entry) => {
9
+ observer.unobserve(entry.target);
10
+ // @ts-ignore
11
+ if (entry.target.children[0]?.content) {
12
+ // @ts-ignore
13
+ entry.target.replaceWith(entry.target.children[0].content);
14
+ }
15
+ });
16
+ },
17
+ { rootMargin: '0px 0px -200px 0px' }
18
+ );
19
+
20
+ /**
21
+ * Lazy load <script> tags in the current document
22
+ */
23
+ export function lazyLoadScripts() {
24
+ document
25
+ .querySelectorAll('div[data-loading=lazy]')
26
+ .forEach((el) => lazyLoadScriptObserver.observe(el));
27
+ }
28
+
29
+ window.addEventListener('load', () => {
30
+ lazyLoadScripts();
31
+ });
@@ -0,0 +1,94 @@
1
+ import { Idiomorph } from './idiomorph';
2
+ import { lazyLoadScripts } from './hyperspan-scripts.client';
3
+
4
+ /**
5
+ * Used for streaming content from the server to the client.
6
+ */
7
+ function htmlAsyncContentObserver() {
8
+ if (typeof MutationObserver != 'undefined') {
9
+ // Hyperspan - Async content loader
10
+ // Puts streamed content in its place immediately after it is added to the DOM
11
+ const asyncContentObserver = new MutationObserver((list) => {
12
+ const asyncContent = list
13
+ .map((mutation) =>
14
+ Array.from(mutation.addedNodes).find((node: any) => {
15
+ if (!node || !node?.id || typeof node.id !== 'string') {
16
+ return false;
17
+ }
18
+ return node.id?.startsWith('async_loading_') && node.id?.endsWith('_content');
19
+ })
20
+ )
21
+ .filter((node: any) => node);
22
+
23
+ asyncContent.forEach((templateEl: any) => {
24
+ try {
25
+ // Also observe for content inside the template content (shadow DOM is separate)
26
+ asyncContentObserver.observe(templateEl.content, { childList: true, subtree: true });
27
+
28
+ const slotId = templateEl.id.replace('_content', '');
29
+ const slotEl = document.getElementById(slotId);
30
+
31
+ if (slotEl) {
32
+ // Content AND slot are present - let's insert the content into the slot
33
+ // Ensure the content is fully done streaming in before inserting it into the slot
34
+ waitForContent(templateEl.content, (el2) => {
35
+ return Array.from(el2.childNodes).find(
36
+ (node) => node.nodeType === Node.COMMENT_NODE && node.nodeValue === 'end'
37
+ );
38
+ })
39
+ .then((endComment) => {
40
+ templateEl.content.removeChild(endComment);
41
+ const content = templateEl.content.cloneNode(true);
42
+ Idiomorph.morph(slotEl, content);
43
+ templateEl.parentNode.removeChild(templateEl);
44
+ lazyLoadScripts();
45
+ })
46
+ .catch(console.error);
47
+ } else {
48
+ // Slot is NOT present - wait for it to be added to the DOM so we can insert the content into it
49
+ waitForContent(document.body, () => {
50
+ return document.getElementById(slotId);
51
+ }).then((slotEl) => {
52
+ Idiomorph.morph(slotEl, templateEl.content.cloneNode(true));
53
+ lazyLoadScripts();
54
+ });
55
+ }
56
+ } catch (e) {
57
+ console.error(e);
58
+ }
59
+ });
60
+ });
61
+ asyncContentObserver.observe(document.body, { childList: true, subtree: true });
62
+ }
63
+ }
64
+ htmlAsyncContentObserver();
65
+
66
+ /**
67
+ * Wait until ALL of the content inside an element is present from streaming in.
68
+ * Large chunks of content can sometimes take more than a single tick to write to DOM.
69
+ */
70
+ async function waitForContent(
71
+ el: HTMLElement,
72
+ waitFn: (
73
+ node: HTMLElement
74
+ ) => HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined,
75
+ options: { timeoutMs?: number; intervalMs?: number } = { timeoutMs: 10000, intervalMs: 20 }
76
+ ): Promise<HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined> {
77
+ return new Promise((resolve, reject) => {
78
+ let timeout: NodeJS.Timeout;
79
+ const interval = setInterval(() => {
80
+ const content = waitFn(el);
81
+ if (content) {
82
+ if (timeout) {
83
+ clearTimeout(timeout);
84
+ }
85
+ clearInterval(interval);
86
+ resolve(content);
87
+ }
88
+ }, options.intervalMs || 20);
89
+ timeout = setTimeout(() => {
90
+ clearInterval(interval);
91
+ reject(new Error(`[Hyperspan] Timeout waiting for end of streaming content ${el.id}`));
92
+ }, options.timeoutMs || 10000);
93
+ });
94
+ }
package/src/client/js.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { html } from '@hyperspan/html';
2
- import type { Hyperspan as HS } from '../types';
3
2
 
4
3
  export const JS_PUBLIC_PATH = '/_hs/js';
5
4
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
@@ -59,23 +58,4 @@ export function functionToString(fn: any) {
59
58
  }
60
59
 
61
60
  return str;
62
- }
63
-
64
- /**
65
- * Island defaults
66
- */
67
- export const ISLAND_DEFAULTS: () => HS.ClientIslandOptions = () => ({
68
- ssr: true,
69
- loading: undefined,
70
- });
71
-
72
- export function renderIsland(Component: any, props: any, options = ISLAND_DEFAULTS()) {
73
- // Render island with its own logic
74
- if (Component.__HS_ISLAND?.render) {
75
- return html.raw(Component.__HS_ISLAND.render(props, options));
76
- }
77
-
78
- throw new Error(
79
- `Module ${Component.name} was not loaded with an island plugin! Did you forget to install an island plugin and add it to the 'islandPlugins' option in your hyperspan.config.ts file?`
80
- );
81
61
  }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse } from './server';
2
+ export type { Hyperspan } from './types';
package/src/plugins.ts CHANGED
@@ -46,6 +46,7 @@ export function clientJSPlugin(): HS.Plugin {
46
46
  const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
47
47
  JS_IMPORT_MAP.set(esmName, `${JS_PUBLIC_PATH}/${esmName}.js`);
48
48
 
49
+ // Get the contents of the file to extract the exports
49
50
  const contents = await result.outputs[0].text();
50
51
  const exportLine = EXPORT_REGEX.exec(contents);
51
52
 
@@ -67,9 +68,6 @@ export function clientJSPlugin(): HS.Plugin {
67
68
  const moduleCode = `// hyperspan:processed
68
69
  import { functionToString } from '@hyperspan/framework/client/js';
69
70
 
70
- // Original file contents
71
- ${contents}
72
-
73
71
  // hyperspan:client-js-plugin
74
72
  export const __CLIENT_JS = {
75
73
  id: "${jsId}",
package/src/server.ts CHANGED
@@ -2,7 +2,6 @@ import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hype
2
2
  import { executeMiddleware } from './middleware';
3
3
  import type { Hyperspan as HS } from './types';
4
4
  import { clientJSPlugin } from './plugins';
5
- import { CSS_ROUTE_MAP } from './client/css';
6
5
  export type { HS as Hyperspan };
7
6
 
8
7
  export const IS_PROD = process.env.NODE_ENV === 'production';
@@ -135,6 +134,10 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
135
134
  _middleware['OPTIONS'] = handlerOptions?.middleware || [];
136
135
  return api;
137
136
  },
137
+ errorHandler(handler: HS.RouteHandler) {
138
+ _handlers['_ERROR'] = handler;
139
+ return api;
140
+ },
138
141
  /**
139
142
  * Add middleware specific to this route
140
143
  */
@@ -196,7 +199,16 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
196
199
  return routeContent;
197
200
  };
198
201
 
199
- return executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
202
+ // Run the route handler and any middleware
203
+ // If an error occurs, run the error handler if it exists
204
+ try {
205
+ return await executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
206
+ } catch (e) {
207
+ if (_handlers['_ERROR']) {
208
+ return await (_handlers['_ERROR'](context) as Promise<Response>);
209
+ }
210
+ throw e;
211
+ }
200
212
  },
201
213
  };
202
214
 
@@ -329,11 +341,6 @@ export function getRunnableRoute(route: unknown, routeConfig?: HS.RouteConfig):
329
341
 
330
342
  const kind = typeof route;
331
343
 
332
- // Plain function - wrap in createRoute()
333
- if (kind === 'function') {
334
- return createRoute(routeConfig).get(route as HS.RouteHandler);
335
- }
336
-
337
344
  // Module - get default and use it
338
345
  // @ts-ignore
339
346
  if (kind === 'object' && 'default' in route) {
@@ -355,7 +362,7 @@ export function isRunnableRoute(route: unknown): boolean {
355
362
  }
356
363
 
357
364
  const obj = route as { _kind: string; fetch: (request: Request) => Promise<Response> };
358
- return 'hsRoute' === obj?._kind && 'fetch' in obj;
365
+ return typeof obj?._kind === 'string' && 'fetch' in obj;
359
366
  }
360
367
 
361
368
  /**
@@ -365,7 +372,7 @@ export function isValidRoutePath(path: string): boolean {
365
372
  const isHiddenRoute = path.includes('/__');
366
373
  const isTestFile = path.includes('.test') || path.includes('.spec');
367
374
 
368
- return !isHiddenRoute && !isTestFile;
375
+ return !isHiddenRoute && !isTestFile && Boolean(path);
369
376
  }
370
377
 
371
378
  /**
package/src/types.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { HSHtml } from '@hyperspan/html';
2
+ import * as z from 'zod/v4';
3
+
1
4
  /**
2
5
  * Hyperspan Types
3
6
  */
@@ -94,7 +97,6 @@ export namespace Hyperspan {
94
97
 
95
98
  export interface Route {
96
99
  _kind: 'hsRoute';
97
- _name: string | undefined;
98
100
  _config: Hyperspan.RouteConfig;
99
101
  _path(): string;
100
102
  _methods(): string[];
@@ -104,7 +106,29 @@ export namespace Hyperspan {
104
106
  patch: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
105
107
  delete: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
106
108
  options: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
109
+ errorHandler: (handler: Hyperspan.RouteHandler) => Hyperspan.Route;
107
110
  middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Hyperspan.Route;
108
111
  fetch: (request: Request) => Promise<Response>;
109
112
  };
113
+
114
+ /**
115
+ * Action = Form + route handler
116
+ */
117
+ export type ActionResponse = HSHtml | void | null | Promise<HSHtml | void | null> | Response | Promise<Response>;
118
+ export type ActionProps<T extends z.ZodTypeAny> = { data?: Partial<z.infer<T>>; error?: z.ZodError | Error };
119
+ export type ActionFormHandler<T extends z.ZodTypeAny> = (
120
+ c: Context, props: ActionProps<T>
121
+ ) => ActionResponse;
122
+ export interface Action<T extends z.ZodTypeAny> {
123
+ _kind: 'hsAction';
124
+ _config: Hyperspan.RouteConfig;
125
+ _path(): string;
126
+ _form: null | ActionFormHandler<T>;
127
+ form(form: ActionFormHandler<T>): Action<T>;
128
+ render: (c: Context, props?: ActionProps<T>) => ActionResponse;
129
+ post: (handler: ActionFormHandler<T>) => Action<T>;
130
+ errorHandler: (handler: ActionFormHandler<T>) => Action<T>;
131
+ middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Action<T>;
132
+ fetch: (request: Request) => Promise<Response>;
133
+ }
110
134
  }
@@ -1,224 +0,0 @@
1
- import { html } from '@hyperspan/html';
2
- import { Idiomorph } from './idiomorph';
3
-
4
- /**
5
- * Used for streaming content from the server to the client.
6
- */
7
- function htmlAsyncContentObserver() {
8
- if (typeof MutationObserver != 'undefined') {
9
- // Hyperspan - Async content loader
10
- // Puts streamed content in its place immediately after it is added to the DOM
11
- const asyncContentObserver = new MutationObserver((list) => {
12
- const asyncContent = list
13
- .map((mutation) =>
14
- Array.from(mutation.addedNodes).find((node: any) => {
15
- if (!node || !node?.id || typeof node.id !== 'string') {
16
- return false;
17
- }
18
- return node.id?.startsWith('async_loading_') && node.id?.endsWith('_content');
19
- })
20
- )
21
- .filter((node: any) => node);
22
-
23
- asyncContent.forEach((templateEl: any) => {
24
- try {
25
- // Also observe for content inside the template content (shadow DOM is separate)
26
- asyncContentObserver.observe(templateEl.content, { childList: true, subtree: true });
27
-
28
- const slotId = templateEl.id.replace('_content', '');
29
- const slotEl = document.getElementById(slotId);
30
-
31
- if (slotEl) {
32
- // Content AND slot are present - let's insert the content into the slot
33
- // Ensure the content is fully done streaming in before inserting it into the slot
34
- waitForContent(templateEl.content, (el2) => {
35
- return Array.from(el2.childNodes).find(
36
- (node) => node.nodeType === Node.COMMENT_NODE && node.nodeValue === 'end'
37
- );
38
- })
39
- .then((endComment) => {
40
- templateEl.content.removeChild(endComment);
41
- const content = templateEl.content.cloneNode(true);
42
- Idiomorph.morph(slotEl, content);
43
- templateEl.parentNode.removeChild(templateEl);
44
- lazyLoadScripts();
45
- })
46
- .catch(console.error);
47
- } else {
48
- // Slot is NOT present - wait for it to be added to the DOM so we can insert the content into it
49
- waitForContent(document.body, () => {
50
- return document.getElementById(slotId);
51
- }).then((slotEl) => {
52
- Idiomorph.morph(slotEl, templateEl.content.cloneNode(true));
53
- lazyLoadScripts();
54
- });
55
- }
56
- } catch (e) {
57
- console.error(e);
58
- }
59
- });
60
- });
61
- asyncContentObserver.observe(document.body, { childList: true, subtree: true });
62
- }
63
- }
64
- htmlAsyncContentObserver();
65
-
66
- /**
67
- * Wait until ALL of the content inside an element is present from streaming in.
68
- * Large chunks of content can sometimes take more than a single tick to write to DOM.
69
- */
70
- async function waitForContent(
71
- el: HTMLElement,
72
- waitFn: (
73
- node: HTMLElement
74
- ) => HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined,
75
- options: { timeoutMs?: number; intervalMs?: number } = { timeoutMs: 10000, intervalMs: 20 }
76
- ): Promise<HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined> {
77
- return new Promise((resolve, reject) => {
78
- let timeout: NodeJS.Timeout;
79
- const interval = setInterval(() => {
80
- const content = waitFn(el);
81
- if (content) {
82
- if (timeout) {
83
- clearTimeout(timeout);
84
- }
85
- clearInterval(interval);
86
- resolve(content);
87
- }
88
- }, options.intervalMs || 20);
89
- timeout = setTimeout(() => {
90
- clearInterval(interval);
91
- reject(new Error(`[Hyperspan] Timeout waiting for end of streaming content ${el.id}`));
92
- }, options.timeoutMs || 10000);
93
- });
94
- }
95
-
96
- /**
97
- * Server action component to handle the client-side form submission and HTML replacement
98
- */
99
- class HSAction extends HTMLElement {
100
- constructor() {
101
- super();
102
- }
103
-
104
- connectedCallback() {
105
- actionFormObserver.observe(this, { childList: true, subtree: true });
106
- bindHSActionForm(this, this.querySelector('form') as HTMLFormElement);
107
- }
108
- }
109
- window.customElements.define('hs-action', HSAction);
110
- const actionFormObserver = new MutationObserver((list) => {
111
- list.forEach((mutation) => {
112
- mutation.addedNodes.forEach((node) => {
113
- if (node && ('closest' in node || node instanceof HTMLFormElement)) {
114
- bindHSActionForm(
115
- (node as HTMLElement).closest('hs-action') as HSAction,
116
- node instanceof HTMLFormElement
117
- ? node
118
- : ((node as HTMLElement | HTMLFormElement).querySelector('form') as HTMLFormElement)
119
- );
120
- }
121
- });
122
- });
123
- });
124
-
125
- /**
126
- * Bind the form inside an hs-action element to the action URL and submit handler
127
- */
128
- function bindHSActionForm(hsActionElement: HSAction, form: HTMLFormElement) {
129
- if (!hsActionElement || !form) {
130
- return;
131
- }
132
-
133
- form.setAttribute('action', hsActionElement.getAttribute('url') || '');
134
- const submitHandler = (e: Event) => {
135
- e.preventDefault();
136
- formSubmitToRoute(e, form as HTMLFormElement, {
137
- afterResponse: () => bindHSActionForm(hsActionElement, form),
138
- });
139
- form.removeEventListener('submit', submitHandler);
140
- };
141
- form.addEventListener('submit', submitHandler);
142
- }
143
-
144
- /**
145
- * Submit form data to route and replace contents with response
146
- */
147
- type TFormSubmitOptons = { afterResponse: () => any };
148
- function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
149
- const formData = new FormData(form);
150
- const formUrl = form.getAttribute('action') || '';
151
- const method = form.getAttribute('method')?.toUpperCase() || 'POST';
152
- const headers = {
153
- Accept: 'text/html',
154
- 'X-Request-Type': 'partial',
155
- };
156
-
157
- const hsActionTag = form.closest('hs-action');
158
- const submitBtn = form.querySelector('button[type=submit],input[type=submit]');
159
- if (submitBtn) {
160
- submitBtn.setAttribute('disabled', 'disabled');
161
- }
162
-
163
- fetch(formUrl, { body: formData, method, headers })
164
- .then((res: Response) => {
165
- // Look for special header that indicates a redirect.
166
- // fetch() automatically follows 3xx redirects, so we need to handle this manually to redirect the user to the full page
167
- if (res.headers.has('X-Redirect-Location')) {
168
- const newUrl = res.headers.get('X-Redirect-Location');
169
- if (newUrl) {
170
- window.location.assign(newUrl);
171
- }
172
- return '';
173
- }
174
-
175
- return res.text();
176
- })
177
- .then((content: string) => {
178
- // No content = DO NOTHING (redirect or something else happened)
179
- if (!content) {
180
- return;
181
- }
182
-
183
- const target = content.includes('<html') ? window.document.body : hsActionTag || form;
184
-
185
- Idiomorph.morph(target, content);
186
- opts.afterResponse && opts.afterResponse();
187
- lazyLoadScripts();
188
- });
189
- }
190
-
191
- /**
192
- * Intersection observer for lazy loading <script> tags
193
- */
194
- const lazyLoadScriptObserver = new IntersectionObserver(
195
- (entries, observer) => {
196
- entries
197
- .filter((entry) => entry.isIntersecting)
198
- .forEach((entry) => {
199
- observer.unobserve(entry.target);
200
- // @ts-ignore
201
- if (entry.target.children[0]?.content) {
202
- // @ts-ignore
203
- entry.target.replaceWith(entry.target.children[0].content);
204
- }
205
- });
206
- },
207
- { rootMargin: '0px 0px -200px 0px' }
208
- );
209
-
210
- /**
211
- * Lazy load <script> tags in the current document
212
- */
213
- function lazyLoadScripts() {
214
- document
215
- .querySelectorAll('div[data-loading=lazy]')
216
- .forEach((el) => lazyLoadScriptObserver.observe(el));
217
- }
218
-
219
- window.addEventListener('load', () => {
220
- lazyLoadScripts();
221
- });
222
-
223
- // @ts-ignore
224
- window.html = html;
File without changes