@hyperspan/framework 2.0.0-alpha.4 → 2.0.0-alpha.6

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.4",
3
+ "version": "2.0.0-alpha.6",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "type": "module",
6
6
  "main": "src/server.ts",
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { Adapter } from './types';
3
+ import { createConfig } from './server';
4
+
5
+ const cloudflare: Adapter = {
6
+ name: 'cloudflare',
7
+ createEntry() {
8
+ return {
9
+ fetch: async () => new Response('ok'),
10
+ };
11
+ },
12
+ };
13
+
14
+ describe('createConfig deployAdapter', () => {
15
+ test('defaults to Node when omitted or undefined', () => {
16
+ expect(createConfig({}).deployAdapter.name).toBe('node');
17
+ expect(createConfig({ deployAdapter: undefined }).deployAdapter.name).toBe('node');
18
+ });
19
+
20
+ test('keeps an explicit adapter', () => {
21
+ expect(createConfig({ deployAdapter: cloudflare }).deployAdapter).toBe(cloudflare);
22
+ });
23
+ });
@@ -63,6 +63,26 @@ describe('buildClientJS', () => {
63
63
  expect(tag).toContain('({ mountPicker }) => mountPicker()');
64
64
  });
65
65
 
66
+ test('package export specifiers resolve on disk like app logical paths', async () => {
67
+ const result = await buildClientJS(
68
+ '@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts',
69
+ { type: 'iife' }
70
+ );
71
+
72
+ expect(result.publicPath).toMatch(/^\/_hs\/js\/client-[0-9a-f]{16}\.js$/);
73
+ expect(getClientJSEntries()[0].type).toBe('iife');
74
+ expect(getClientJSEntries()[0].absPath).toMatch(/hyperspan-streaming\.client\.ts$/);
75
+ });
76
+
77
+ test('type iife renders a classic script tag', async () => {
78
+ const result = await buildClientJS(clientFile, { type: 'iife' });
79
+ const tag = render(result.renderScriptTag());
80
+
81
+ expect(tag).toContain(`<script src="${result.publicPath}"`);
82
+ expect(tag).not.toContain('type="module"');
83
+ expect(getClientJSEntries()[0].type).toBe('iife');
84
+ });
85
+
66
86
  test('logical app-relative paths hash consistently and resolve on disk when present', async () => {
67
87
  const dir = mkdtempSync(join(tmpdir(), 'hs-client-logical-'));
68
88
  const { mkdirSync } = await import('node:fs');
package/src/client/js.ts CHANGED
@@ -11,6 +11,13 @@ export { registerImport, resolveImport, getImportMap } from './manifest';
11
11
  export const JS_PUBLIC_PATH = '/_hs/js';
12
12
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
13
13
 
14
+ export type ClientJSType = 'module' | 'iife';
15
+
16
+ export type BuildClientJSOptions = {
17
+ /** `iife` emits a classic <script> (runs during HTML streaming). Default `module`. */
18
+ type?: ClientJSType;
19
+ };
20
+
14
21
  export type ClientJSEntry = {
15
22
  absPath: string;
16
23
  assetHash: string;
@@ -18,6 +25,7 @@ export type ClientJSEntry = {
18
25
  publicPath: string;
19
26
  exports: string;
20
27
  fnArgs: string;
28
+ type: ClientJSType;
21
29
  };
22
30
 
23
31
  const CLIENT_JS_REGISTRY = Symbol.for('@hyperspan/client-js-entries');
@@ -89,6 +97,14 @@ export function resolveClientModulePaths(modulePathResolved: string): {
89
97
  return { hashKey, absPath: candidate };
90
98
  }
91
99
  }
100
+ try {
101
+ const url = import.meta.resolve(resolved);
102
+ if (typeof url === 'string' && url.startsWith('file://')) {
103
+ return { hashKey, absPath: fileURLToPath(url) };
104
+ }
105
+ } catch {
106
+ // Bare specifiers that aren't installed yet still hash stably.
107
+ }
92
108
  return { hashKey, absPath: hashKey };
93
109
  }
94
110
 
@@ -111,13 +127,11 @@ function registerClientJSEntry(entry: ClientJSEntry): void {
111
127
  getClientJSRegistry().set(entry.esmName, entry);
112
128
  }
113
129
 
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> {
130
+ function registerClientJS(
131
+ modulePathResolved: string,
132
+ options: BuildClientJSOptions = {}
133
+ ): HS.ClientJSBuildResult {
134
+ const type = options.type ?? 'module';
121
135
  const { hashKey, absPath } = resolveClientModulePaths(modulePathResolved);
122
136
  const hash = assetHashFn(hashKey);
123
137
  const esmName = `client-${hash}`;
@@ -133,7 +147,7 @@ export async function buildClientJS(modulePathResolved: string): Promise<HS.Clie
133
147
  fnArgs = discovered.fnArgs;
134
148
  }
135
149
 
136
- registerClientJSEntry({ absPath, assetHash: hash, esmName, publicPath, exports, fnArgs });
150
+ registerClientJSEntry({ absPath, assetHash: hash, esmName, publicPath, exports, fnArgs, type });
137
151
  registerImport(esmName, publicPath);
138
152
 
139
153
  return {
@@ -141,6 +155,10 @@ export async function buildClientJS(modulePathResolved: string): Promise<HS.Clie
141
155
  esmName,
142
156
  publicPath,
143
157
  renderScriptTag: (loadScript) => {
158
+ if (type === 'iife') {
159
+ return html`<script src="${publicPath}"></script>`;
160
+ }
161
+
144
162
  const t = typeof loadScript;
145
163
 
146
164
  if (t === 'string') {
@@ -169,6 +187,28 @@ export async function buildClientJS(modulePathResolved: string): Promise<HS.Clie
169
187
  };
170
188
  }
171
189
 
190
+ /**
191
+ * Build (or look up) a client JS module and return a helper for rendering script tags.
192
+ *
193
+ * Never evaluates the module on the server — only registers the path for Vite to bundle
194
+ * and returns URLs / script-tag helpers for the browser.
195
+ */
196
+ export async function buildClientJS(
197
+ modulePathResolved: string,
198
+ options: BuildClientJSOptions = {}
199
+ ): Promise<HS.ClientJSBuildResult> {
200
+ return registerClientJS(modulePathResolved, options);
201
+ }
202
+
203
+ /** Same `buildClientJS` path an app would use for a package export. */
204
+ export const streamingClient = registerClientJS(
205
+ '@hyperspan/framework/client/_hs/hyperspan-streaming.client.ts',
206
+ { type: 'iife' }
207
+ );
208
+ export const actionsClient = registerClientJS(
209
+ '@hyperspan/framework/client/_hs/hyperspan-actions.client.ts'
210
+ );
211
+
172
212
  /**
173
213
  * Discover export names from source or bundled client JS.
174
214
  * Prefers bundled `export{...}` form, then common source export patterns.
@@ -3,11 +3,7 @@ import type { Hyperspan as HS } from '../types';
3
3
  export type AssetManifest = {
4
4
  imports: Record<string, string>;
5
5
  css: Record<string, string[]>;
6
- clients: {
7
- streaming?: string;
8
- actions?: string;
9
- scripts?: string;
10
- };
6
+ clients: Record<string, string>;
11
7
  };
12
8
 
13
9
  let _manifest: AssetManifest = {
@@ -67,7 +63,7 @@ export function getClientJSFromManifest(
67
63
  moduleId: string,
68
64
  _exportNames = 'default'
69
65
  ): ClientJSBuildResult {
70
- const publicPath = _manifest.clients[moduleId as keyof AssetManifest['clients']];
66
+ const publicPath = _manifest.clients[moduleId];
71
67
  if (!publicPath) {
72
68
  throw new Error(
73
69
  `[Hyperspan] Client module "${moduleId}" not found in asset manifest. Run hyperspan build or start dev server.`
package/src/index.ts CHANGED
@@ -20,9 +20,18 @@ export {
20
20
  registerImport,
21
21
  } from './client/manifest';
22
22
  export { buildClientJS } from './client/js';
23
+ export type { BuildClientJSOptions, ClientJSType } from './client/js';
23
24
  export { registerRouteModule, registerRouteModules } from './register-routes';
24
25
  export type { RouteModuleEntry } from './register-routes';
25
26
  export type { AssetManifest } from './client/manifest';
26
27
  export { renderIsland } from './island';
27
28
  export type { IslandRenderOptions } from './island';
28
- export type { Hyperspan, ServerCreateContext, DeployTarget } from './types';
29
+ export type {
30
+ Hyperspan,
31
+ ServerCreateContext,
32
+ DeployAdapter,
33
+ Adapter,
34
+ AdapterAfterBuildContext,
35
+ DeployEntry,
36
+ DeployEntryContext,
37
+ } from './types';
@@ -0,0 +1,12 @@
1
+ import { test, expect } from 'vitest';
2
+ import { render } from '@hyperspan/html';
3
+ import { hyperspanScriptTags } from './layout';
4
+ import { streamingClient } from './client/js';
5
+
6
+ test('streaming bootstrap injects a classic script so chunks swap in during parse', () => {
7
+ const html = render(hyperspanScriptTags());
8
+ expect(html).toContain("document.createElement('script')");
9
+ expect(html).toContain(`script.src = '${streamingClient.publicPath}'`);
10
+ expect(html).not.toContain("type = 'module'");
11
+ expect(html).not.toContain(`import('${streamingClient.publicPath}')`);
12
+ });
package/src/layout.ts CHANGED
@@ -1,23 +1,20 @@
1
1
  import { html } from '@hyperspan/html';
2
2
  import { CSS_PUBLIC_PATH } from './client/css';
3
- import { getImportMap, getRouteCss, getClientJSFromManifest } from './client/manifest';
3
+ import { getImportMap, getRouteCss } from './client/manifest';
4
+ import { actionsClient, streamingClient } from './client/js';
4
5
  import type { Hyperspan as HS } from './types';
5
6
 
6
- function streamingClientPath(): string {
7
- return getClientJSFromManifest('streaming').publicPath;
8
- }
9
-
10
7
  /**
11
- * Output the importmap for the client so we can use ESModules on the client to load JS files on demand
8
+ * Output the importmap for the client so we can use ESModules on the client to load JS files on demand.
9
+ *
10
+ * The streaming client MUST be loaded as a classic <script>, not type=module.
11
+ * Browsers defer all ESM until the document finishes parsing, which would hold
12
+ * every streaming chunk until the whole page is done. A dynamically inserted
13
+ * classic script runs as soon as it downloads.
12
14
  */
13
15
  export function hyperspanScriptTags() {
14
16
  const imports = getImportMap();
15
- let streamingPath: string;
16
- try {
17
- streamingPath = streamingClientPath();
18
- } catch {
19
- streamingPath = '/_hs/js/hyperspan-streaming.client.js';
20
- }
17
+ const streamingPath = streamingClient.publicPath;
21
18
 
22
19
  return html`
23
20
  <script type="importmap">
@@ -65,10 +62,5 @@ export function hyperspanStyleTags(context: HS.Context) {
65
62
  * Render the actions client script tag (used by createAction).
66
63
  */
67
64
  export function hyperspanActionsScriptTag() {
68
- try {
69
- const actions = getClientJSFromManifest('actions');
70
- return html`<script type="module" src="${actions.publicPath}"></script>`;
71
- } catch {
72
- return html`<script type="module" src="/_hs/js/hyperspan-actions.client.js"></script>`;
73
- }
65
+ return html`<script type="module" src="${actionsClient.publicPath}"></script>`;
74
66
  }
package/src/server.ts CHANGED
@@ -12,6 +12,7 @@ import { isbot } from 'isbot';
12
12
  import { executeMiddleware } from './middleware';
13
13
  import { buildUrl, parsePath, removeUndefined } from './utils';
14
14
  import { Cookies } from './cookies';
15
+ import { nodeAdapter } from '@hyperspan/adapter-node';
15
16
 
16
17
  import type { Hyperspan as HS } from './types';
17
18
 
@@ -51,7 +52,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
51
52
  appDir: './app',
52
53
  publicDir: './public',
53
54
  plugins: [],
54
- deployTarget: 'node',
55
+ deployAdapter: config.deployAdapter ?? nodeAdapter(),
55
56
  responseOptions: {
56
57
  disableStreaming: hyperspanDisableStreaming,
57
58
  },
@@ -59,6 +60,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
59
60
  return {
60
61
  ...defaultConfig,
61
62
  ...config,
63
+ deployAdapter: config.deployAdapter ?? defaultConfig.deployAdapter,
62
64
  responseOptions: {
63
65
  ...defaultConfig.responseOptions,
64
66
  ...config.responseOptions,
package/src/types.ts CHANGED
@@ -63,7 +63,38 @@ export namespace Hyperspan {
63
63
  disableStreaming?: DisableStreamingFn;
64
64
  };
65
65
 
66
- export type DeployTarget = 'node' | 'bun' | 'cloudflare';
66
+ export type AdapterAfterBuildContext = {
67
+ root: string;
68
+ appDir: string;
69
+ outDir: string;
70
+ };
71
+
72
+ export type DeployEntryContext = {
73
+ createHyperspanServer: () => Promise<Server>;
74
+ config: Config;
75
+ };
76
+
77
+ export type DeployEntry = {
78
+ start?(options?: { port?: number }): unknown | Promise<unknown>;
79
+ fetch?(request: Request, env?: unknown, ctx?: unknown): Response | Promise<Response>;
80
+ };
81
+
82
+ export type Adapter = {
83
+ /** Diagnostic name, e.g. `node`, `cloudflare`. */
84
+ name: string;
85
+ /**
86
+ * Native ESM specifier exporting `resolveDevEnv(root)`.
87
+ * Imported by the Vite plugin so platform SDKs like wrangler load correctly.
88
+ */
89
+ devModule?: string;
90
+ resolveDevEnv?(root: string): Promise<unknown>;
91
+ /** Platform entry (`start` and/or `fetch`). Called from generated `dist/server.ts`. */
92
+ createEntry(ctx: DeployEntryContext): DeployEntry;
93
+ afterBuild?(ctx: AdapterAfterBuildContext): void | Promise<void>;
94
+ };
95
+
96
+ /** Runtime adapter object (`nodeAdapter()`, `cloudflareAdapter()`, …). */
97
+ export type DeployAdapter = Adapter;
67
98
 
68
99
  export type ServerCreateContext = {
69
100
  env: unknown;
@@ -72,12 +103,16 @@ export namespace Hyperspan {
72
103
  export type Config = {
73
104
  appDir: string;
74
105
  publicDir: string;
75
- plugins: Array<Hyperspan.Plugin>; // Loaders for client islands
76
- /** Production runtime target. Controls the generated dist/server entry. */
77
- deployTarget?: DeployTarget;
106
+ /** Island plugins (`preactPlugin()`, `sveltePlugin()`, …). */
107
+ plugins: Array<Hyperspan.Plugin>;
108
+ /**
109
+ * Deployment adapter. Pass `cloudflareAdapter()` or `bunAdapter()`.
110
+ * Omit (or pass `nodeAdapter()`) to deploy to Node.
111
+ */
112
+ deployAdapter: Adapter;
78
113
  /**
79
114
  * Called before the server is created, with platform bindings.
80
- * - Vite/`hyperspan dev`: adapter `/dev` `resolveDevEnv(root)` when present; else `process.env`
115
+ * - Vite/`hyperspan dev`: adapter `devModule` `resolveDevEnv(root)` when present; else `process.env`
81
116
  * - Node/Bun production entry: `process.env`
82
117
  * - Cloudflare Workers entry: Worker `env` bindings
83
118
  */
@@ -402,8 +437,12 @@ export namespace Hyperspan {
402
437
  }
403
438
  }
404
439
 
405
- export type DeployTarget = Hyperspan.DeployTarget;
440
+ export type DeployAdapter = Hyperspan.DeployAdapter;
406
441
  export type ServerCreateContext = Hyperspan.ServerCreateContext;
442
+ export type Adapter = Hyperspan.Adapter;
443
+ export type AdapterAfterBuildContext = Hyperspan.AdapterAfterBuildContext;
444
+ export type DeployEntry = Hyperspan.DeployEntry;
445
+ export type DeployEntryContext = Hyperspan.DeployEntryContext;
407
446
 
408
447
  declare global {
409
448
  interface DocumentEventMap {