@hyperspan/framework 2.0.0-alpha.1 → 2.0.0-alpha.3

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": "2.0.0-alpha.1",
3
+ "version": "2.0.0-alpha.3",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "type": "module",
6
6
  "main": "src/server.ts",
@@ -27,6 +27,10 @@
27
27
  "types": "./src/utils.ts",
28
28
  "default": "./src/utils.ts"
29
29
  },
30
+ "./routes": {
31
+ "types": "./src/register-routes.ts",
32
+ "default": "./src/register-routes.ts"
33
+ },
30
34
  "./layout": {
31
35
  "types": "./src/layout.ts",
32
36
  "default": "./src/layout.ts"
@@ -96,7 +100,7 @@
96
100
  "vitest": "^3.2.4"
97
101
  },
98
102
  "dependencies": {
99
- "@hyperspan/html": "^2.0.0-alpha.1",
103
+ "@hyperspan/html": "^2.0.0-alpha.3",
100
104
  "debug": "^4.4.3",
101
105
  "isbot": "^5.1.32",
102
106
  "zod": "^4.4.3"
@@ -59,62 +59,56 @@ function setActionLoading(hsActionTag: HTMLElement | null, loading: boolean) {
59
59
  }
60
60
  }
61
61
 
62
- const actionFormObserver = new MutationObserver((list) => {
63
- list.forEach((mutation) => {
64
- mutation.addedNodes.forEach((node) => {
65
- if (node && ('closest' in node || node instanceof HTMLFormElement)) {
66
- bindHSActionForm(
67
- (node as HTMLElement).closest('hs-action') as HSAction,
68
- node instanceof HTMLFormElement
69
- ? node
70
- : ((node as HTMLElement | HTMLFormElement).querySelector('form') as HTMLFormElement)
71
- );
72
- }
73
- });
74
- });
75
- });
76
-
77
62
  /**
78
- * Server action component to handle the client-side form submission and HTML replacement
63
+ * Server action component — holds the action URL; form submit is handled via delegation.
79
64
  */
80
- class HSAction extends HTMLElement {
81
- constructor() {
82
- super();
83
- }
84
-
85
- connectedCallback() {
86
- actionFormObserver.observe(this, { childList: true, subtree: true });
87
- bindHSActionForm(this, this.querySelector('form') as HTMLFormElement);
88
- }
89
- }
65
+ class HSAction extends HTMLElement {}
90
66
  window.customElements.define('hs-action', HSAction);
91
67
 
92
- /**
93
- * Bind the form inside an hs-action element to the action URL and submit handler
94
- */
95
- function bindHSActionForm(hsActionElement: HSAction, form: HTMLFormElement) {
96
- if (!hsActionElement || !form) {
68
+ let actionDelegationInitialized = false;
69
+
70
+ /** One document-level handler so morphs never leave forms without listeners or stack duplicates. */
71
+ function initHSActionFormDelegation() {
72
+ if (actionDelegationInitialized) {
97
73
  return;
98
74
  }
75
+ actionDelegationInitialized = true;
99
76
 
100
- form.setAttribute('action', hsActionElement.getAttribute('url') || '');
101
- const submitHandler = (e: Event) => {
102
- e.preventDefault();
103
- formSubmitToRoute(e, form as HTMLFormElement, {
104
- afterResponse: () => bindHSActionForm(hsActionElement, form),
105
- });
106
- form.removeEventListener('submit', submitHandler);
107
- };
108
- form.addEventListener('submit', submitHandler);
77
+ document.addEventListener(
78
+ 'submit',
79
+ (e) => {
80
+ const form = e.target;
81
+ if (!(form instanceof HTMLFormElement)) {
82
+ return;
83
+ }
84
+
85
+ const hsAction = form.closest('hs-action');
86
+ if (!hsAction) {
87
+ return;
88
+ }
89
+
90
+ e.preventDefault();
91
+
92
+ const url = hsAction.getAttribute('url') || '';
93
+ if (url) {
94
+ form.setAttribute('action', url);
95
+ }
96
+
97
+ formSubmitToRoute(form);
98
+ },
99
+ true
100
+ );
109
101
  }
110
102
 
103
+ initHSActionFormDelegation();
104
+
111
105
  /**
112
106
  * Submit form data to route and replace contents with response
113
107
  */
114
- type TFormSubmitOptons = { afterResponse: () => any };
115
- function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
108
+ function formSubmitToRoute(form: HTMLFormElement) {
116
109
  const formData = new FormData(form);
117
- const formUrl = form.getAttribute('action') || '';
110
+ const hsActionTag = form.closest('hs-action') as HTMLElement | null;
111
+ const formUrl = hsActionTag?.getAttribute('url') || form.getAttribute('action') || '';
118
112
  const method = form.getAttribute('method')?.toUpperCase() || 'POST';
119
113
  const confirmMessage = form.getAttribute('data-confirm') || '';
120
114
  const headers = {
@@ -129,7 +123,6 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
129
123
  }
130
124
  }
131
125
 
132
- const hsActionTag = form.closest('hs-action') as HTMLElement | null;
133
126
  const eventTarget: EventTarget = hsActionTag || document;
134
127
  const fetchDetail: HS.ActionFetchDetail = {
135
128
  form,
@@ -185,7 +178,6 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
185
178
 
186
179
  dispatchActionEvent(eventTarget, HS_ACTION_AFTER_SWAP, swapDetail);
187
180
 
188
- opts.afterResponse && opts.afterResponse();
189
181
  lazyLoadScripts();
190
182
  }
191
183
 
@@ -1,5 +1,90 @@
1
- import { describe, expect, test } from 'vitest';
2
- import { extractExports } from './js';
1
+ import { describe, expect, test, beforeEach } from 'vitest';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { render } from '@hyperspan/html';
6
+ import {
7
+ buildClientJS,
8
+ discoverClientExports,
9
+ extractExports,
10
+ getClientJSEntries,
11
+ resetClientJSEntriesForTests,
12
+ } from './js';
13
+
14
+ describe('buildClientJS', () => {
15
+ let clientFile: string;
16
+
17
+ beforeEach(() => {
18
+ resetClientJSEntriesForTests();
19
+ const dir = mkdtempSync(join(tmpdir(), 'hs-client-'));
20
+ clientFile = join(dir, 'picker.ts');
21
+ writeFileSync(
22
+ clientFile,
23
+ `export function mountPicker() {
24
+ document.getElementById('picker')?.classList.add('ready');
25
+ }
26
+ `
27
+ );
28
+ });
29
+
30
+ test('registers entry and returns stable esmName from path hash', async () => {
31
+ const result = await buildClientJS(clientFile);
32
+
33
+ expect(result.esmName).toMatch(/^client-[0-9a-f]{16}$/);
34
+ expect(result.publicPath).toBe(`/_hs/js/${result.esmName}.js`);
35
+ expect(getClientJSEntries()).toHaveLength(1);
36
+ expect(getClientJSEntries()[0].absPath).toBe(clientFile);
37
+ });
38
+
39
+ test('renderScriptTag with no loader uses import map key', async () => {
40
+ const result = await buildClientJS(clientFile);
41
+ const tag = render(result.renderScriptTag());
42
+
43
+ expect(tag).toContain(`import "${result.esmName}"`);
44
+ expect(tag).toContain(`data-source-id="${result.assetHash}"`);
45
+ });
46
+
47
+ test('renderScriptTag with function inlines bootstrap code', async () => {
48
+ const result = await buildClientJS(clientFile);
49
+ const tag = render(
50
+ result.renderScriptTag(({ mountPicker }: { mountPicker: () => void }) => {
51
+ mountPicker();
52
+ })
53
+ );
54
+
55
+ expect(tag).toContain(`import {mountPicker} from "${result.esmName}"`);
56
+ expect(tag).toContain('mountPicker()');
57
+ });
58
+
59
+ test('renderScriptTag with string inlines bootstrap code', async () => {
60
+ const result = await buildClientJS(clientFile);
61
+ const tag = render(result.renderScriptTag('({ mountPicker }) => mountPicker()'));
62
+
63
+ expect(tag).toContain('({ mountPicker }) => mountPicker()');
64
+ });
65
+
66
+ test('logical app-relative paths hash consistently and resolve on disk when present', async () => {
67
+ const dir = mkdtempSync(join(tmpdir(), 'hs-client-logical-'));
68
+ const { mkdirSync } = await import('node:fs');
69
+ mkdirSync(join(dir, 'app/client'), { recursive: true });
70
+ const logicalPath = join(dir, 'app/client/stats.ts');
71
+ writeFileSync(logicalPath, `export function mountStats() {}\n`);
72
+
73
+ const cwd = process.cwd();
74
+ process.chdir(dir);
75
+ try {
76
+ const first = await buildClientJS('app/client/stats.ts');
77
+ resetClientJSEntriesForTests();
78
+ const second = await buildClientJS('app/client/stats.ts');
79
+ expect(first.esmName).toBe(second.esmName);
80
+ expect(getClientJSEntries()[0].absPath.replace(/^\/private/, '')).toBe(
81
+ logicalPath.replace(/^\/private/, '')
82
+ );
83
+ } finally {
84
+ process.chdir(cwd);
85
+ }
86
+ });
87
+ });
3
88
 
4
89
  describe('extractExports', () => {
5
90
  test('extracts aliased export from bundled JS content', () => {
@@ -14,7 +99,7 @@ describe('extractExports', () => {
14
99
  });
15
100
  });
16
101
 
17
- test('returns empty exports when none found', () => {
102
+ test('returns namespace export when none found', () => {
18
103
  const contents = 'function noop(){return 1}const value=2;';
19
104
 
20
105
  const result = extractExports(contents);
@@ -25,3 +110,14 @@ describe('extractExports', () => {
25
110
  });
26
111
  });
27
112
  });
113
+
114
+ describe('discoverClientExports', () => {
115
+ test('discovers named exports from source', () => {
116
+ const result = discoverClientExports(
117
+ `export function mountPicker() {}\nexport const VERSION = 1;`
118
+ );
119
+
120
+ expect(result.exports).toBe('{mountPicker, VERSION}');
121
+ expect(result.fnArgs).toBe('{mountPicker, VERSION}');
122
+ });
123
+ });
package/src/client/js.ts CHANGED
@@ -1,4 +1,8 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { isAbsolute, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
1
4
  import { html } from '@hyperspan/html';
5
+ import { assetHash as assetHashFn } from '../utils';
2
6
  import { getImportMap, registerImport, resolveImport } from './manifest';
3
7
  import type { Hyperspan as HS } from '../types';
4
8
 
@@ -7,6 +11,27 @@ export { registerImport, resolveImport, getImportMap } from './manifest';
7
11
  export const JS_PUBLIC_PATH = '/_hs/js';
8
12
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
9
13
 
14
+ export type ClientJSEntry = {
15
+ absPath: string;
16
+ assetHash: string;
17
+ esmName: string;
18
+ publicPath: string;
19
+ exports: string;
20
+ fnArgs: string;
21
+ };
22
+
23
+ const CLIENT_JS_REGISTRY = Symbol.for('@hyperspan/client-js-entries');
24
+
25
+ function getClientJSRegistry(): Map<string, ClientJSEntry> {
26
+ const globalRegistry = globalThis as {
27
+ [CLIENT_JS_REGISTRY]?: Map<string, ClientJSEntry>;
28
+ };
29
+ if (!globalRegistry[CLIENT_JS_REGISTRY]) {
30
+ globalRegistry[CLIENT_JS_REGISTRY] = new Map();
31
+ }
32
+ return globalRegistry[CLIENT_JS_REGISTRY];
33
+ }
34
+
10
35
  /**
11
36
  * Backward-compatible import map (backed by asset manifest).
12
37
  */
@@ -35,40 +60,175 @@ export const JS_IMPORT_MAP = {
35
60
  },
36
61
  };
37
62
 
38
- const EXPORT_REGEX = /export\{(.*)\}/g;
63
+ /**
64
+ * Resolve import.meta.resolve() / file URL / absolute path to a filesystem path.
65
+ */
66
+ export function resolveClientModulePath(modulePathResolved: string): string {
67
+ if (modulePathResolved.startsWith('file://')) {
68
+ return fileURLToPath(modulePathResolved);
69
+ }
70
+ return modulePathResolved;
71
+ }
39
72
 
40
73
  /**
41
- * @deprecated Use build-time asset manifest via @hyperspan/vite-plugin instead.
42
- * Kept for compatibility — returns manifest entry if available.
74
+ * Resolve a client module path for hashing and bundling.
75
+ * Logical app-relative paths (e.g. app/client/foo.ts) hash consistently across
76
+ * Node and edge runtimes where absolute paths differ.
43
77
  */
44
- export async function buildClientJS(_modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
45
- const { getClientJSFromManifest } = await import('./manifest');
46
- const moduleId = _modulePathResolved.includes('streaming')
47
- ? 'streaming'
48
- : _modulePathResolved.includes('actions')
49
- ? 'actions'
50
- : 'scripts';
51
-
52
- try {
53
- return getClientJSFromManifest(moduleId);
54
- } catch {
55
- const esmName = moduleId;
56
- const publicPath = `${JS_PUBLIC_PATH}/hyperspan-${moduleId}.client.js`;
57
- registerImport(esmName, publicPath);
58
- return {
59
- assetHash: moduleId,
60
- esmName,
61
- publicPath,
62
- renderScriptTag: () => html`<script type="module" src="${publicPath}"></script>`,
63
- };
78
+ export function resolveClientModulePaths(modulePathResolved: string): {
79
+ hashKey: string;
80
+ absPath: string;
81
+ } {
82
+ const resolved = resolveClientModulePath(modulePathResolved).replace(/\\/g, '/');
83
+
84
+ if (!isAbsolute(resolved)) {
85
+ const hashKey = resolved;
86
+ if (typeof process !== 'undefined' && typeof process.cwd === 'function') {
87
+ const candidate = join(process.cwd(), hashKey);
88
+ if (existsSync(candidate)) {
89
+ return { hashKey, absPath: candidate };
90
+ }
91
+ }
92
+ return { hashKey, absPath: hashKey };
64
93
  }
94
+
95
+ return { hashKey: resolved, absPath: resolved };
96
+ }
97
+
98
+ export function getClientJSEntries(): ClientJSEntry[] {
99
+ return [...getClientJSRegistry().values()];
100
+ }
101
+
102
+ export function getClientJSEntryByEsmName(esmName: string): ClientJSEntry | undefined {
103
+ return getClientJSRegistry().get(esmName);
104
+ }
105
+
106
+ export function resetClientJSEntriesForTests(): void {
107
+ getClientJSRegistry().clear();
108
+ }
109
+
110
+ function registerClientJSEntry(entry: ClientJSEntry): void {
111
+ getClientJSRegistry().set(entry.esmName, entry);
112
+ }
113
+
114
+ /**
115
+ * Build (or look up) a client JS module and return a helper for rendering script tags.
116
+ *
117
+ * Never evaluates the module on the server — only registers the path for Vite to bundle
118
+ * and returns URLs / script-tag helpers for the browser.
119
+ */
120
+ export async function buildClientJS(modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
121
+ const { hashKey, absPath } = resolveClientModulePaths(modulePathResolved);
122
+ const hash = assetHashFn(hashKey);
123
+ const esmName = `client-${hash}`;
124
+ const publicPath = resolveImport(esmName) ?? `${JS_PUBLIC_PATH}/${esmName}.js`;
125
+
126
+ let exports = '* as _module';
127
+ let fnArgs = '_module';
128
+ const sourcePath = existsSync(absPath) ? absPath : null;
129
+ if (sourcePath) {
130
+ const source = readFileSync(sourcePath, 'utf-8');
131
+ const discovered = discoverClientExports(source);
132
+ exports = discovered.exports;
133
+ fnArgs = discovered.fnArgs;
134
+ }
135
+
136
+ registerClientJSEntry({ absPath, assetHash: hash, esmName, publicPath, exports, fnArgs });
137
+ registerImport(esmName, publicPath);
138
+
139
+ return {
140
+ assetHash: hash,
141
+ esmName,
142
+ publicPath,
143
+ renderScriptTag: (loadScript) => {
144
+ const t = typeof loadScript;
145
+
146
+ if (t === 'string') {
147
+ return html`
148
+ <script type="module" data-source-id="${hash}">
149
+ import ${exports} from '${esmName}';
150
+ (${html.raw(loadScript as string)})(${fnArgs});
151
+ </script>
152
+ `;
153
+ }
154
+ if (t === 'function') {
155
+ return html`
156
+ <script type="module" data-source-id="${hash}">
157
+ import ${exports} from '${esmName}';
158
+ (${html.raw(functionToString(loadScript))})(${fnArgs});
159
+ </script>
160
+ `;
161
+ }
162
+
163
+ return html`
164
+ <script type="module" data-source-id="${hash}">
165
+ import '${esmName}';
166
+ </script>
167
+ `;
168
+ },
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Discover export names from source or bundled client JS.
174
+ * Prefers bundled `export{...}` form, then common source export patterns.
175
+ */
176
+ export function discoverClientExports(contents: string): { exports: string; fnArgs: string } {
177
+ const bundled = extractExports(contents);
178
+ if (bundled.exports !== '* as _module') {
179
+ return bundled;
180
+ }
181
+
182
+ const names: string[] = [];
183
+ const seen = new Set<string>();
184
+
185
+ for (const re of [
186
+ /export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
187
+ /export\s+const\s+([A-Za-z_$][\w$]*)/g,
188
+ /export\s+class\s+([A-Za-z_$][\w$]*)/g,
189
+ /export\s+let\s+([A-Za-z_$][\w$]*)/g,
190
+ ]) {
191
+ let match: RegExpExecArray | null;
192
+ while ((match = re.exec(contents)) !== null) {
193
+ if (!seen.has(match[1])) {
194
+ names.push(match[1]);
195
+ seen.add(match[1]);
196
+ }
197
+ }
198
+ }
199
+
200
+ const exportBlock = /export\s*\{([^}]+)\}/g;
201
+ let block: RegExpExecArray | null;
202
+ while ((block = exportBlock.exec(contents)) !== null) {
203
+ for (const part of block[1].split(',')) {
204
+ const trimmed = part.trim();
205
+ if (!trimmed || trimmed.startsWith('type ')) continue;
206
+ const asMatch = trimmed.match(/^(?:([\w$]+)\s+as\s+)?([\w$]+)$/);
207
+ const name = asMatch?.[2] ?? trimmed;
208
+ if (name && name !== 'default' && !seen.has(name)) {
209
+ names.push(name);
210
+ seen.add(name);
211
+ }
212
+ }
213
+ }
214
+
215
+ if (names.length === 0) {
216
+ if (/export\s+default\b/.test(contents)) {
217
+ return { exports: 'DefaultExport', fnArgs: 'DefaultExport' };
218
+ }
219
+ return { exports: '* as _module', fnArgs: '_module' };
220
+ }
221
+
222
+ const exports = `{${names.join(', ')}}`;
223
+ return { exports, fnArgs: exports };
65
224
  }
66
225
 
67
226
  /**
68
- * Extract the exports from a client JS module
227
+ * Extract the exports from a client JS module (bundled `export{a as b}` form).
69
228
  */
70
229
  export function extractExports(contents: string): { exports: string; fnArgs: string } {
71
- const exportLine = EXPORT_REGEX.exec(contents);
230
+ const exportRegex = /export\{(.*)\}/g;
231
+ const exportLine = exportRegex.exec(contents);
72
232
  let exports = '{}';
73
233
  let fnArgs = '{}';
74
234
 
package/src/index.ts CHANGED
@@ -19,5 +19,10 @@ export {
19
19
  getImportMap,
20
20
  registerImport,
21
21
  } from './client/manifest';
22
+ export { buildClientJS } from './client/js';
23
+ export { registerRouteModule, registerRouteModules } from './register-routes';
24
+ export type { RouteModuleEntry } from './register-routes';
22
25
  export type { AssetManifest } from './client/manifest';
23
- export type { Hyperspan } from './types';
26
+ export { renderIsland } from './island';
27
+ export type { IslandRenderOptions } from './island';
28
+ export type { Hyperspan, ServerCreateContext, DeployTarget } from './types';
@@ -0,0 +1,30 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { renderIsland } from './island';
3
+
4
+ describe('renderIsland', () => {
5
+ test('throws when component has no __HS_ISLAND', () => {
6
+ expect(() => renderIsland({ name: 'Bare' } as never, {})).toThrow(/not a Hyperspan island/i);
7
+ });
8
+
9
+ test('returns html.raw for sync island render', () => {
10
+ const component = {
11
+ name: 'Counter',
12
+ __HS_ISLAND: {
13
+ render: () => '<div id="x">1</div>',
14
+ },
15
+ };
16
+ const result = renderIsland(component, { count: 1 });
17
+ expect(result).toHaveProperty('_kind', 'html_safe');
18
+ expect((result as { content: string }).content).toContain('<div id="x">1</div>');
19
+ });
20
+
21
+ test('awaits async island render', async () => {
22
+ const component = {
23
+ __HS_ISLAND: {
24
+ render: async () => '<div>async</div>',
25
+ },
26
+ };
27
+ const result = await renderIsland(component, {});
28
+ expect(result.content).toContain('<div>async</div>');
29
+ });
30
+ });
package/src/island.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { html } from '@hyperspan/html';
2
+
3
+ type IslandComponent = {
4
+ __HS_ISLAND?: {
5
+ render: (props: unknown, options: unknown) => string | Promise<string>;
6
+ };
7
+ name?: string;
8
+ };
9
+
10
+ export type IslandRenderOptions = {
11
+ ssr?: boolean;
12
+ loading?: 'lazy' | undefined;
13
+ };
14
+
15
+ /**
16
+ * Render any Hyperspan island component (Preact, Svelte, Vue).
17
+ * Proxies to `Component.__HS_ISLAND.render` injected by the island Vite plugin.
18
+ */
19
+ export function renderIsland(
20
+ Component: IslandComponent,
21
+ props: Record<string, unknown> = {},
22
+ options: IslandRenderOptions = { ssr: true }
23
+ ) {
24
+ if (!Component?.__HS_ISLAND?.render) {
25
+ const label = Component?.name ?? 'Component';
26
+ throw new Error(
27
+ `${label} is not a Hyperspan island. Import it with \`with { island: 'preact' | 'svelte' | 'vue' }\` ` +
28
+ `and ensure the matching plugin is listed in hyperspan.config.ts plugins.`
29
+ );
30
+ }
31
+
32
+ const result = Component.__HS_ISLAND.render(props, options);
33
+ if (result != null && typeof (result as Promise<string>).then === 'function') {
34
+ return (result as Promise<string>).then((s) => html.raw(s));
35
+ }
36
+ return html.raw(result as string);
37
+ }
@@ -0,0 +1,64 @@
1
+ import { getRunnableRoute } from './server';
2
+ import { isValidRoutePath, parsePath } from './utils';
3
+ import type { AssetManifest } from './client/manifest';
4
+ import type { Hyperspan as HS } from './types';
5
+
6
+ export type RouteModuleEntry = {
7
+ file: string;
8
+ mod: unknown;
9
+ };
10
+
11
+ /**
12
+ * Register a route or action module on a Hyperspan server.
13
+ * Shared by the CLI, Vite plugin, and the generated production server entry.
14
+ */
15
+ export function registerRouteModule(
16
+ server: HS.Server,
17
+ relativeFilePath: string,
18
+ mod: unknown,
19
+ manifest?: AssetManifest
20
+ ): HS.Route | null {
21
+ if (!isValidRoutePath(relativeFilePath)) {
22
+ return null;
23
+ }
24
+
25
+ const route = getRunnableRoute(mod);
26
+ const parsedPath = parsePath(relativeFilePath);
27
+
28
+ let path = parsedPath.path;
29
+ if (typeof route._path === 'function') {
30
+ const routePath = route._path();
31
+ if (routePath && routePath !== '/') path = routePath;
32
+ }
33
+
34
+ if (!route._config.path) {
35
+ route._config.path = path;
36
+ if (parsedPath.params.length > 0) {
37
+ const params = route._config.params ?? {};
38
+ parsedPath.params.forEach((param) => {
39
+ params[param] = undefined;
40
+ });
41
+ route._config.params = params;
42
+ }
43
+ }
44
+
45
+ if (manifest) {
46
+ const cssFiles = manifest.css?.[path] ?? manifest.css?.['*'];
47
+ if (cssFiles?.length) {
48
+ route._config.cssImports = cssFiles;
49
+ }
50
+ }
51
+
52
+ server._routes.push(route);
53
+ return route;
54
+ }
55
+
56
+ export function registerRouteModules(
57
+ server: HS.Server,
58
+ modules: RouteModuleEntry[],
59
+ manifest?: AssetManifest
60
+ ): void {
61
+ for (const { file, mod } of modules) {
62
+ registerRouteModule(server, file, mod, manifest);
63
+ }
64
+ }
package/src/server.ts CHANGED
@@ -51,6 +51,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
51
51
  appDir: './app',
52
52
  publicDir: './public',
53
53
  plugins: [],
54
+ deployTarget: 'node',
54
55
  responseOptions: {
55
56
  disableStreaming: hyperspanDisableStreaming,
56
57
  },
@@ -541,7 +542,7 @@ export async function returnHTMLResponse(
541
542
  if (!disableStreaming && (routeContent as HSHtml).asyncContent?.length > 0) {
542
543
  return new StreamResponse(
543
544
  renderStream(routeContent as HSHtml, {
544
- renderChunk: (chunk) => {
545
+ renderChunk: (chunk: { id: string; content: string }) => {
545
546
  // Trailing <!--/hs:chunk--> marks the end of a streaming chunk boundary.
546
547
  return html`
547
548
  <template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
package/src/types.ts CHANGED
@@ -63,10 +63,20 @@ export namespace Hyperspan {
63
63
  disableStreaming?: DisableStreamingFn;
64
64
  };
65
65
 
66
+ export type DeployTarget = 'node' | 'bun' | 'cloudflare';
67
+
68
+ export type ServerCreateContext = {
69
+ env: unknown;
70
+ };
71
+
66
72
  export type Config = {
67
73
  appDir: string;
68
74
  publicDir: string;
69
75
  plugins: Array<Hyperspan.Plugin>; // Loaders for client islands
76
+ /** Production runtime target. Controls the generated dist/server entry. */
77
+ deployTarget?: DeployTarget;
78
+ /** Called once before the production server is created, with platform bindings. */
79
+ beforeServerCreate?: (ctx: ServerCreateContext) => void | Promise<void>;
70
80
  // For customizing the routes and adding your own...
71
81
  beforeRoutesAdded?: (server: Hyperspan.Server) => void;
72
82
  afterRoutesAdded?: (server: Hyperspan.Server) => void;
@@ -387,6 +397,9 @@ export namespace Hyperspan {
387
397
  }
388
398
  }
389
399
 
400
+ export type DeployTarget = Hyperspan.DeployTarget;
401
+ export type ServerCreateContext = Hyperspan.ServerCreateContext;
402
+
390
403
  declare global {
391
404
  interface DocumentEventMap {
392
405
  'hs:action:before-fetch': CustomEvent<Hyperspan.ActionFetchDetail>;
package/tsconfig.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "compileOnSave": true,
3
+ "include": ["src/**/*.ts"],
3
4
  "compilerOptions": {
4
5
  "outDir": "dist",
5
6
  "target": "ES2020",
@@ -10,7 +11,6 @@
10
11
  "esModuleInterop": true,
11
12
  "allowImportingTsExtensions": true,
12
13
  "noEmit": true,
13
- "composite": true,
14
14
  "strict": true,
15
15
  "declaration": true,
16
16
  "sourceMap": true,
@@ -19,11 +19,7 @@
19
19
  "allowSyntheticDefaultImports": true,
20
20
  "forceConsistentCasingInFileNames": true,
21
21
  "allowJs": true,
22
- "baseUrl": ".",
23
- "paths": {
24
- "@hyperspan/html": ["../html/src/html.ts"]
25
- }
22
+ "baseUrl": "."
26
23
  },
27
- "references": [{ "path": "../html" }],
28
- "exclude": ["node_modules", "__tests__", "*.test.ts"]
24
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
29
25
  }