@hyperspan/framework 2.0.0-alpha.7 → 2.0.1

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/src/island.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { html } from '@hyperspan/html';
1
+ import { html } from './html';
2
2
 
3
3
  type IslandComponent = {
4
4
  __HS_ISLAND?: {
@@ -1,5 +1,5 @@
1
1
  import { test, expect } from 'vitest';
2
- import { render } from '@hyperspan/html';
2
+ import { render } from './html';
3
3
  import { hyperspanScriptTags } from './layout';
4
4
  import { streamingClient } from './client/js';
5
5
 
package/src/layout.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { html } from '@hyperspan/html';
1
+ import { html } from './html';
2
2
  import { CSS_PUBLIC_PATH } from './client/css';
3
3
  import { getImportMap, getRouteCss } from './client/manifest';
4
4
  import { actionsClient, streamingClient } from './client/js';
@@ -1,7 +1,7 @@
1
1
  import { test, expect, describe } from 'vitest';
2
- import { createRoute, createServer, createContext } from './server';
2
+ import { createRoute, createServer, createContext, initServerRoutes } from './server';
3
3
  import { createAction } from './actions';
4
- import { html, placeholder } from '@hyperspan/html';
4
+ import { html, placeholder } from './html';
5
5
  import type { Hyperspan as HS } from './types';
6
6
 
7
7
  test('route fetch() returns a Response', async () => {
@@ -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',
@@ -566,3 +585,31 @@ describe('when streaming is disabled', () => {
566
585
  expect(text).not.toContain('hs:loading');
567
586
  });
568
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
@@ -1,13 +1,5 @@
1
1
  import './ssr/install-server-dom-mock';
2
- import {
3
- HSHtml,
4
- html,
5
- isHSHtml,
6
- renderStream,
7
- renderAsync,
8
- render,
9
- _typeOf,
10
- } from '@hyperspan/html';
2
+ import { HSHtml, html, isHSHtml, renderStream, renderAsync, render, _typeOf } from './html';
11
3
  import { isbot } from 'isbot';
12
4
  import { executeMiddleware } from './middleware';
13
5
  import { buildUrl, parsePath, removeUndefined } from './utils';
@@ -75,7 +67,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
75
67
  const url = new URL(req.url);
76
68
  const query = new URLSearchParams(url.search);
77
69
  const method = req.method.toUpperCase();
78
- const headers = new Headers(req.headers);
70
+ const requestHeaders = new Headers(req.headers);
71
+ const responseHeaders = new Headers();
79
72
  const path = route?._path() || '/';
80
73
  const requestParams = (req as Request & { params?: Record<string, string | undefined> }).params;
81
74
  const params: Record<string, string | undefined> = Object.assign(
@@ -95,12 +88,21 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
95
88
  // Status override for the response. Will use if set. (e.g. c.res.status = 400)
96
89
  let status: number | undefined = undefined;
97
90
 
91
+ const copyHeaders = (source: Headers, target: Headers) => {
92
+ source.forEach((value, key) => {
93
+ if (key.toLowerCase() === 'set-cookie') return;
94
+ target.set(key, value);
95
+ });
96
+ const cookies = typeof source.getSetCookie === 'function' ? source.getSetCookie() : [];
97
+ for (const cookie of cookies) {
98
+ target.append('Set-Cookie', cookie);
99
+ }
100
+ };
101
+
98
102
  const merge = async (response: Response) => {
99
- // Convert headers to plain objects and merge (response headers override context headers)
100
- const mergedHeaders = {
101
- ...Object.fromEntries(headers.entries()),
102
- ...Object.fromEntries(response.headers.entries()),
103
- };
103
+ const mergedHeaders = new Headers();
104
+ copyHeaders(responseHeaders, mergedHeaders);
105
+ copyHeaders(response.headers, mergedHeaders);
104
106
 
105
107
  return new Response(await response.text(), {
106
108
  status: context.res.status ?? response.status,
@@ -124,7 +126,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
124
126
  raw: req,
125
127
  url,
126
128
  method,
127
- headers,
129
+ headers: requestHeaders,
128
130
  query,
129
131
  cookies: new Cookies(req),
130
132
  async text() {
@@ -141,8 +143,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
141
143
  },
142
144
  },
143
145
  res: {
144
- cookies: new Cookies(req, headers),
145
- headers,
146
+ cookies: new Cookies(req, responseHeaders),
147
+ headers: responseHeaders,
146
148
  status,
147
149
  html: (html: string, options?: ResponseInit) =>
148
150
  merge(
@@ -504,6 +506,20 @@ export async function createServer(config: HS.Config = {} as HS.Config): Promise
504
506
  return api;
505
507
  }
506
508
 
509
+ /**
510
+ * Shared route lifecycle for Vite dev, production entries, and `hyperspan start`.
511
+ * Always: `beforeRoutesAdded` → add routes → `afterRoutesAdded`.
512
+ */
513
+ export async function initServerRoutes(
514
+ server: HS.Server,
515
+ config: Pick<HS.Config, 'beforeRoutesAdded' | 'afterRoutesAdded'>,
516
+ addRoutes: (server: HS.Server) => void | Promise<void>
517
+ ): Promise<void> {
518
+ await config.beforeRoutesAdded?.(server);
519
+ await addRoutes(server);
520
+ await config.afterRoutesAdded?.(server);
521
+ }
522
+
507
523
  /**
508
524
  * Checks if a response is HTML content
509
525
  */
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { HSHtml } from '@hyperspan/html';
1
+ import { HSHtml } from './html';
2
2
  import * as z from 'zod';
3
3
 
4
4
  /**
@@ -69,8 +69,12 @@ export namespace Hyperspan {
69
69
  outDir: string;
70
70
  };
71
71
 
72
+ export type ServerCreateContext = {
73
+ env: unknown;
74
+ };
75
+
72
76
  export type DeployEntryContext = {
73
- createHyperspanServer: () => Promise<Server>;
77
+ createHyperspanServer: (ctx: ServerCreateContext) => Promise<Server>;
74
78
  config: Config;
75
79
  };
76
80
 
@@ -79,6 +83,18 @@ export namespace Hyperspan {
79
83
  fetch?(request: Request, env?: unknown, ctx?: unknown): Response | Promise<Response>;
80
84
  };
81
85
 
86
+ /** Optional snippets interpolated into generated `dist/server.ts`. Null/omitted slots emit nothing. */
87
+ export type ServerEntrySlots = {
88
+ beforeFileContent?: string | null;
89
+ beforeCreateServer?: string | null;
90
+ afterCreateServer?: string | null;
91
+ beforeRoutes?: string | null;
92
+ afterRoutes?: string | null;
93
+ afterFileContent?: string | null;
94
+ };
95
+
96
+ export type ServerEntryTemplate = (slots?: ServerEntrySlots) => string;
97
+
82
98
  export type Adapter = {
83
99
  /** Diagnostic name, e.g. `node`, `cloudflare`. */
84
100
  name: string;
@@ -88,18 +104,17 @@ export namespace Hyperspan {
88
104
  */
89
105
  devModule?: string;
90
106
  resolveDevEnv?(root: string): Promise<unknown>;
91
- /** Platform entry (`start` and/or `fetch`). Called from generated `dist/server.ts`. */
92
- createEntry(ctx: DeployEntryContext): DeployEntry;
107
+ /**
108
+ * Render generated `dist/server.ts` from the shared bootstrap template.
109
+ * Fill slots (especially `afterFileContent`) for the platform entry.
110
+ */
111
+ renderServerEntry(template: ServerEntryTemplate): string | Promise<string>;
93
112
  afterBuild?(ctx: AdapterAfterBuildContext): void | Promise<void>;
94
113
  };
95
114
 
96
115
  /** Runtime adapter object (`nodeAdapter()`, `cloudflareAdapter()`, …). */
97
116
  export type DeployAdapter = Adapter;
98
117
 
99
- export type ServerCreateContext = {
100
- env: unknown;
101
- };
102
-
103
118
  export type Config = {
104
119
  appDir: string;
105
120
  publicDir: string;
@@ -118,8 +133,8 @@ export namespace Hyperspan {
118
133
  */
119
134
  beforeServerCreate?: (ctx: ServerCreateContext) => void | Promise<void>;
120
135
  // For customizing the routes and adding your own...
121
- beforeRoutesAdded?: (server: Hyperspan.Server) => void;
122
- afterRoutesAdded?: (server: Hyperspan.Server) => void;
136
+ beforeRoutesAdded?: (server: Hyperspan.Server) => void | Promise<void>;
137
+ afterRoutesAdded?: (server: Hyperspan.Server) => void | Promise<void>;
123
138
  responseOptions?: ResponseOptions;
124
139
  };
125
140
 
@@ -415,7 +430,7 @@ export namespace Hyperspan {
415
430
  * Client JS Module = ESM Module + Public Path + Render Script Tag
416
431
  */
417
432
  export type ClientJSBuildResult = {
418
- assetHash: string; // Asset hash of the module path
433
+ assetHash: string; // Identity of the resolved path; production file hash comes from Vite
419
434
  esmName: string; // Filename of the built JavaScript file without the extension
420
435
  publicPath: string; // Full public path of the built JavaScript file
421
436
  /**
@@ -443,6 +458,8 @@ export type Adapter = Hyperspan.Adapter;
443
458
  export type AdapterAfterBuildContext = Hyperspan.AdapterAfterBuildContext;
444
459
  export type DeployEntry = Hyperspan.DeployEntry;
445
460
  export type DeployEntryContext = Hyperspan.DeployEntryContext;
461
+ export type ServerEntrySlots = Hyperspan.ServerEntrySlots;
462
+ export type ServerEntryTemplate = Hyperspan.ServerEntryTemplate;
446
463
 
447
464
  declare global {
448
465
  interface DocumentEventMap {
@@ -1,9 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(bun test:*)",
5
- "Bash(npx tsc *)",
6
- "Bash(echo \"exit: $?\")"
7
- ]
8
- }
9
- }
package/tsconfig.json DELETED
@@ -1,25 +0,0 @@
1
- {
2
- "compileOnSave": true,
3
- "include": ["src/**/*.ts"],
4
- "compilerOptions": {
5
- "outDir": "dist",
6
- "target": "ES2020",
7
- "lib": ["ESNext", "dom", "dom.iterable"],
8
- "module": "esnext",
9
- "moduleResolution": "bundler",
10
- "moduleDetection": "force",
11
- "esModuleInterop": true,
12
- "allowImportingTsExtensions": true,
13
- "noEmit": true,
14
- "strict": true,
15
- "declaration": true,
16
- "sourceMap": true,
17
- "downlevelIteration": true,
18
- "skipLibCheck": true,
19
- "allowSyntheticDefaultImports": true,
20
- "forceConsistentCasingInFileNames": true,
21
- "allowJs": true,
22
- "baseUrl": "."
23
- },
24
- "exclude": ["node_modules", "dist", "**/*.test.ts"]
25
- }