@vertz/cloudflare 0.2.0 → 0.2.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.
@@ -1,15 +1 @@
1
- $ bunup
2
- i Using bunup v0.16.26 and bun v1.3.9
3
- i Build started
4
-
5
- src/index.ts
6
-
7
-  Output Raw Gzip
8
-
9
- dist/index.js 676 B 349 B
10
- dist/index.d.ts 327 B 225 B
11
-
12
- 2 files  1003 B 574 B
13
-
14
-
15
- ✓ Build completed in 114ms
1
+ $ tsc
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # @vertz/cloudflare
2
+
3
+ ## 0.2.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`62dddcb`](https://github.com/vertz-dev/vertz/commit/62dddcbcb4943b12a04bca8466b09ae21901070b), [`2e86c55`](https://github.com/vertz-dev/vertz/commit/2e86c55e3c04f3c534bf0dc124d18dcdc5d9eefc), [`62dddcb`](https://github.com/vertz-dev/vertz/commit/62dddcbcb4943b12a04bca8466b09ae21901070b), [`b0b6115`](https://github.com/vertz-dev/vertz/commit/b0b6115e0389447ffb951e875b5ce224e4ace51c)]:
8
+ - @vertz/core@0.2.3
9
+ - @vertz/ui-server@0.2.3
10
+
11
+ ## 0.2.2
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies []:
16
+ - @vertz/ui-server@0.2.2
17
+ - @vertz/core@0.2.2
@@ -0,0 +1,73 @@
1
+ import type { AppBuilder } from '@vertz/core';
2
+ import type { SSRModule } from '@vertz/ui-server/ssr';
3
+ export interface CloudflareHandlerOptions {
4
+ basePath?: string;
5
+ }
6
+ /**
7
+ * SSR module configuration for zero-boilerplate server-side rendering.
8
+ *
9
+ * Pass the app module directly and the handler generates the HTML template,
10
+ * wires up createSSRHandler, and handles the full SSR pipeline.
11
+ */
12
+ export interface SSRModuleConfig {
13
+ /** App module exporting App, theme?, styles?, getInjectedCSS? */
14
+ module: SSRModule;
15
+ /** Client-side entry script path. Default: '/assets/entry-client.js' */
16
+ clientScript?: string;
17
+ /** HTML document title. Default: 'Vertz App' */
18
+ title?: string;
19
+ /** SSR query timeout in ms. Default: 5000 (generous for D1 cold starts). */
20
+ ssrTimeout?: number;
21
+ }
22
+ /**
23
+ * Full-stack configuration for createHandler.
24
+ *
25
+ * Supports lazy app initialization (for D1 bindings), SSR fallback,
26
+ * and automatic security headers.
27
+ */
28
+ export interface CloudflareHandlerConfig {
29
+ /**
30
+ * Factory that creates the AppBuilder. Receives the Worker env bindings.
31
+ * Called once on first request, then cached.
32
+ */
33
+ app: (env: unknown) => AppBuilder;
34
+ /** API path prefix. Requests matching this prefix go to the app handler. */
35
+ basePath: string;
36
+ /**
37
+ * SSR configuration for non-API routes.
38
+ *
39
+ * - `SSRModuleConfig` — zero-boilerplate: pass the app module directly
40
+ * - `(request: Request) => Promise<Response>` — custom SSR callback
41
+ * - `undefined` — non-API requests return 404
42
+ */
43
+ ssr?: SSRModuleConfig | ((request: Request) => Promise<Response>);
44
+ /** When true, adds standard security headers to all responses. */
45
+ securityHeaders?: boolean;
46
+ }
47
+ /** Generate a cryptographically random nonce for CSP. */
48
+ export declare function generateNonce(): string;
49
+ /**
50
+ * Create a Cloudflare Worker handler from a Vertz app.
51
+ *
52
+ * Simple form — wraps an AppBuilder directly:
53
+ * ```ts
54
+ * export default createHandler(app, { basePath: '/api' });
55
+ * ```
56
+ *
57
+ * Config form — full-stack with lazy init, SSR, and security headers:
58
+ * ```ts
59
+ * export default createHandler({
60
+ * app: (env) => createServer({ entities, db: createDb({ d1: env.DB }) }),
61
+ * basePath: '/api',
62
+ * ssr: (req) => renderToString(new URL(req.url).pathname),
63
+ * securityHeaders: true,
64
+ * });
65
+ * ```
66
+ */
67
+ /** Worker module shape returned by createHandler. */
68
+ export interface CloudflareWorkerModule {
69
+ fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response>;
70
+ }
71
+ export declare function createHandler(appOrConfig: AppBuilder | CloudflareHandlerConfig, options?: CloudflareHandlerOptions): CloudflareWorkerModule;
72
+ export declare function generateHTMLTemplate(clientScript: string, title: string, nonce?: string): string;
73
+ //# sourceMappingURL=handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAMtD,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,iEAAiE;IACjE,MAAM,EAAE,SAAS,CAAC;IAClB,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,GAAG,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,UAAU,CAAC;IAElC,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,eAAe,GAAG,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;IAElE,kEAAkE;IAClE,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAMD,yDAAyD;AACzD,wBAAgB,aAAa,IAAI,MAAM,CAStC;AA4CD;;;;;;;;;;;;;;;;;GAiBG;AACH,qDAAqD;AACrD,MAAM,WAAW,sBAAsB;IACrC,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACjF;AAED,wBAAgB,aAAa,CAC3B,WAAW,EAAE,UAAU,GAAG,uBAAuB,EACjD,OAAO,CAAC,EAAE,wBAAwB,GACjC,sBAAsB,CAQxB;AA+BD,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAchG"}
@@ -0,0 +1,196 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Security headers
3
+ // ---------------------------------------------------------------------------
4
+ /** Generate a cryptographically random nonce for CSP. */
5
+ export function generateNonce() {
6
+ const bytes = new Uint8Array(16);
7
+ crypto.getRandomValues(bytes);
8
+ // Base64-encode without padding for a compact, URL-safe nonce
9
+ let binary = '';
10
+ for (const byte of bytes) {
11
+ binary += String.fromCharCode(byte);
12
+ }
13
+ return btoa(binary);
14
+ }
15
+ const STATIC_SECURITY_HEADERS = {
16
+ 'X-Content-Type-Options': 'nosniff',
17
+ 'X-Frame-Options': 'DENY',
18
+ 'X-XSS-Protection': '1; mode=block',
19
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
20
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
21
+ };
22
+ function buildCSPHeader(nonce) {
23
+ return `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;`;
24
+ }
25
+ function withSecurityHeaders(response, nonce) {
26
+ const headers = new Headers(response.headers);
27
+ for (const [key, value] of Object.entries(STATIC_SECURITY_HEADERS)) {
28
+ headers.set(key, value);
29
+ }
30
+ headers.set('Content-Security-Policy', buildCSPHeader(nonce));
31
+ return new Response(response.body, {
32
+ status: response.status,
33
+ statusText: response.statusText,
34
+ headers,
35
+ });
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Helpers
39
+ // ---------------------------------------------------------------------------
40
+ function stripBasePath(request, basePath) {
41
+ const url = new URL(request.url);
42
+ if (url.pathname.startsWith(basePath)) {
43
+ url.pathname = url.pathname.slice(basePath.length) || '/';
44
+ return new Request(url.toString(), request);
45
+ }
46
+ return request;
47
+ }
48
+ export function createHandler(appOrConfig, options) {
49
+ // Config object form
50
+ if ('app' in appOrConfig && typeof appOrConfig.app === 'function') {
51
+ return createFullStackHandler(appOrConfig);
52
+ }
53
+ // Simple AppBuilder form (backward compat)
54
+ return createSimpleHandler(appOrConfig, options);
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Simple handler (backward compat)
58
+ // ---------------------------------------------------------------------------
59
+ function createSimpleHandler(app, options) {
60
+ const handler = app.handler;
61
+ return {
62
+ async fetch(request, _env, _ctx) {
63
+ if (options?.basePath) {
64
+ request = stripBasePath(request, options.basePath);
65
+ }
66
+ try {
67
+ return await handler(request);
68
+ }
69
+ catch (error) {
70
+ console.error('Unhandled error in worker:', error);
71
+ return new Response('Internal Server Error', { status: 500 });
72
+ }
73
+ },
74
+ };
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // HTML template generation
78
+ // ---------------------------------------------------------------------------
79
+ export function generateHTMLTemplate(clientScript, title, nonce) {
80
+ const nonceAttr = nonce != null ? ` nonce="${nonce}"` : '';
81
+ return `<!doctype html>
82
+ <html lang="en">
83
+ <head>
84
+ <meta charset="UTF-8">
85
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
86
+ <title>${title}</title>
87
+ </head>
88
+ <body>
89
+ <div id="app"><!--ssr-outlet--></div>
90
+ <script type="module" src="${clientScript}"${nonceAttr}></script>
91
+ </body>
92
+ </html>`;
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Full-stack handler
96
+ // ---------------------------------------------------------------------------
97
+ function isSSRModuleConfig(ssr) {
98
+ return typeof ssr === 'object' && 'module' in ssr;
99
+ }
100
+ function createFullStackHandler(config) {
101
+ const { basePath, ssr, securityHeaders } = config;
102
+ let cachedApp = null;
103
+ // SSR handler factory: when using SSRModuleConfig with nonce support, this
104
+ // is called per-request with the current nonce. For custom callbacks it
105
+ // is set once and always returns the same handler.
106
+ let ssrHandlerFactory = null;
107
+ let ssrResolved = false;
108
+ function getApp(env) {
109
+ if (!cachedApp) {
110
+ cachedApp = config.app(env);
111
+ }
112
+ return cachedApp;
113
+ }
114
+ async function resolveSSR() {
115
+ if (ssrResolved)
116
+ return;
117
+ ssrResolved = true;
118
+ if (!ssr)
119
+ return;
120
+ if (isSSRModuleConfig(ssr)) {
121
+ const { createSSRHandler } = await import('@vertz/ui-server/ssr');
122
+ const { module, clientScript = '/assets/entry-client.js', title = 'Vertz App', ssrTimeout = 5000, } = ssr;
123
+ // Return a factory that creates an SSR handler with the per-request nonce
124
+ ssrHandlerFactory = (nonce) => createSSRHandler({
125
+ module,
126
+ template: generateHTMLTemplate(clientScript, title, nonce),
127
+ ssrTimeout,
128
+ nonce,
129
+ });
130
+ }
131
+ else {
132
+ // Custom callback — wrap it in a factory that ignores the nonce
133
+ ssrHandlerFactory = () => ssr;
134
+ }
135
+ }
136
+ function applyHeaders(response, nonce) {
137
+ return securityHeaders ? withSecurityHeaders(response, nonce) : response;
138
+ }
139
+ return {
140
+ async fetch(request, env, _ctx) {
141
+ await resolveSSR();
142
+ const url = new URL(request.url);
143
+ const nonce = generateNonce();
144
+ // Route splitting: basePath/* → API handler (no URL rewriting — the
145
+ // app's own basePath/apiPrefix handles prefix matching internally)
146
+ if (url.pathname.startsWith(basePath)) {
147
+ try {
148
+ const app = getApp(env);
149
+ const response = await app.handler(request);
150
+ return applyHeaders(response, nonce);
151
+ }
152
+ catch (error) {
153
+ console.error('Unhandled error in worker:', error);
154
+ return applyHeaders(new Response('Internal Server Error', { status: 500 }), nonce);
155
+ }
156
+ }
157
+ // Non-API routes → SSR or 404
158
+ if (ssrHandlerFactory) {
159
+ const app = getApp(env);
160
+ const ssrHandler = ssrHandlerFactory(nonce);
161
+ const origin = url.origin;
162
+ // Patch fetch during SSR so API requests (e.g. query() calling
163
+ // fetch('/api/todos')) are routed through the in-memory app handler
164
+ // instead of attempting a network self-fetch (which fails on Workers).
165
+ const originalFetch = globalThis.fetch;
166
+ globalThis.fetch = (input, init) => {
167
+ // Determine the pathname from the input (string, URL, or Request)
168
+ const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
169
+ const isRelative = rawUrl.startsWith('/');
170
+ const pathname = isRelative ? rawUrl.split('?')[0] : new URL(rawUrl).pathname;
171
+ const isLocal = isRelative || new URL(rawUrl).origin === origin;
172
+ if (isLocal && pathname.startsWith(basePath)) {
173
+ // Build an absolute URL so Request() doesn't reject relative URLs
174
+ const absoluteUrl = isRelative ? `${origin}${rawUrl}` : rawUrl;
175
+ const req = new Request(absoluteUrl, init);
176
+ return app.handler(req);
177
+ }
178
+ return originalFetch(input, init);
179
+ };
180
+ try {
181
+ const response = await ssrHandler(request);
182
+ return applyHeaders(response, nonce);
183
+ }
184
+ catch (error) {
185
+ console.error('Unhandled error in worker:', error);
186
+ return applyHeaders(new Response('Internal Server Error', { status: 500 }), nonce);
187
+ }
188
+ finally {
189
+ globalThis.fetch = originalFetch;
190
+ }
191
+ }
192
+ return applyHeaders(new Response('Not Found', { status: 404 }), nonce);
193
+ },
194
+ };
195
+ }
196
+ //# sourceMappingURL=handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.js","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,8 +1,3 @@
1
- import { AppBuilder } from "@vertz/core";
2
- interface CloudflareHandlerOptions {
3
- basePath?: string;
4
- }
5
- declare function createHandler(app: AppBuilder, options?: CloudflareHandlerOptions): {
6
- fetch(request: Request, _env: unknown, _ctx: ExecutionContext): Promise<Response>;
7
- };
8
- export { createHandler, CloudflareHandlerOptions };
1
+ export type { CloudflareHandlerConfig, CloudflareHandlerOptions, CloudflareWorkerModule, SSRModuleConfig, } from './handler.js';
2
+ export { createHandler, generateHTMLTemplate, generateNonce } from './handler.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,uBAAuB,EACvB,wBAAwB,EACxB,sBAAsB,EACtB,eAAe,GAChB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -1,24 +1,2 @@
1
- // src/handler.ts
2
- function createHandler(app, options) {
3
- const handler = app.handler;
4
- return {
5
- async fetch(request, _env, _ctx) {
6
- if (options?.basePath) {
7
- const url = new URL(request.url);
8
- if (url.pathname.startsWith(options.basePath)) {
9
- url.pathname = url.pathname.slice(options.basePath.length) || "/";
10
- request = new Request(url.toString(), request);
11
- }
12
- }
13
- try {
14
- return await handler(request);
15
- } catch (error) {
16
- console.error("Unhandled error in worker:", error);
17
- return new Response("Internal Server Error", { status: 500 });
18
- }
19
- }
20
- };
21
- }
22
- export {
23
- createHandler
24
- };
1
+ export { createHandler, generateHTMLTemplate, generateNonce } from './handler.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertz/cloudflare",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "description": "Cloudflare Workers adapter for vertz",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,20 +17,21 @@
17
17
  }
18
18
  },
19
19
  "scripts": {
20
- "build": "bunup",
20
+ "build": "tsc",
21
21
  "typecheck": "tsc --noEmit",
22
22
  "test": "vitest run"
23
23
  },
24
24
  "dependencies": {
25
- "@vertz/core": "workspace:*"
25
+ "@vertz/core": "0.2.2"
26
26
  },
27
27
  "devDependencies": {
28
- "@cloudflare/workers-types": "^4.20250214.0",
29
- "bunup": "latest",
28
+ "@cloudflare/workers-types": "^4.20260305.0",
29
+ "@vertz/ui-server": "0.2.2",
30
+ "@vitest/coverage-v8": "^4.0.18",
30
31
  "typescript": "^5.7.3"
31
32
  },
32
33
  "peerDependencies": {
33
- "@vertz/ui-server": "workspace:*"
34
+ "@vertz/ui-server": "0.2.2"
34
35
  },
35
36
  "peerDependenciesMeta": {
36
37
  "@vertz/ui-server": {
package/src/handler.ts CHANGED
@@ -1,21 +1,168 @@
1
1
  import type { AppBuilder } from '@vertz/core';
2
+ import type { SSRModule } from '@vertz/ui-server/ssr';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Types
6
+ // ---------------------------------------------------------------------------
2
7
 
3
8
  export interface CloudflareHandlerOptions {
4
9
  basePath?: string;
5
10
  }
6
11
 
7
- export function createHandler(app: AppBuilder, options?: CloudflareHandlerOptions) {
12
+ /**
13
+ * SSR module configuration for zero-boilerplate server-side rendering.
14
+ *
15
+ * Pass the app module directly and the handler generates the HTML template,
16
+ * wires up createSSRHandler, and handles the full SSR pipeline.
17
+ */
18
+ export interface SSRModuleConfig {
19
+ /** App module exporting App, theme?, styles?, getInjectedCSS? */
20
+ module: SSRModule;
21
+ /** Client-side entry script path. Default: '/assets/entry-client.js' */
22
+ clientScript?: string;
23
+ /** HTML document title. Default: 'Vertz App' */
24
+ title?: string;
25
+ /** SSR query timeout in ms. Default: 5000 (generous for D1 cold starts). */
26
+ ssrTimeout?: number;
27
+ }
28
+
29
+ /**
30
+ * Full-stack configuration for createHandler.
31
+ *
32
+ * Supports lazy app initialization (for D1 bindings), SSR fallback,
33
+ * and automatic security headers.
34
+ */
35
+ export interface CloudflareHandlerConfig {
36
+ /**
37
+ * Factory that creates the AppBuilder. Receives the Worker env bindings.
38
+ * Called once on first request, then cached.
39
+ */
40
+ app: (env: unknown) => AppBuilder;
41
+
42
+ /** API path prefix. Requests matching this prefix go to the app handler. */
43
+ basePath: string;
44
+
45
+ /**
46
+ * SSR configuration for non-API routes.
47
+ *
48
+ * - `SSRModuleConfig` — zero-boilerplate: pass the app module directly
49
+ * - `(request: Request) => Promise<Response>` — custom SSR callback
50
+ * - `undefined` — non-API requests return 404
51
+ */
52
+ ssr?: SSRModuleConfig | ((request: Request) => Promise<Response>);
53
+
54
+ /** When true, adds standard security headers to all responses. */
55
+ securityHeaders?: boolean;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Security headers
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /** Generate a cryptographically random nonce for CSP. */
63
+ export function generateNonce(): string {
64
+ const bytes = new Uint8Array(16);
65
+ crypto.getRandomValues(bytes);
66
+ // Base64-encode without padding for a compact, URL-safe nonce
67
+ let binary = '';
68
+ for (const byte of bytes) {
69
+ binary += String.fromCharCode(byte);
70
+ }
71
+ return btoa(binary);
72
+ }
73
+
74
+ const STATIC_SECURITY_HEADERS: Record<string, string> = {
75
+ 'X-Content-Type-Options': 'nosniff',
76
+ 'X-Frame-Options': 'DENY',
77
+ 'X-XSS-Protection': '1; mode=block',
78
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
79
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
80
+ };
81
+
82
+ function buildCSPHeader(nonce: string): string {
83
+ return `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;`;
84
+ }
85
+
86
+ function withSecurityHeaders(response: Response, nonce: string): Response {
87
+ const headers = new Headers(response.headers);
88
+ for (const [key, value] of Object.entries(STATIC_SECURITY_HEADERS)) {
89
+ headers.set(key, value);
90
+ }
91
+ headers.set('Content-Security-Policy', buildCSPHeader(nonce));
92
+ return new Response(response.body, {
93
+ status: response.status,
94
+ statusText: response.statusText,
95
+ headers,
96
+ });
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Helpers
101
+ // ---------------------------------------------------------------------------
102
+
103
+ function stripBasePath(request: Request, basePath: string): Request {
104
+ const url = new URL(request.url);
105
+ if (url.pathname.startsWith(basePath)) {
106
+ url.pathname = url.pathname.slice(basePath.length) || '/';
107
+ return new Request(url.toString(), request);
108
+ }
109
+ return request;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // createHandler overloads
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Create a Cloudflare Worker handler from a Vertz app.
118
+ *
119
+ * Simple form — wraps an AppBuilder directly:
120
+ * ```ts
121
+ * export default createHandler(app, { basePath: '/api' });
122
+ * ```
123
+ *
124
+ * Config form — full-stack with lazy init, SSR, and security headers:
125
+ * ```ts
126
+ * export default createHandler({
127
+ * app: (env) => createServer({ entities, db: createDb({ d1: env.DB }) }),
128
+ * basePath: '/api',
129
+ * ssr: (req) => renderToString(new URL(req.url).pathname),
130
+ * securityHeaders: true,
131
+ * });
132
+ * ```
133
+ */
134
+ /** Worker module shape returned by createHandler. */
135
+ export interface CloudflareWorkerModule {
136
+ fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response>;
137
+ }
138
+
139
+ export function createHandler(
140
+ appOrConfig: AppBuilder | CloudflareHandlerConfig,
141
+ options?: CloudflareHandlerOptions,
142
+ ): CloudflareWorkerModule {
143
+ // Config object form
144
+ if ('app' in appOrConfig && typeof appOrConfig.app === 'function') {
145
+ return createFullStackHandler(appOrConfig);
146
+ }
147
+
148
+ // Simple AppBuilder form (backward compat)
149
+ return createSimpleHandler(appOrConfig as AppBuilder, options);
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Simple handler (backward compat)
154
+ // ---------------------------------------------------------------------------
155
+
156
+ function createSimpleHandler(
157
+ app: AppBuilder,
158
+ options?: CloudflareHandlerOptions,
159
+ ): CloudflareWorkerModule {
8
160
  const handler = app.handler;
9
161
 
10
162
  return {
11
163
  async fetch(request: Request, _env: unknown, _ctx: ExecutionContext): Promise<Response> {
12
- // If basePath, strip it from the URL before routing
13
164
  if (options?.basePath) {
14
- const url = new URL(request.url);
15
- if (url.pathname.startsWith(options.basePath)) {
16
- url.pathname = url.pathname.slice(options.basePath.length) || '/';
17
- request = new Request(url.toString(), request);
18
- }
165
+ request = stripBasePath(request, options.basePath);
19
166
  }
20
167
  try {
21
168
  return await handler(request);
@@ -26,3 +173,142 @@ export function createHandler(app: AppBuilder, options?: CloudflareHandlerOption
26
173
  },
27
174
  };
28
175
  }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // HTML template generation
179
+ // ---------------------------------------------------------------------------
180
+
181
+ export function generateHTMLTemplate(clientScript: string, title: string, nonce?: string): string {
182
+ const nonceAttr = nonce != null ? ` nonce="${nonce}"` : '';
183
+ return `<!doctype html>
184
+ <html lang="en">
185
+ <head>
186
+ <meta charset="UTF-8">
187
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
188
+ <title>${title}</title>
189
+ </head>
190
+ <body>
191
+ <div id="app"><!--ssr-outlet--></div>
192
+ <script type="module" src="${clientScript}"${nonceAttr}></script>
193
+ </body>
194
+ </html>`;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Full-stack handler
199
+ // ---------------------------------------------------------------------------
200
+
201
+ function isSSRModuleConfig(
202
+ ssr: SSRModuleConfig | ((request: Request) => Promise<Response>),
203
+ ): ssr is SSRModuleConfig {
204
+ return typeof ssr === 'object' && 'module' in ssr;
205
+ }
206
+
207
+ function createFullStackHandler(config: CloudflareHandlerConfig): CloudflareWorkerModule {
208
+ const { basePath, ssr, securityHeaders } = config;
209
+ let cachedApp: AppBuilder | null = null;
210
+ // SSR handler factory: when using SSRModuleConfig with nonce support, this
211
+ // is called per-request with the current nonce. For custom callbacks it
212
+ // is set once and always returns the same handler.
213
+ let ssrHandlerFactory: ((nonce?: string) => (request: Request) => Promise<Response>) | null =
214
+ null;
215
+ let ssrResolved = false;
216
+
217
+ function getApp(env: unknown): AppBuilder {
218
+ if (!cachedApp) {
219
+ cachedApp = config.app(env);
220
+ }
221
+ return cachedApp;
222
+ }
223
+
224
+ async function resolveSSR(): Promise<void> {
225
+ if (ssrResolved) return;
226
+ ssrResolved = true;
227
+
228
+ if (!ssr) return;
229
+
230
+ if (isSSRModuleConfig(ssr)) {
231
+ const { createSSRHandler } = await import('@vertz/ui-server/ssr');
232
+ const {
233
+ module,
234
+ clientScript = '/assets/entry-client.js',
235
+ title = 'Vertz App',
236
+ ssrTimeout = 5000,
237
+ } = ssr;
238
+ // Return a factory that creates an SSR handler with the per-request nonce
239
+ ssrHandlerFactory = (nonce?: string) =>
240
+ createSSRHandler({
241
+ module,
242
+ template: generateHTMLTemplate(clientScript, title, nonce),
243
+ ssrTimeout,
244
+ nonce,
245
+ });
246
+ } else {
247
+ // Custom callback — wrap it in a factory that ignores the nonce
248
+ ssrHandlerFactory = () => ssr;
249
+ }
250
+ }
251
+
252
+ function applyHeaders(response: Response, nonce: string): Response {
253
+ return securityHeaders ? withSecurityHeaders(response, nonce) : response;
254
+ }
255
+
256
+ return {
257
+ async fetch(request: Request, env: unknown, _ctx: ExecutionContext): Promise<Response> {
258
+ await resolveSSR();
259
+ const url = new URL(request.url);
260
+ const nonce = generateNonce();
261
+
262
+ // Route splitting: basePath/* → API handler (no URL rewriting — the
263
+ // app's own basePath/apiPrefix handles prefix matching internally)
264
+ if (url.pathname.startsWith(basePath)) {
265
+ try {
266
+ const app = getApp(env);
267
+ const response = await app.handler(request);
268
+ return applyHeaders(response, nonce);
269
+ } catch (error) {
270
+ console.error('Unhandled error in worker:', error);
271
+ return applyHeaders(new Response('Internal Server Error', { status: 500 }), nonce);
272
+ }
273
+ }
274
+
275
+ // Non-API routes → SSR or 404
276
+ if (ssrHandlerFactory) {
277
+ const app = getApp(env);
278
+ const ssrHandler = ssrHandlerFactory(nonce);
279
+ const origin = url.origin;
280
+ // Patch fetch during SSR so API requests (e.g. query() calling
281
+ // fetch('/api/todos')) are routed through the in-memory app handler
282
+ // instead of attempting a network self-fetch (which fails on Workers).
283
+ const originalFetch = globalThis.fetch;
284
+ globalThis.fetch = (input, init) => {
285
+ // Determine the pathname from the input (string, URL, or Request)
286
+ const rawUrl =
287
+ typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
288
+ const isRelative = rawUrl.startsWith('/');
289
+ const pathname = isRelative ? rawUrl.split('?')[0] : new URL(rawUrl).pathname;
290
+ const isLocal = isRelative || new URL(rawUrl).origin === origin;
291
+
292
+ if (isLocal && pathname.startsWith(basePath)) {
293
+ // Build an absolute URL so Request() doesn't reject relative URLs
294
+ const absoluteUrl = isRelative ? `${origin}${rawUrl}` : rawUrl;
295
+ const req = new Request(absoluteUrl, init);
296
+ return app.handler(req);
297
+ }
298
+ return originalFetch(input, init);
299
+ };
300
+ try {
301
+ const response = await ssrHandler(request);
302
+ return applyHeaders(response, nonce);
303
+ } catch (error) {
304
+ console.error('Unhandled error in worker:', error);
305
+ return applyHeaders(new Response('Internal Server Error', { status: 500 }), nonce);
306
+ } finally {
307
+ globalThis.fetch = originalFetch;
308
+ }
309
+ }
310
+
311
+ return applyHeaders(new Response('Not Found', { status: 404 }), nonce);
312
+ },
313
+ };
314
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,7 @@
1
- export type { CloudflareHandlerOptions } from './handler.js';
2
- export { createHandler } from './handler.js';
1
+ export type {
2
+ CloudflareHandlerConfig,
3
+ CloudflareHandlerOptions,
4
+ CloudflareWorkerModule,
5
+ SSRModuleConfig,
6
+ } from './handler.js';
7
+ export { createHandler, generateHTMLTemplate, generateNonce } from './handler.js';
@@ -1,6 +1,12 @@
1
1
  import type { AppBuilder } from '@vertz/core';
2
- import { describe, expect, it, vi } from 'vitest';
3
- import { createHandler } from '../src/handler.js';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { createHandler, generateHTMLTemplate, generateNonce } from '../src/handler.js';
4
+
5
+ function mockApp(handler?: (...args: unknown[]) => Promise<Response>): AppBuilder {
6
+ return {
7
+ handler: handler ?? vi.fn().mockResolvedValue(new Response('OK')),
8
+ } as unknown as AppBuilder;
9
+ }
4
10
 
5
11
  describe('createHandler', () => {
6
12
  it('returns proper Worker export with fetch method', () => {
@@ -176,3 +182,494 @@ describe('createHandler', () => {
176
182
  consoleErrorSpy.mockRestore();
177
183
  });
178
184
  });
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Full-stack config-based createHandler
188
+ // ---------------------------------------------------------------------------
189
+
190
+ describe('createHandler (config object)', () => {
191
+ const mockEnv = { DB: {} };
192
+ const mockCtx = {} as ExecutionContext;
193
+
194
+ it('routes API requests to app handler and SSR to ssr handler', async () => {
195
+ const apiHandler = vi.fn().mockResolvedValue(
196
+ new Response('{"items":[]}', {
197
+ headers: { 'Content-Type': 'application/json' },
198
+ }),
199
+ );
200
+ const ssrHandler = vi.fn().mockResolvedValue(
201
+ new Response('<html>SSR</html>', {
202
+ headers: { 'Content-Type': 'text/html' },
203
+ }),
204
+ );
205
+
206
+ const worker = createHandler({
207
+ app: () => mockApp(apiHandler),
208
+ basePath: '/api',
209
+ ssr: ssrHandler,
210
+ });
211
+
212
+ // API request → app handler
213
+ const apiResponse = await worker.fetch(
214
+ new Request('https://example.com/api/todos'),
215
+ mockEnv,
216
+ mockCtx,
217
+ );
218
+ expect(apiHandler).toHaveBeenCalled();
219
+ expect(await apiResponse.text()).toBe('{"items":[]}');
220
+
221
+ // SSR request → ssr handler
222
+ const ssrResponse = await worker.fetch(new Request('https://example.com/'), mockEnv, mockCtx);
223
+ expect(ssrHandler).toHaveBeenCalled();
224
+ expect(await ssrResponse.text()).toBe('<html>SSR</html>');
225
+ });
226
+
227
+ it('passes env to the app factory and caches the result', async () => {
228
+ const apiHandler = vi.fn().mockResolvedValue(new Response('OK'));
229
+ const appFactory = vi.fn().mockReturnValue(mockApp(apiHandler));
230
+
231
+ const worker = createHandler({
232
+ app: appFactory,
233
+ basePath: '/api',
234
+ });
235
+
236
+ // First request — factory called
237
+ await worker.fetch(new Request('https://example.com/api/x'), mockEnv, mockCtx);
238
+ expect(appFactory).toHaveBeenCalledTimes(1);
239
+ expect(appFactory).toHaveBeenCalledWith(mockEnv);
240
+
241
+ // Second request — factory NOT called again (cached)
242
+ await worker.fetch(new Request('https://example.com/api/y'), mockEnv, mockCtx);
243
+ expect(appFactory).toHaveBeenCalledTimes(1);
244
+ });
245
+
246
+ it('adds security headers when securityHeaders is true', async () => {
247
+ const worker = createHandler({
248
+ app: () => mockApp(vi.fn().mockResolvedValue(new Response('OK'))),
249
+ basePath: '/api',
250
+ securityHeaders: true,
251
+ });
252
+
253
+ const response = await worker.fetch(
254
+ new Request('https://example.com/api/test'),
255
+ mockEnv,
256
+ mockCtx,
257
+ );
258
+
259
+ expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
260
+ expect(response.headers.get('X-Frame-Options')).toBe('DENY');
261
+ expect(response.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin');
262
+ });
263
+
264
+ it('adds security headers to SSR responses too', async () => {
265
+ const worker = createHandler({
266
+ app: () => mockApp(),
267
+ basePath: '/api',
268
+ ssr: () => Promise.resolve(new Response('<html></html>')),
269
+ securityHeaders: true,
270
+ });
271
+
272
+ const response = await worker.fetch(new Request('https://example.com/'), mockEnv, mockCtx);
273
+
274
+ expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
275
+ expect(response.headers.get('X-Frame-Options')).toBe('DENY');
276
+ });
277
+
278
+ it('passes full URL to app handler (no basePath stripping)', async () => {
279
+ const apiHandler = vi.fn().mockResolvedValue(new Response('OK'));
280
+
281
+ const worker = createHandler({
282
+ app: () => mockApp(apiHandler),
283
+ basePath: '/api',
284
+ });
285
+
286
+ await worker.fetch(new Request('https://example.com/api/todos/123'), mockEnv, mockCtx);
287
+
288
+ const calledRequest = apiHandler.mock.calls[0][0] as Request;
289
+ const url = new URL(calledRequest.url);
290
+ expect(url.pathname).toBe('/api/todos/123');
291
+ });
292
+
293
+ it('returns 500 with error message when app handler throws', async () => {
294
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
295
+
296
+ const worker = createHandler({
297
+ app: () => mockApp(vi.fn().mockRejectedValue(new Error('DB connection failed'))),
298
+ basePath: '/api',
299
+ });
300
+
301
+ const response = await worker.fetch(
302
+ new Request('https://example.com/api/test'),
303
+ mockEnv,
304
+ mockCtx,
305
+ );
306
+
307
+ expect(response.status).toBe(500);
308
+ expect(await response.text()).toBe('Internal Server Error');
309
+ consoleErrorSpy.mockRestore();
310
+ });
311
+
312
+ it('returns 500 with error message when SSR handler throws', async () => {
313
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
314
+
315
+ const worker = createHandler({
316
+ app: () => mockApp(),
317
+ basePath: '/api',
318
+ ssr: () => Promise.reject(new Error('SSR render failed')),
319
+ });
320
+
321
+ const response = await worker.fetch(new Request('https://example.com/'), mockEnv, mockCtx);
322
+
323
+ expect(response.status).toBe(500);
324
+ expect(await response.text()).toBe('Internal Server Error');
325
+ consoleErrorSpy.mockRestore();
326
+ });
327
+
328
+ it('returns 404 for non-API routes when no SSR handler is provided', async () => {
329
+ const worker = createHandler({
330
+ app: () => mockApp(),
331
+ basePath: '/api',
332
+ });
333
+
334
+ const response = await worker.fetch(
335
+ new Request('https://example.com/some-page'),
336
+ mockEnv,
337
+ mockCtx,
338
+ );
339
+
340
+ expect(response.status).toBe(404);
341
+ expect(await response.text()).toBe('Not Found');
342
+ });
343
+ });
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // generateHTMLTemplate
347
+ // ---------------------------------------------------------------------------
348
+
349
+ describe('generateHTMLTemplate', () => {
350
+ it('generates HTML with ssr-outlet, client script, and title', () => {
351
+ const html = generateHTMLTemplate('/assets/client.js', 'My App');
352
+
353
+ expect(html).toContain('<!--ssr-outlet-->');
354
+ expect(html).toContain('<script type="module" src="/assets/client.js"></script>');
355
+ expect(html).toContain('<title>My App</title>');
356
+ expect(html).toContain('<!doctype html>');
357
+ expect(html).toContain('<div id="app">');
358
+ });
359
+ });
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // SSR module config
363
+ // ---------------------------------------------------------------------------
364
+
365
+ describe('createHandler (SSR module config)', () => {
366
+ const mockEnv = { DB: {} };
367
+ const mockCtx = {} as ExecutionContext;
368
+
369
+ // We mock @vertz/ui-server's createSSRHandler to isolate wiring logic.
370
+ // The mock returns a handler that echoes 'SSR Module' so we can verify routing.
371
+ const mockSSRRequestHandler = vi.fn().mockResolvedValue(
372
+ new Response('<html>SSR Module</html>', {
373
+ headers: { 'Content-Type': 'text/html' },
374
+ }),
375
+ );
376
+ const mockCreateSSRHandler = vi.fn().mockReturnValue(mockSSRRequestHandler);
377
+
378
+ beforeEach(() => {
379
+ vi.doMock('@vertz/ui-server/ssr', () => ({
380
+ createSSRHandler: mockCreateSSRHandler,
381
+ }));
382
+ mockSSRRequestHandler.mockClear();
383
+ mockCreateSSRHandler.mockClear();
384
+ });
385
+
386
+ afterEach(() => {
387
+ vi.doUnmock('@vertz/ui-server/ssr');
388
+ });
389
+
390
+ it('routes non-API requests through the SSR handler created from module config', async () => {
391
+ const { createHandler: freshCreateHandler } = await import('../src/handler.js');
392
+
393
+ const ssrModule = { App: () => ({}) };
394
+ const worker = freshCreateHandler({
395
+ app: () => mockApp(),
396
+ basePath: '/api',
397
+ ssr: { module: ssrModule },
398
+ });
399
+
400
+ const response = await worker.fetch(new Request('https://example.com/'), mockEnv, mockCtx);
401
+
402
+ expect(mockCreateSSRHandler).toHaveBeenCalledOnce();
403
+ expect(mockSSRRequestHandler).toHaveBeenCalled();
404
+ expect(await response.text()).toBe('<html>SSR Module</html>');
405
+ });
406
+
407
+ it('passes default clientScript and title to createSSRHandler', async () => {
408
+ const { createHandler: freshCreateHandler } = await import('../src/handler.js');
409
+
410
+ const ssrModule = { App: () => ({}) };
411
+ const worker = freshCreateHandler({
412
+ app: () => mockApp(),
413
+ basePath: '/api',
414
+ ssr: { module: ssrModule },
415
+ });
416
+
417
+ await worker.fetch(new Request('https://example.com/'), mockEnv, mockCtx);
418
+
419
+ expect(mockCreateSSRHandler).toHaveBeenCalledWith(
420
+ expect.objectContaining({
421
+ module: ssrModule,
422
+ template: expect.stringContaining('/assets/entry-client.js'),
423
+ }),
424
+ );
425
+ expect(mockCreateSSRHandler).toHaveBeenCalledWith(
426
+ expect.objectContaining({
427
+ module: ssrModule,
428
+ template: expect.stringContaining('<title>Vertz App</title>'),
429
+ }),
430
+ );
431
+ });
432
+
433
+ it('passes custom clientScript and title to createSSRHandler', async () => {
434
+ const { createHandler: freshCreateHandler } = await import('../src/handler.js');
435
+
436
+ const ssrModule = { App: () => ({}) };
437
+ const worker = freshCreateHandler({
438
+ app: () => mockApp(),
439
+ basePath: '/api',
440
+ ssr: {
441
+ module: ssrModule,
442
+ clientScript: '/custom/client.js',
443
+ title: 'My Custom App',
444
+ },
445
+ });
446
+
447
+ await worker.fetch(new Request('https://example.com/'), mockEnv, mockCtx);
448
+
449
+ expect(mockCreateSSRHandler).toHaveBeenCalledWith(
450
+ expect.objectContaining({
451
+ module: ssrModule,
452
+ template: expect.stringContaining('/custom/client.js'),
453
+ }),
454
+ );
455
+ expect(mockCreateSSRHandler).toHaveBeenCalledWith(
456
+ expect.objectContaining({
457
+ module: ssrModule,
458
+ template: expect.stringContaining('<title>My Custom App</title>'),
459
+ }),
460
+ );
461
+ });
462
+
463
+ it('still works with ssr callback (backward compat)', async () => {
464
+ const { createHandler: freshCreateHandler } = await import('../src/handler.js');
465
+
466
+ const ssrCallback = vi.fn().mockResolvedValue(
467
+ new Response('<html>Callback SSR</html>', {
468
+ headers: { 'Content-Type': 'text/html' },
469
+ }),
470
+ );
471
+
472
+ const worker = freshCreateHandler({
473
+ app: () => mockApp(),
474
+ basePath: '/api',
475
+ ssr: ssrCallback,
476
+ });
477
+
478
+ const response = await worker.fetch(new Request('https://example.com/'), mockEnv, mockCtx);
479
+
480
+ // createSSRHandler should NOT be called — callback is used directly
481
+ expect(mockCreateSSRHandler).not.toHaveBeenCalled();
482
+ expect(ssrCallback).toHaveBeenCalled();
483
+ expect(await response.text()).toBe('<html>Callback SSR</html>');
484
+ });
485
+
486
+ it('routes API requests to app handler even with SSR module config', async () => {
487
+ const { createHandler: freshCreateHandler } = await import('../src/handler.js');
488
+
489
+ const apiHandler = vi.fn().mockResolvedValue(
490
+ new Response('{"items":[]}', {
491
+ headers: { 'Content-Type': 'application/json' },
492
+ }),
493
+ );
494
+
495
+ const worker = freshCreateHandler({
496
+ app: () => mockApp(apiHandler),
497
+ basePath: '/api',
498
+ ssr: { module: { App: () => ({}) } },
499
+ });
500
+
501
+ const response = await worker.fetch(
502
+ new Request('https://example.com/api/todos'),
503
+ mockEnv,
504
+ mockCtx,
505
+ );
506
+
507
+ expect(apiHandler).toHaveBeenCalled();
508
+ expect(mockSSRRequestHandler).not.toHaveBeenCalled();
509
+ expect(await response.text()).toBe('{"items":[]}');
510
+ });
511
+ });
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // Nonce-based CSP
515
+ // ---------------------------------------------------------------------------
516
+
517
+ describe('generateNonce', () => {
518
+ it('returns a base64-encoded string', () => {
519
+ const nonce = generateNonce();
520
+
521
+ expect(typeof nonce).toBe('string');
522
+ expect(nonce.length).toBeGreaterThan(0);
523
+ // Base64 alphabet: A-Z, a-z, 0-9, +, /, =
524
+ expect(nonce).toMatch(/^[A-Za-z0-9+/=]+$/);
525
+ });
526
+
527
+ it('generates different nonces on each call', () => {
528
+ const nonces = new Set<string>();
529
+ for (let i = 0; i < 50; i++) {
530
+ nonces.add(generateNonce());
531
+ }
532
+ // With 128-bit random values, collisions are astronomically unlikely
533
+ expect(nonces.size).toBe(50);
534
+ });
535
+ });
536
+
537
+ describe('nonce-based CSP headers', () => {
538
+ const mockEnv = { DB: {} };
539
+ const mockCtx = {} as ExecutionContext;
540
+
541
+ it('CSP header contains nonce (not unsafe-inline) for script-src', async () => {
542
+ const worker = createHandler({
543
+ app: () => mockApp(vi.fn().mockResolvedValue(new Response('OK'))),
544
+ basePath: '/api',
545
+ securityHeaders: true,
546
+ });
547
+
548
+ const response = await worker.fetch(
549
+ new Request('https://example.com/api/test'),
550
+ mockEnv,
551
+ mockCtx,
552
+ );
553
+
554
+ const csp = response.headers.get('Content-Security-Policy')!;
555
+ expect(csp).toBeTruthy();
556
+ // Extract the script-src directive and verify it uses nonce, not unsafe-inline
557
+ const scriptSrcMatch = csp.match(/script-src\s+([^;]+)/);
558
+ expect(scriptSrcMatch).toBeTruthy();
559
+ const scriptSrc = scriptSrcMatch![1];
560
+ expect(scriptSrc).not.toContain('unsafe-inline');
561
+ expect(scriptSrc).toMatch(/'nonce-[A-Za-z0-9+/=]+'/);
562
+ });
563
+
564
+ it('CSP header keeps unsafe-inline for style-src', async () => {
565
+ const worker = createHandler({
566
+ app: () => mockApp(vi.fn().mockResolvedValue(new Response('OK'))),
567
+ basePath: '/api',
568
+ securityHeaders: true,
569
+ });
570
+
571
+ const response = await worker.fetch(
572
+ new Request('https://example.com/api/test'),
573
+ mockEnv,
574
+ mockCtx,
575
+ );
576
+
577
+ const csp = response.headers.get('Content-Security-Policy');
578
+ expect(csp).toMatch(/style-src 'self' 'unsafe-inline'/);
579
+ });
580
+
581
+ it('each request gets a different nonce in the CSP header', async () => {
582
+ const worker = createHandler({
583
+ app: () => mockApp(vi.fn().mockResolvedValue(new Response('OK'))),
584
+ basePath: '/api',
585
+ securityHeaders: true,
586
+ });
587
+
588
+ const nonces: string[] = [];
589
+ for (let i = 0; i < 10; i++) {
590
+ const response = await worker.fetch(
591
+ new Request('https://example.com/api/test'),
592
+ mockEnv,
593
+ mockCtx,
594
+ );
595
+ const csp = response.headers.get('Content-Security-Policy')!;
596
+ const match = csp.match(/nonce-([A-Za-z0-9+/=]+)/);
597
+ expect(match).toBeTruthy();
598
+ nonces.push(match![1]);
599
+ }
600
+
601
+ // All nonces should be unique
602
+ expect(new Set(nonces).size).toBe(10);
603
+ });
604
+
605
+ it('applies nonce-based CSP to SSR responses', async () => {
606
+ const worker = createHandler({
607
+ app: () => mockApp(),
608
+ basePath: '/api',
609
+ ssr: () => Promise.resolve(new Response('<html></html>')),
610
+ securityHeaders: true,
611
+ });
612
+
613
+ const response = await worker.fetch(new Request('https://example.com/'), mockEnv, mockCtx);
614
+
615
+ const csp = response.headers.get('Content-Security-Policy');
616
+ expect(csp).toBeTruthy();
617
+ expect(csp).toMatch(/script-src 'self' 'nonce-[A-Za-z0-9+/=]+'/);
618
+ });
619
+
620
+ it('applies nonce-based CSP to 404 responses', async () => {
621
+ const worker = createHandler({
622
+ app: () => mockApp(),
623
+ basePath: '/api',
624
+ securityHeaders: true,
625
+ });
626
+
627
+ const response = await worker.fetch(
628
+ new Request('https://example.com/some-page'),
629
+ mockEnv,
630
+ mockCtx,
631
+ );
632
+
633
+ expect(response.status).toBe(404);
634
+ const csp = response.headers.get('Content-Security-Policy');
635
+ expect(csp).toBeTruthy();
636
+ expect(csp).toMatch(/script-src 'self' 'nonce-[A-Za-z0-9+/=]+'/);
637
+ });
638
+
639
+ it('applies nonce-based CSP to 500 error responses', async () => {
640
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
641
+
642
+ const worker = createHandler({
643
+ app: () => mockApp(vi.fn().mockRejectedValue(new Error('fail'))),
644
+ basePath: '/api',
645
+ securityHeaders: true,
646
+ });
647
+
648
+ const response = await worker.fetch(
649
+ new Request('https://example.com/api/test'),
650
+ mockEnv,
651
+ mockCtx,
652
+ );
653
+
654
+ expect(response.status).toBe(500);
655
+ const csp = response.headers.get('Content-Security-Policy');
656
+ expect(csp).toMatch(/script-src 'self' 'nonce-[A-Za-z0-9+/=]+'/);
657
+
658
+ consoleErrorSpy.mockRestore();
659
+ });
660
+ });
661
+
662
+ describe('generateHTMLTemplate with nonce', () => {
663
+ it('adds nonce attribute to script tag when nonce is provided', () => {
664
+ const html = generateHTMLTemplate('/assets/client.js', 'My App', 'abc123');
665
+
666
+ expect(html).toContain('<script type="module" src="/assets/client.js" nonce="abc123"></script>');
667
+ });
668
+
669
+ it('omits nonce attribute when nonce is not provided', () => {
670
+ const html = generateHTMLTemplate('/assets/client.js', 'My App');
671
+
672
+ expect(html).toContain('<script type="module" src="/assets/client.js"></script>');
673
+ expect(html).not.toContain('nonce');
674
+ });
675
+ });
package/vitest.config.ts CHANGED
@@ -15,7 +15,7 @@ export default defineConfig({
15
15
  reporter: ['text', 'json-summary', 'json'],
16
16
  provider: 'v8',
17
17
  include: ['src/**/*.ts'],
18
- exclude: ['src/**/*.test.ts', 'src/index.ts'],
18
+ exclude: ['src/**/*.test.ts', 'src/**/*.test-d.ts', 'src/index.ts'],
19
19
  },
20
20
  },
21
21
  });