@hyperspan/framework 1.0.29 → 2.0.0-alpha.13

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.
@@ -0,0 +1,168 @@
1
+ import { createContext } from './server';
2
+ import type { Hyperspan as HS } from './types';
3
+
4
+ export type FetchHandlerOptions = {
5
+ /** Called when no route matches (before 404). Return a Response to handle static assets etc. */
6
+ onNotMatched?: (request: Request) => Promise<Response | undefined>;
7
+ };
8
+
9
+ type CompiledRoute = {
10
+ route: HS.Route;
11
+ pattern: RegExp;
12
+ paramNames: string[];
13
+ isWildcard: boolean;
14
+ };
15
+
16
+ /**
17
+ * Convert a Hyperspan route path (`/users/:id`, `/blog/*`) into a RegExp + param names.
18
+ */
19
+ export function compileRoutePath(path: string): {
20
+ pattern: RegExp;
21
+ paramNames: string[];
22
+ isWildcard: boolean;
23
+ } {
24
+ const paramNames: string[] = [];
25
+ const isWildcard = path.endsWith('/*');
26
+
27
+ if (path === '/') {
28
+ return { pattern: /^\/$/, paramNames, isWildcard: false };
29
+ }
30
+
31
+ const segments = path.split('/').filter(Boolean);
32
+ const regexParts = segments.map((segment) => {
33
+ if (segment === '*') {
34
+ paramNames.push('...slug');
35
+ return '(.+)';
36
+ }
37
+ if (segment.startsWith(':')) {
38
+ paramNames.push(segment.slice(1));
39
+ return '([^/]+)';
40
+ }
41
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
+ });
43
+
44
+ const pattern = new RegExp(`^/${regexParts.join('/')}${isWildcard ? '' : '/?'}$`);
45
+ return { pattern, paramNames, isWildcard };
46
+ }
47
+
48
+ function mergeRouteMiddleware(server: HS.Server, route: HS.Route): void {
49
+ route._serverConfig = server._config;
50
+
51
+ if (!server._middleware || !route._middleware) {
52
+ return;
53
+ }
54
+
55
+ for (const method of Object.keys(server._middleware) as HS.MiddlewareMethod[]) {
56
+ if (Array.isArray(server._middleware[method]) && Array.isArray(route._middleware[method])) {
57
+ route._middleware[method] = server._middleware[method].concat(route._middleware[method]);
58
+ }
59
+ }
60
+ }
61
+
62
+ function matchRoute(
63
+ compiledRoutes: CompiledRoute[],
64
+ pathname: string,
65
+ method: string
66
+ ): { route: HS.Route; params: Record<string, string | undefined> } | null {
67
+ for (const compiled of compiledRoutes) {
68
+ const match = pathname.match(compiled.pattern);
69
+ if (!match) {
70
+ continue;
71
+ }
72
+
73
+ // Actions and some custom handlers may not expose _methods(); treat as all methods.
74
+ const routeMethods =
75
+ typeof compiled.route._methods === 'function' ? compiled.route._methods() : ['*'];
76
+ const supportsMethod =
77
+ routeMethods.includes(method) ||
78
+ routeMethods.includes('*') ||
79
+ (method === 'HEAD' && routeMethods.includes('GET'));
80
+
81
+ if (!supportsMethod) {
82
+ continue;
83
+ }
84
+
85
+ const params: Record<string, string | undefined> = {};
86
+ compiled.paramNames.forEach((name, index) => {
87
+ const value = match[index + 1];
88
+ if (name.startsWith('...')) {
89
+ params[name] = value;
90
+ } else {
91
+ params[name] = value;
92
+ }
93
+ });
94
+
95
+ return { route: compiled.route, params };
96
+ }
97
+
98
+ return null;
99
+ }
100
+
101
+ function trailingSlashRedirect(pathname: string, request: Request): Response | null {
102
+ if (pathname.length > 1 && pathname.endsWith('/')) {
103
+ const url = new URL(request.url);
104
+ url.pathname = pathname.slice(0, -1);
105
+ return Response.redirect(url.toString(), 308);
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Create a portable fetch handler for a Hyperspan server.
112
+ * Works on Node, Bun, Cloudflare Workers, and any runtime with Web Fetch API.
113
+ */
114
+ export function createFetchHandler(
115
+ server: HS.Server,
116
+ options: FetchHandlerOptions = {}
117
+ ): (request: Request) => Promise<Response> {
118
+ const compiledRoutes: CompiledRoute[] = server._routes.map((route) => {
119
+ const path = route._path();
120
+ mergeRouteMiddleware(server, route);
121
+ const { pattern, paramNames, isWildcard } = compileRoutePath(path);
122
+ return { route, pattern, paramNames, isWildcard };
123
+ });
124
+
125
+ // Longer / more specific paths first (more segments wins)
126
+ compiledRoutes.sort((a, b) => {
127
+ const aSegments = a.route._path().split('/').filter(Boolean).length;
128
+ const bSegments = b.route._path().split('/').filter(Boolean).length;
129
+ return bSegments - aSegments;
130
+ });
131
+
132
+ return async (request: Request): Promise<Response> => {
133
+ const url = new URL(request.url);
134
+ const pathname = url.pathname;
135
+ const method = request.method.toUpperCase();
136
+
137
+ const slashRedirect = trailingSlashRedirect(pathname, request);
138
+ if (slashRedirect) {
139
+ return slashRedirect;
140
+ }
141
+
142
+ const matched = matchRoute(compiledRoutes, pathname, method);
143
+ if (matched) {
144
+ // Attach params on the original request. `new Request(request)` clones
145
+ // through Node's Request constructor, which strips Cookie/Origin/Host.
146
+ (request as Request & { params?: Record<string, string | undefined> }).params =
147
+ matched.params;
148
+ return matched.route.fetch(request);
149
+ }
150
+
151
+ if (options.onNotMatched) {
152
+ const staticResponse = await options.onNotMatched(request);
153
+ if (staticResponse) {
154
+ return staticResponse;
155
+ }
156
+ }
157
+
158
+ return createContext(request).res.notFound();
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Create an app object with a fetch method (adapter contract).
164
+ */
165
+ export function createApp(server: HS.Server, options: FetchHandlerOptions = {}) {
166
+ const fetch = createFetchHandler(server, options);
167
+ return { fetch, server };
168
+ }
package/src/index.ts CHANGED
@@ -3,10 +3,36 @@ export {
3
3
  createContext,
4
4
  createRoute,
5
5
  createServer,
6
+ initServerRoutes,
6
7
  getRunnableRoute,
7
8
  StreamResponse,
8
9
  IS_PROD,
9
10
  HTTPResponseException,
10
11
  hyperspanDisableStreaming,
11
12
  } from './server';
12
- export type { Hyperspan } from './types';
13
+ export { createFetchHandler, createApp, compileRoutePath } from './fetch-handler';
14
+ export type { FetchHandlerOptions } from './fetch-handler';
15
+ export {
16
+ setAssetManifest,
17
+ getAssetManifest,
18
+ resolveImport,
19
+ getRouteCss,
20
+ getImportMap,
21
+ registerImport,
22
+ } from './client/manifest';
23
+ export { buildClientJS, registerPathAliases } from './client/js';
24
+ export type { BuildClientJSOptions, ClientJSType } from './client/js';
25
+ export { registerRouteModule, registerRouteModules } from './register-routes';
26
+ export type { RouteModuleEntry } from './register-routes';
27
+ export type { AssetManifest } from './client/manifest';
28
+ export { renderIsland } from './island';
29
+ export type { IslandRenderOptions } from './island';
30
+ export type {
31
+ Hyperspan,
32
+ ServerCreateContext,
33
+ DeployAdapter,
34
+ Adapter,
35
+ AdapterAfterBuildContext,
36
+ DeployEntry,
37
+ DeployEntryContext,
38
+ } 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,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,19 +1,24 @@
1
1
  import { html } from '@hyperspan/html';
2
- import { JS_IMPORT_MAP, buildClientJS } from './client/js';
3
- import { CSS_PUBLIC_PATH, CSS_ROUTE_MAP } from './client/css';
2
+ import { CSS_PUBLIC_PATH } from './client/css';
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
- const clientStreamingJS = await buildClientJS(
7
- import.meta.resolve('./client/_hs/hyperspan-streaming.client')
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() {
16
+ const imports = getImportMap();
17
+ const streamingPath = streamingClient.publicPath;
18
+
14
19
  return html`
15
20
  <script type="importmap">
16
- {"imports": ${Object.fromEntries(JS_IMPORT_MAP)}}
21
+ {"imports": ${imports}}
17
22
  </script>
18
23
  <script id="hyperspan-streaming-script">
19
24
  // [Hyperspan] Streaming - Load the client streaming JS module only when the first chunk is loaded
@@ -25,7 +30,7 @@ export function hyperspanScriptTags() {
25
30
  if (!window._hscLoading) {
26
31
  window._hscLoading = true;
27
32
  const script = document.createElement('script');
28
- script.src = '${clientStreamingJS.publicPath}';
33
+ script.src = '${streamingPath}';
29
34
  document.body.appendChild(script);
30
35
  }
31
36
  };
@@ -39,11 +44,23 @@ export function hyperspanScriptTags() {
39
44
  */
40
45
  export function hyperspanStyleTags(context: HS.Context) {
41
46
  const styleTags = [];
42
- const cssImports = context.route.cssImports ?? CSS_ROUTE_MAP.get(context.route.path) ?? [];
47
+ const cssImports = context.route.cssImports?.length
48
+ ? context.route.cssImports
49
+ : getRouteCss(context.route.path);
43
50
 
44
51
  for (const cssFile of cssImports) {
45
- styleTags.push(html` <link rel="stylesheet" href="${CSS_PUBLIC_PATH}/${cssFile}" /> `);
52
+ // Absolute Vite/dev URLs (e.g. /app/styles/globals.css) are used as-is;
53
+ // hashed build artifacts are served under /_hs/css/.
54
+ const href = cssFile.startsWith('/') ? cssFile : `${CSS_PUBLIC_PATH}/${cssFile}`;
55
+ styleTags.push(html` <link rel="stylesheet" href="${href}" /> `);
46
56
  }
47
57
 
48
58
  return styleTags;
49
59
  }
60
+
61
+ /**
62
+ * Render the actions client script tag (used by createAction).
63
+ */
64
+ export function hyperspanActionsScriptTag() {
65
+ return html`<script type="module" src="${actionsClient.publicPath}"></script>`;
66
+ }
@@ -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
+ }
@@ -1,5 +1,5 @@
1
- import { test, expect, describe } from 'bun:test';
2
- import { createRoute, createServer, createContext } from './server';
1
+ import { test, expect, describe } from 'vitest';
2
+ import { createRoute, createServer, createContext, initServerRoutes } from './server';
3
3
  import { createAction } from './actions';
4
4
  import { html, placeholder } from '@hyperspan/html';
5
5
  import type { Hyperspan as HS } from './types';
@@ -92,6 +92,25 @@ test('server returns a route with a POST request', async () => {
92
92
  expect(await response.text()).toBe('<h1>POST /users</h1>');
93
93
  });
94
94
 
95
+ test('POST HTML response does not inherit request Content-Length', async () => {
96
+ const route = createRoute().post((context: HS.Context) => {
97
+ return context.res.html('<h1>hello world this is longer than the form body</h1>');
98
+ });
99
+ const body = 'email=a@b.com&password=secret';
100
+ const request = new Request('http://localhost:3000/', {
101
+ method: 'POST',
102
+ headers: {
103
+ 'content-type': 'application/x-www-form-urlencoded',
104
+ 'content-length': String(body.length),
105
+ },
106
+ body,
107
+ });
108
+ const response = await route.fetch(request);
109
+ const text = await response.text();
110
+ expect(text).toBe('<h1>hello world this is longer than the form body</h1>');
111
+ expect(Number(response.headers.get('content-length') ?? text.length)).toBe(text.length);
112
+ });
113
+
95
114
  test('server returns a route with a ALL request', async () => {
96
115
  const server = await createServer({
97
116
  appDir: './app',
@@ -199,6 +218,17 @@ test('createContext() can get and set cookies', () => {
199
218
  }
200
219
  });
201
220
 
221
+ test('createContext() url() returns a relative URL string', () => {
222
+ const request = new Request('http://localhost:3000/posts?sort=asc#top');
223
+ const context = createContext(request);
224
+
225
+ expect(typeof context.url()).toBe('string');
226
+ expect(context.url()).toBe('/posts?sort=asc#top');
227
+ expect(context.url({ searchParams: { page: 2 } })).toBe('/posts?sort=asc&page=2#top');
228
+ expect(context.url({ searchParams: { page: 2 } }, { clean: true })).toBe('/posts?page=2');
229
+ expect(context.req.url.search).toBe('?sort=asc');
230
+ });
231
+
202
232
  test('createContext() merge() function preserves custom headers when using response methods', async () => {
203
233
  // Create a request
204
234
  const request = new Request('http://localhost:3000/');
@@ -555,3 +585,31 @@ describe('when streaming is disabled', () => {
555
585
  expect(text).not.toContain('hs:loading');
556
586
  });
557
587
  });
588
+
589
+ test('initServerRoutes runs beforeRoutesAdded, then addRoutes, then afterRoutesAdded', async () => {
590
+ const order: string[] = [];
591
+ const server = await createServer({
592
+ appDir: './app',
593
+ publicDir: './public',
594
+ plugins: [],
595
+ });
596
+
597
+ await initServerRoutes(
598
+ server,
599
+ {
600
+ beforeRoutesAdded(s) {
601
+ order.push('before');
602
+ s.use(async (_c, next) => next());
603
+ },
604
+ afterRoutesAdded() {
605
+ order.push('after');
606
+ },
607
+ },
608
+ () => {
609
+ order.push('routes');
610
+ }
611
+ );
612
+
613
+ expect(order).toEqual(['before', 'routes', 'after']);
614
+ expect(server._middleware['*']).toHaveLength(1);
615
+ });
package/src/server.ts CHANGED
@@ -10,8 +10,9 @@ import {
10
10
  } from '@hyperspan/html';
11
11
  import { isbot } from 'isbot';
12
12
  import { executeMiddleware } from './middleware';
13
- import { parsePath, removeUndefined } from './utils';
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,6 +52,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
51
52
  appDir: './app',
52
53
  publicDir: './public',
53
54
  plugins: [],
55
+ deployAdapter: config.deployAdapter ?? nodeAdapter(),
54
56
  responseOptions: {
55
57
  disableStreaming: hyperspanDisableStreaming,
56
58
  },
@@ -58,6 +60,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
58
60
  return {
59
61
  ...defaultConfig,
60
62
  ...config,
63
+ deployAdapter: config.deployAdapter ?? defaultConfig.deployAdapter,
61
64
  responseOptions: {
62
65
  ...defaultConfig.responseOptions,
63
66
  ...config.responseOptions,
@@ -72,7 +75,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
72
75
  const url = new URL(req.url);
73
76
  const query = new URLSearchParams(url.search);
74
77
  const method = req.method.toUpperCase();
75
- const headers = new Headers(req.headers);
78
+ const requestHeaders = new Headers(req.headers);
79
+ const responseHeaders = new Headers();
76
80
  const path = route?._path() || '/';
77
81
  const requestParams = (req as Request & { params?: Record<string, string | undefined> }).params;
78
82
  const params: Record<string, string | undefined> = Object.assign(
@@ -92,12 +96,21 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
92
96
  // Status override for the response. Will use if set. (e.g. c.res.status = 400)
93
97
  let status: number | undefined = undefined;
94
98
 
99
+ const copyHeaders = (source: Headers, target: Headers) => {
100
+ source.forEach((value, key) => {
101
+ if (key.toLowerCase() === 'set-cookie') return;
102
+ target.set(key, value);
103
+ });
104
+ const cookies = typeof source.getSetCookie === 'function' ? source.getSetCookie() : [];
105
+ for (const cookie of cookies) {
106
+ target.append('Set-Cookie', cookie);
107
+ }
108
+ };
109
+
95
110
  const merge = async (response: Response) => {
96
- // Convert headers to plain objects and merge (response headers override context headers)
97
- const mergedHeaders = {
98
- ...Object.fromEntries(headers.entries()),
99
- ...Object.fromEntries(response.headers.entries()),
100
- };
111
+ const mergedHeaders = new Headers();
112
+ copyHeaders(responseHeaders, mergedHeaders);
113
+ copyHeaders(response.headers, mergedHeaders);
101
114
 
102
115
  return new Response(await response.text(), {
103
116
  status: context.res.status ?? response.status,
@@ -107,6 +120,10 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
107
120
 
108
121
  const context: HS.Context = {
109
122
  vars: {},
123
+ url: (diff, options) => {
124
+ const next = buildUrl(url, diff, options);
125
+ return `${next.pathname}${next.search}${next.hash}`;
126
+ },
110
127
  route: {
111
128
  name: route?._config.name || undefined,
112
129
  path,
@@ -117,7 +134,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
117
134
  raw: req,
118
135
  url,
119
136
  method,
120
- headers,
137
+ headers: requestHeaders,
121
138
  query,
122
139
  cookies: new Cookies(req),
123
140
  async text() {
@@ -134,8 +151,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
134
151
  },
135
152
  },
136
153
  res: {
137
- cookies: new Cookies(req, headers),
138
- headers,
154
+ cookies: new Cookies(req, responseHeaders),
155
+ headers: responseHeaders,
139
156
  status,
140
157
  html: (html: string, options?: ResponseInit) =>
141
158
  merge(
@@ -497,6 +514,20 @@ export async function createServer(config: HS.Config = {} as HS.Config): Promise
497
514
  return api;
498
515
  }
499
516
 
517
+ /**
518
+ * Shared route lifecycle for Vite dev, production entries, and `hyperspan start`.
519
+ * Always: `beforeRoutesAdded` → add routes → `afterRoutesAdded`.
520
+ */
521
+ export async function initServerRoutes(
522
+ server: HS.Server,
523
+ config: Pick<HS.Config, 'beforeRoutesAdded' | 'afterRoutesAdded'>,
524
+ addRoutes: (server: HS.Server) => void | Promise<void>
525
+ ): Promise<void> {
526
+ await config.beforeRoutesAdded?.(server);
527
+ await addRoutes(server);
528
+ await config.afterRoutesAdded?.(server);
529
+ }
530
+
500
531
  /**
501
532
  * Checks if a response is HTML content
502
533
  */
@@ -537,7 +568,7 @@ export async function returnHTMLResponse(
537
568
  if (!disableStreaming && (routeContent as HSHtml).asyncContent?.length > 0) {
538
569
  return new StreamResponse(
539
570
  renderStream(routeContent as HSHtml, {
540
- renderChunk: (chunk) => {
571
+ renderChunk: (chunk: { id: string; content: string }) => {
541
572
  // Trailing <!--/hs:chunk--> marks the end of a streaming chunk boundary.
542
573
  return html`
543
574
  <template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'bun:test';
1
+ import { describe, expect, test } from 'vitest';
2
2
  import { MOCK_DOM_MARK, installMockDom, stubElement } from './mock-dom';
3
3
 
4
4
  describe('stubElement', () => {