@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.
- package/.turbo/turbo-build.log +1 -15
- package/CHANGELOG.md +17 -0
- package/dist/handler.d.ts +73 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +196 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +3 -8
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -24
- package/dist/index.js.map +1 -0
- package/package.json +7 -6
- package/src/handler.ts +293 -7
- package/src/index.ts +7 -2
- package/tests/handler.test.ts +499 -2
- package/vitest.config.ts +1 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,15 +1 @@
|
|
|
1
|
-
$
|
|
2
|
-
[34mi[39m [2mUsing bunup v0.16.26 and bun v1.3.9[22m
|
|
3
|
-
[34mi[39m Build started
|
|
4
|
-
|
|
5
|
-
[2msrc/index.ts[22m
|
|
6
|
-
|
|
7
|
-
[2m Output Raw Gzip[22m
|
|
8
|
-
|
|
9
|
-
[2mdist/[22mindex.js 676 B 349 B
|
|
10
|
-
[2mdist/[22m[32m[1mindex.d.ts[22m[39m 327 B 225 B
|
|
11
|
-
|
|
12
|
-
[1m2 files [22m [1m1003 B[22m [1m574 B[22m
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
[32m✓[39m Build completed in [32m[32m114ms[32m[39m
|
|
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"}
|
package/dist/handler.js
ADDED
|
@@ -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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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.
|
|
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": "
|
|
20
|
+
"build": "tsc",
|
|
21
21
|
"typecheck": "tsc --noEmit",
|
|
22
22
|
"test": "vitest run"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@vertz/core": "
|
|
25
|
+
"@vertz/core": "0.2.2"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@cloudflare/workers-types": "^4.
|
|
29
|
-
"
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
-
|
|
1
|
+
export type {
|
|
2
|
+
CloudflareHandlerConfig,
|
|
3
|
+
CloudflareHandlerOptions,
|
|
4
|
+
CloudflareWorkerModule,
|
|
5
|
+
SSRModuleConfig,
|
|
6
|
+
} from './handler.js';
|
|
7
|
+
export { createHandler, generateHTMLTemplate, generateNonce } from './handler.js';
|
package/tests/handler.test.ts
CHANGED
|
@@ -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
|
});
|