@vertz/cloudflare 0.2.1 → 0.2.4

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,16 +1 @@
1
- $ bunup
2
- i Using bunup v0.16.31 and bun v1.3.9
3
- i Using cloudflare/bunup.config.ts
4
- i Build started
5
-
6
- src/index.ts
7
-
8
- Output Raw Gzip
9
-
10
- dist/index.js 4.80 KB 1.56 KB
11
- dist/index.d.ts 2.56 KB 1.12 KB
12
-
13
- 2 files 7.36 KB 2.67 KB
14
-
15
-
16
- ✓ Build completed in 341ms
1
+ $ tsc
package/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # @vertz/cloudflare
2
+
3
+ ## 0.2.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`a986d07`](https://github.com/vertz-dev/vertz/commit/a986d0788ca0210dfa4f624153d4bda72257a78c)]:
8
+ - @vertz/ui-server@0.2.4
9
+ - @vertz/core@0.2.4
10
+
11
+ ## 0.2.3
12
+
13
+ ### Patch Changes
14
+
15
+ - 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)]:
16
+ - @vertz/core@0.2.3
17
+ - @vertz/ui-server@0.2.3
18
+
19
+ ## 0.2.2
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies []:
24
+ - @vertz/ui-server@0.2.2
25
+ - @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":"AAyDA,8EAA8E;AAC9E,mBAAmB;AACnB,8EAA8E;AAE9E,yDAAyD;AACzD,MAAM,UAAU,aAAa;IAC3B,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC,CAAC;IACjC,MAAM,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;IAC9B,8DAA8D;IAC9D,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,IAAI,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC;IACD,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC;AACtB,CAAC;AAED,MAAM,uBAAuB,GAA2B;IACtD,wBAAwB,EAAE,SAAS;IACnC,iBAAiB,EAAE,MAAM;IACzB,kBAAkB,EAAE,eAAe;IACnC,iBAAiB,EAAE,iCAAiC;IACpD,2BAA2B,EAAE,qCAAqC;CACnE,CAAC;AAEF,SAAS,cAAc,CAAC,KAAa;IACnC,OAAO,gDAAgD,KAAK,4DAA4D,CAAC;AAC3H,CAAC;AAED,SAAS,mBAAmB,CAAC,QAAkB,EAAE,KAAa;IAC5D,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IAC9C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,uBAAuB,CAAC,EAAE,CAAC;QACnE,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAC1B,CAAC;IACD,OAAO,CAAC,GAAG,CAAC,yBAAyB,EAAE,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;IAC9D,OAAO,IAAI,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE;QACjC,MAAM,EAAE,QAAQ,CAAC,MAAM;QACvB,UAAU,EAAE,QAAQ,CAAC,UAAU;QAC/B,OAAO;KACR,CAAC,CAAC;AACL,CAAC;AAED,8EAA8E;AAC9E,UAAU;AACV,8EAA8E;AAE9E,SAAS,aAAa,CAAC,OAAgB,EAAE,QAAgB;IACvD,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACjC,IAAI,GAAG,CAAC,QAAQ,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QACtC,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,GAAG,CAAC;QAC1D,OAAO,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,EAAE,OAAO,CAAC,CAAC;IAC9C,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AA6BD,MAAM,UAAU,aAAa,CAC3B,WAAiD,EACjD,OAAkC;IAElC,qBAAqB;IACrB,IAAI,KAAK,IAAI,WAAW,IAAI,OAAO,WAAW,CAAC,GAAG,KAAK,UAAU,EAAE,CAAC;QAClE,OAAO,sBAAsB,CAAC,WAAW,CAAC,CAAC;IAC7C,CAAC;IAED,2CAA2C;IAC3C,OAAO,mBAAmB,CAAC,WAAyB,EAAE,OAAO,CAAC,CAAC;AACjE,CAAC;AAED,8EAA8E;AAC9E,mCAAmC;AACnC,8EAA8E;AAE9E,SAAS,mBAAmB,CAC1B,GAAe,EACf,OAAkC;IAElC,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC;IAE5B,OAAO;QACL,KAAK,CAAC,KAAK,CAAC,OAAgB,EAAE,IAAa,EAAE,IAAsB;YACjE,IAAI,OAAO,EAAE,QAAQ,EAAE,CAAC;gBACtB,OAAO,GAAG,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;YACrD,CAAC;YACD,IAAI,CAAC;gBACH,OAAO,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;YAChC,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,OAAO,CAAC,KAAK,CAAC,4BAA4B,EAAE,KAAK,CAAC,CAAC;gBACnD,OAAO,IAAI,QAAQ,CAAC,uBAAuB,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;YAChE,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC;AAED,8EAA8E;AAC9E,2BAA2B;AAC3B,8EAA8E;AAE9E,MAAM,UAAU,oBAAoB,CAAC,YAAoB,EAAE,KAAa,EAAE,KAAc;IACtF,MAAM,SAAS,GAAG,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,WAAW,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;IAC3D,OAAO;;;;;SAKA,KAAK;;;;6BAIe,YAAY,IAAI,SAAS;;QAE9C,CAAC;AACT,CAAC;AAED,8EAA8E;AAC9E,qBAAqB;AACrB,8EAA8E;AAE9E,SAAS,iBAAiB,CACxB,GAAgE;IAEhE,OAAO,OAAO,GAAG,KAAK,QAAQ,IAAI,QAAQ,IAAI,GAAG,CAAC;AACpD,CAAC;AAED,SAAS,sBAAsB,CAAC,MAA+B;IAC7D,MAAM,EAAE,QAAQ,EAAE,GAAG,EAAE,eAAe,EAAE,GAAG,MAAM,CAAC;IAClD,IAAI,SAAS,GAAsB,IAAI,CAAC;IACxC,2EAA2E;IAC3E,wEAAwE;IACxE,mDAAmD;IACnD,IAAI,iBAAiB,GACnB,IAAI,CAAC;IACP,IAAI,WAAW,GAAG,KAAK,CAAC;IAExB,SAAS,MAAM,CAAC,GAAY;QAC1B,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,SAAS,GAAG,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC9B,CAAC;QACD,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,KAAK,UAAU,UAAU;QACvB,IAAI,WAAW;YAAE,OAAO;QACxB,WAAW,GAAG,IAAI,CAAC;QAEnB,IAAI,CAAC,GAAG;YAAE,OAAO;QAEjB,IAAI,iBAAiB,CAAC,GAAG,CAAC,EAAE,CAAC;YAC3B,MAAM,EAAE,gBAAgB,EAAE,GAAG,MAAM,MAAM,CAAC,sBAAsB,CAAC,CAAC;YAClE,MAAM,EACJ,MAAM,EACN,YAAY,GAAG,yBAAyB,EACxC,KAAK,GAAG,WAAW,EACnB,UAAU,GAAG,IAAI,GAClB,GAAG,GAAG,CAAC;YACR,0EAA0E;YAC1E,iBAAiB,GAAG,CAAC,KAAc,EAAE,EAAE,CACrC,gBAAgB,CAAC;gBACf,MAAM;gBACN,QAAQ,EAAE,oBAAoB,CAAC,YAAY,EAAE,KAAK,EAAE,KAAK,CAAC;gBAC1D,UAAU;gBACV,KAAK;aACN,CAAC,CAAC;QACP,CAAC;aAAM,CAAC;YACN,gEAAgE;YAChE,iBAAiB,GAAG,GAAG,EAAE,CAAC,GAAG,CAAC;QAChC,CAAC;IACH,CAAC;IAED,SAAS,YAAY,CAAC,QAAkB,EAAE,KAAa;QACrD,OAAO,eAAe,CAAC,CAAC,CAAC,mBAAmB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;IAC3E,CAAC;IAED,OAAO;QACL,KAAK,CAAC,KAAK,CAAC,OAAgB,EAAE,GAAY,EAAE,IAAsB;YAChE,MAAM,UAAU,EAAE,CAAC;YACnB,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YACjC,MAAM,KAAK,GAAG,aAAa,EAAE,CAAC;YAE9B,oEAAoE;YACpE,mEAAmE;YACnE,IAAI,GAAG,CAAC,QAAQ,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;gBACtC,IAAI,CAAC;oBACH,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;oBACxB,MAAM,QAAQ,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;oBAC5C,OAAO,YAAY,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;gBACvC,CAAC;gBAAC,OAAO,KAAK,EAAE,CAAC;oBACf,OAAO,CAAC,KAAK,CAAC,4BAA4B,EAAE,KAAK,CAAC,CAAC;oBACnD,OAAO,YAAY,CAAC,IAAI,QAAQ,CAAC,uBAAuB,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;gBACrF,CAAC;YACH,CAAC;YAED,8BAA8B;YAC9B,IAAI,iBAAiB,EAAE,CAAC;gBACtB,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;gBACxB,MAAM,UAAU,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;gBAC5C,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC;gBAC1B,+DAA+D;gBAC/D,oEAAoE;gBACpE,uEAAuE;gBACvE,MAAM,aAAa,GAAG,UAAU,CAAC,KAAK,CAAC;gBACvC,UAAU,CAAC,KAAK,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE;oBACjC,kEAAkE;oBAClE,MAAM,MAAM,GACV,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,YAAY,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC;oBACpF,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;oBAC1C,MAAM,QAAQ,GAAG,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC;oBAC9E,MAAM,OAAO,GAAG,UAAU,IAAI,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,MAAM,KAAK,MAAM,CAAC;oBAEhE,IAAI,OAAO,IAAI,QAAQ,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;wBAC7C,kEAAkE;wBAClE,MAAM,WAAW,GAAG,UAAU,CAAC,CAAC,CAAC,GAAG,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;wBAC/D,MAAM,GAAG,GAAG,IAAI,OAAO,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC;wBAC3C,OAAO,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;oBAC1B,CAAC;oBACD,OAAO,aAAa,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;gBACpC,CAAC,CAAC;gBACF,IAAI,CAAC;oBACH,MAAM,QAAQ,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC,CAAC;oBAC3C,OAAO,YAAY,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;gBACvC,CAAC;gBAAC,OAAO,KAAK,EAAE,CAAC;oBACf,OAAO,CAAC,KAAK,CAAC,4BAA4B,EAAE,KAAK,CAAC,CAAC;oBACnD,OAAO,YAAY,CAAC,IAAI,QAAQ,CAAC,uBAAuB,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;gBACrF,CAAC;wBAAS,CAAC;oBACT,UAAU,CAAC,KAAK,GAAG,aAAa,CAAC;gBACnC,CAAC;YACH,CAAC;YAED,OAAO,YAAY,CAAC,IAAI,QAAQ,CAAC,WAAW,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC;QACzE,CAAC;KACF,CAAC;AACJ,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,71 +1,3 @@
1
- import { AppBuilder } from "@vertz/core";
2
- import { SSRModule } from "@vertz/ui-server/ssr";
3
- 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
- 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
- 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
- /**
48
- * Create a Cloudflare Worker handler from a Vertz app.
49
- *
50
- * Simple form — wraps an AppBuilder directly:
51
- * ```ts
52
- * createHandler(app, { basePath: '/api' });
53
- * ```
54
- *
55
- * Config form — full-stack with lazy init, SSR, and security headers:
56
- * ```ts
57
- * createHandler({
58
- * app: (env) => createServer({ entities, db: createDb({ d1: env.DB }) }),
59
- * basePath: '/api',
60
- * ssr: (req) => renderToString(new URL(req.url).pathname),
61
- * securityHeaders: true,
62
- * });
63
- * ```
64
- */
65
- /** Worker module shape returned by createHandler. */
66
- interface CloudflareWorkerModule {
67
- fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response>;
68
- }
69
- declare function createHandler(appOrConfig: AppBuilder | CloudflareHandlerConfig, options?: CloudflareHandlerOptions): CloudflareWorkerModule;
70
- declare function generateHTMLTemplate(clientScript: string, title: string): string;
71
- export { generateHTMLTemplate, createHandler, SSRModuleConfig, CloudflareWorkerModule, CloudflareHandlerOptions, CloudflareHandlerConfig };
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,152 +1,2 @@
1
-
2
- // src/handler.ts
3
- var SECURITY_HEADERS = {
4
- "X-Content-Type-Options": "nosniff",
5
- "X-Frame-Options": "DENY",
6
- "X-XSS-Protection": "1; mode=block",
7
- "Referrer-Policy": "strict-origin-when-cross-origin",
8
- "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
9
- };
10
- function withSecurityHeaders(response) {
11
- const headers = new Headers(response.headers);
12
- for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
13
- headers.set(key, value);
14
- }
15
- return new Response(response.body, {
16
- status: response.status,
17
- statusText: response.statusText,
18
- headers
19
- });
20
- }
21
- function stripBasePath(request, basePath) {
22
- const url = new URL(request.url);
23
- if (url.pathname.startsWith(basePath)) {
24
- url.pathname = url.pathname.slice(basePath.length) || "/";
25
- return new Request(url.toString(), request);
26
- }
27
- return request;
28
- }
29
- function createHandler(appOrConfig, options) {
30
- if ("app" in appOrConfig && typeof appOrConfig.app === "function") {
31
- return createFullStackHandler(appOrConfig);
32
- }
33
- return createSimpleHandler(appOrConfig, options);
34
- }
35
- function createSimpleHandler(app, options) {
36
- const handler = app.handler;
37
- return {
38
- async fetch(request, _env, _ctx) {
39
- if (options?.basePath) {
40
- request = stripBasePath(request, options.basePath);
41
- }
42
- try {
43
- return await handler(request);
44
- } catch (error) {
45
- console.error("Unhandled error in worker:", error);
46
- return new Response("Internal Server Error", { status: 500 });
47
- }
48
- }
49
- };
50
- }
51
- function generateHTMLTemplate(clientScript, title) {
52
- return `<!doctype html>
53
- <html lang="en">
54
- <head>
55
- <meta charset="UTF-8">
56
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
57
- <title>${title}</title>
58
- </head>
59
- <body>
60
- <div id="app"><!--ssr-outlet--></div>
61
- <script type="module" src="${clientScript}"></script>
62
- </body>
63
- </html>`;
64
- }
65
- function isSSRModuleConfig(ssr) {
66
- return typeof ssr === "object" && "module" in ssr;
67
- }
68
- function createFullStackHandler(config) {
69
- const { basePath, ssr, securityHeaders } = config;
70
- let cachedApp = null;
71
- let ssrHandler = null;
72
- let ssrResolved = false;
73
- function getApp(env) {
74
- if (!cachedApp) {
75
- cachedApp = config.app(env);
76
- }
77
- return cachedApp;
78
- }
79
- async function resolveSSR() {
80
- if (ssrResolved)
81
- return;
82
- ssrResolved = true;
83
- if (!ssr)
84
- return;
85
- if (isSSRModuleConfig(ssr)) {
86
- const { createSSRHandler } = await import("@vertz/ui-server/ssr");
87
- const {
88
- module,
89
- clientScript = "/assets/entry-client.js",
90
- title = "Vertz App",
91
- ssrTimeout = 5000
92
- } = ssr;
93
- ssrHandler = createSSRHandler({
94
- module,
95
- template: generateHTMLTemplate(clientScript, title),
96
- ssrTimeout
97
- });
98
- } else {
99
- ssrHandler = ssr;
100
- }
101
- }
102
- function applyHeaders(response) {
103
- return securityHeaders ? withSecurityHeaders(response) : response;
104
- }
105
- return {
106
- async fetch(request, env, _ctx) {
107
- await resolveSSR();
108
- const url = new URL(request.url);
109
- if (url.pathname.startsWith(basePath)) {
110
- try {
111
- const app = getApp(env);
112
- const response = await app.handler(request);
113
- return applyHeaders(response);
114
- } catch (error) {
115
- console.error("Unhandled error in worker:", error);
116
- return applyHeaders(new Response("Internal Server Error", { status: 500 }));
117
- }
118
- }
119
- if (ssrHandler) {
120
- const app = getApp(env);
121
- const origin = url.origin;
122
- const originalFetch = globalThis.fetch;
123
- globalThis.fetch = (input, init) => {
124
- const rawUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
125
- const isRelative = rawUrl.startsWith("/");
126
- const pathname = isRelative ? rawUrl.split("?")[0] : new URL(rawUrl).pathname;
127
- const isLocal = isRelative || new URL(rawUrl).origin === origin;
128
- if (isLocal && pathname.startsWith(basePath)) {
129
- const absoluteUrl = isRelative ? `${origin}${rawUrl}` : rawUrl;
130
- const req = new Request(absoluteUrl, init);
131
- return app.handler(req);
132
- }
133
- return originalFetch(input, init);
134
- };
135
- try {
136
- const response = await ssrHandler(request);
137
- return applyHeaders(response);
138
- } catch (error) {
139
- console.error("Unhandled error in worker:", error);
140
- return applyHeaders(new Response("Internal Server Error", { status: 500 }));
141
- } finally {
142
- globalThis.fetch = originalFetch;
143
- }
144
- }
145
- return applyHeaders(new Response("Not Found", { status: 404 }));
146
- }
147
- };
148
- }
149
- export {
150
- generateHTMLTemplate,
151
- createHandler
152
- };
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.1",
3
+ "version": "0.2.4",
4
4
  "description": "Cloudflare Workers adapter for vertz",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,22 +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": "0.2.1"
25
+ "@vertz/core": "0.2.2"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@cloudflare/workers-types": "^4.20260305.0",
29
- "@vertz/ui-server": "0.2.1",
29
+ "@vertz/ui-server": "0.2.2",
30
30
  "@vitest/coverage-v8": "^4.0.18",
31
- "bunup": "latest",
32
31
  "typescript": "^5.7.3"
33
32
  },
34
33
  "peerDependencies": {
35
- "@vertz/ui-server": "0.2.1"
34
+ "@vertz/ui-server": "0.2.2"
36
35
  },
37
36
  "peerDependenciesMeta": {
38
37
  "@vertz/ui-server": {
package/src/handler.ts CHANGED
@@ -59,20 +59,36 @@ export interface CloudflareHandlerConfig {
59
59
  // Security headers
60
60
  // ---------------------------------------------------------------------------
61
61
 
62
- const SECURITY_HEADERS: Record<string, string> = {
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> = {
63
75
  'X-Content-Type-Options': 'nosniff',
64
76
  'X-Frame-Options': 'DENY',
65
77
  'X-XSS-Protection': '1; mode=block',
66
78
  'Referrer-Policy': 'strict-origin-when-cross-origin',
67
- 'Content-Security-Policy':
68
- "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;",
79
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
69
80
  };
70
81
 
71
- function withSecurityHeaders(response: Response): Response {
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 {
72
87
  const headers = new Headers(response.headers);
73
- for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
88
+ for (const [key, value] of Object.entries(STATIC_SECURITY_HEADERS)) {
74
89
  headers.set(key, value);
75
90
  }
91
+ headers.set('Content-Security-Policy', buildCSPHeader(nonce));
76
92
  return new Response(response.body, {
77
93
  status: response.status,
78
94
  statusText: response.statusText,
@@ -162,7 +178,8 @@ function createSimpleHandler(
162
178
  // HTML template generation
163
179
  // ---------------------------------------------------------------------------
164
180
 
165
- export function generateHTMLTemplate(clientScript: string, title: string): string {
181
+ export function generateHTMLTemplate(clientScript: string, title: string, nonce?: string): string {
182
+ const nonceAttr = nonce != null ? ` nonce="${nonce}"` : '';
166
183
  return `<!doctype html>
167
184
  <html lang="en">
168
185
  <head>
@@ -172,7 +189,7 @@ export function generateHTMLTemplate(clientScript: string, title: string): strin
172
189
  </head>
173
190
  <body>
174
191
  <div id="app"><!--ssr-outlet--></div>
175
- <script type="module" src="${clientScript}"></script>
192
+ <script type="module" src="${clientScript}"${nonceAttr}></script>
176
193
  </body>
177
194
  </html>`;
178
195
  }
@@ -190,7 +207,11 @@ function isSSRModuleConfig(
190
207
  function createFullStackHandler(config: CloudflareHandlerConfig): CloudflareWorkerModule {
191
208
  const { basePath, ssr, securityHeaders } = config;
192
209
  let cachedApp: AppBuilder | null = null;
193
- let ssrHandler: ((request: Request) => Promise<Response>) | 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;
194
215
  let ssrResolved = false;
195
216
 
196
217
  function getApp(env: unknown): AppBuilder {
@@ -214,24 +235,29 @@ function createFullStackHandler(config: CloudflareHandlerConfig): CloudflareWork
214
235
  title = 'Vertz App',
215
236
  ssrTimeout = 5000,
216
237
  } = ssr;
217
- ssrHandler = createSSRHandler({
218
- module,
219
- template: generateHTMLTemplate(clientScript, title),
220
- ssrTimeout,
221
- });
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
+ });
222
246
  } else {
223
- ssrHandler = ssr;
247
+ // Custom callback — wrap it in a factory that ignores the nonce
248
+ ssrHandlerFactory = () => ssr;
224
249
  }
225
250
  }
226
251
 
227
- function applyHeaders(response: Response): Response {
228
- return securityHeaders ? withSecurityHeaders(response) : response;
252
+ function applyHeaders(response: Response, nonce: string): Response {
253
+ return securityHeaders ? withSecurityHeaders(response, nonce) : response;
229
254
  }
230
255
 
231
256
  return {
232
257
  async fetch(request: Request, env: unknown, _ctx: ExecutionContext): Promise<Response> {
233
258
  await resolveSSR();
234
259
  const url = new URL(request.url);
260
+ const nonce = generateNonce();
235
261
 
236
262
  // Route splitting: basePath/* → API handler (no URL rewriting — the
237
263
  // app's own basePath/apiPrefix handles prefix matching internally)
@@ -239,16 +265,17 @@ function createFullStackHandler(config: CloudflareHandlerConfig): CloudflareWork
239
265
  try {
240
266
  const app = getApp(env);
241
267
  const response = await app.handler(request);
242
- return applyHeaders(response);
268
+ return applyHeaders(response, nonce);
243
269
  } catch (error) {
244
270
  console.error('Unhandled error in worker:', error);
245
- return applyHeaders(new Response('Internal Server Error', { status: 500 }));
271
+ return applyHeaders(new Response('Internal Server Error', { status: 500 }), nonce);
246
272
  }
247
273
  }
248
274
 
249
275
  // Non-API routes → SSR or 404
250
- if (ssrHandler) {
276
+ if (ssrHandlerFactory) {
251
277
  const app = getApp(env);
278
+ const ssrHandler = ssrHandlerFactory(nonce);
252
279
  const origin = url.origin;
253
280
  // Patch fetch during SSR so API requests (e.g. query() calling
254
281
  // fetch('/api/todos')) are routed through the in-memory app handler
@@ -257,15 +284,9 @@ function createFullStackHandler(config: CloudflareHandlerConfig): CloudflareWork
257
284
  globalThis.fetch = (input, init) => {
258
285
  // Determine the pathname from the input (string, URL, or Request)
259
286
  const rawUrl =
260
- typeof input === 'string'
261
- ? input
262
- : input instanceof URL
263
- ? input.href
264
- : input.url;
287
+ typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
265
288
  const isRelative = rawUrl.startsWith('/');
266
- const pathname = isRelative
267
- ? rawUrl.split('?')[0]
268
- : new URL(rawUrl).pathname;
289
+ const pathname = isRelative ? rawUrl.split('?')[0] : new URL(rawUrl).pathname;
269
290
  const isLocal = isRelative || new URL(rawUrl).origin === origin;
270
291
 
271
292
  if (isLocal && pathname.startsWith(basePath)) {
@@ -278,16 +299,16 @@ function createFullStackHandler(config: CloudflareHandlerConfig): CloudflareWork
278
299
  };
279
300
  try {
280
301
  const response = await ssrHandler(request);
281
- return applyHeaders(response);
302
+ return applyHeaders(response, nonce);
282
303
  } catch (error) {
283
304
  console.error('Unhandled error in worker:', error);
284
- return applyHeaders(new Response('Internal Server Error', { status: 500 }));
305
+ return applyHeaders(new Response('Internal Server Error', { status: 500 }), nonce);
285
306
  } finally {
286
307
  globalThis.fetch = originalFetch;
287
308
  }
288
309
  }
289
310
 
290
- return applyHeaders(new Response('Not Found', { status: 404 }));
311
+ return applyHeaders(new Response('Not Found', { status: 404 }), nonce);
291
312
  },
292
313
  };
293
314
  }
package/src/index.ts CHANGED
@@ -4,4 +4,4 @@ export type {
4
4
  CloudflareWorkerModule,
5
5
  SSRModuleConfig,
6
6
  } from './handler.js';
7
- export { createHandler, generateHTMLTemplate } from './handler.js';
7
+ export { createHandler, generateHTMLTemplate, generateNonce } from './handler.js';
@@ -1,6 +1,6 @@
1
1
  import type { AppBuilder } from '@vertz/core';
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
- import { createHandler, generateHTMLTemplate } from '../src/handler.js';
3
+ import { createHandler, generateHTMLTemplate, generateNonce } from '../src/handler.js';
4
4
 
5
5
  function mockApp(handler?: (...args: unknown[]) => Promise<Response>): AppBuilder {
6
6
  return {
@@ -509,3 +509,167 @@ describe('createHandler (SSR module config)', () => {
509
509
  expect(await response.text()).toBe('{"items":[]}');
510
510
  });
511
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
+ });
@@ -1,11 +0,0 @@
1
- $ vitest run
2
-
3
-  RUN  v4.0.18 /Users/viniciusdacal/vertz-dev/vertz/.claude/worktrees/poc-ssr-hmr/packages/cloudflare
4
-
5
- ✓ tests/handler.test.ts (23 tests) 72ms
6
-
7
-  Test Files  1 passed (1)
8
-  Tests  23 passed (23)
9
-  Start at  23:53:54
10
-  Duration  397ms (transform 146ms, setup 0ms, import 186ms, tests 72ms, environment 0ms)
11
-
@@ -1 +0,0 @@
1
- $ tsc --noEmit
package/bunup.config.ts DELETED
@@ -1,20 +0,0 @@
1
- import { defineConfig } from 'bunup';
2
-
3
- export default defineConfig({
4
- entry: ['src/index.ts'],
5
- format: ['esm'],
6
- dts: { inferTypes: true },
7
- clean: true,
8
- external: ['@vertz/core', '@vertz/ui-server'],
9
- // onSuccess strips the CJS createRequire shim that Bun.build injects.
10
- // The shim references import.meta.url which is undefined on Cloudflare
11
- // Workers, and __require is never actually called in the output.
12
- onSuccess: async () => {
13
- const path = 'dist/index.js';
14
- const code = await Bun.file(path).text();
15
- const cleaned = code
16
- .replace(/import \{ createRequire \} from "node:module";\n?/, '')
17
- .replace(/var __require = \/\* @__PURE__ \*\/ createRequire\(import\.meta\.url\);\n?/, '');
18
- await Bun.write(path, cleaned);
19
- },
20
- });