@vertz/cloudflare 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,16 @@
1
1
  $ bunup
2
- i Using bunup v0.16.26 and bun v1.3.9
3
- i Build started
2
+ i Using bunup v0.16.31 and bun v1.3.9
3
+ i Using cloudflare/bunup.config.ts
4
+ i Build started
4
5
 
5
- src/index.ts
6
+ src/index.ts
6
7
 
7
-  Output Raw Gzip
8
+ Output Raw Gzip
8
9
 
9
- dist/index.js 676 B 349 B
10
- dist/index.d.ts 327 B 225 B
10
+ dist/index.js 4.80 KB 1.56 KB
11
+ dist/index.d.ts 2.56 KB 1.12 KB
11
12
 
12
- 2 files  1003 B 574 B
13
+ 2 files 7.36 KB 2.67 KB
13
14
 
14
15
 
15
- ✓ Build completed in 114ms
16
+ ✓ Build completed in 341ms
@@ -0,0 +1,11 @@
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
+
@@ -0,0 +1 @@
1
+ $ tsc --noEmit
@@ -0,0 +1,20 @@
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
+ });
package/dist/index.d.ts CHANGED
@@ -1,8 +1,71 @@
1
1
  import { AppBuilder } from "@vertz/core";
2
+ import { SSRModule } from "@vertz/ui-server/ssr";
2
3
  interface CloudflareHandlerOptions {
3
4
  basePath?: string;
4
5
  }
5
- declare function createHandler(app: AppBuilder, options?: CloudflareHandlerOptions): {
6
- fetch(request: Request, _env: unknown, _ctx: ExecutionContext): Promise<Response>;
7
- };
8
- export { createHandler, CloudflareHandlerOptions };
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 };
package/dist/index.js CHANGED
@@ -1,14 +1,43 @@
1
+
1
2
  // src/handler.ts
2
- function createHandler(app, options) {
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) {
3
36
  const handler = app.handler;
4
37
  return {
5
38
  async fetch(request, _env, _ctx) {
6
39
  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
- }
40
+ request = stripBasePath(request, options.basePath);
12
41
  }
13
42
  try {
14
43
  return await handler(request);
@@ -19,6 +48,105 @@ function createHandler(app, options) {
19
48
  }
20
49
  };
21
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
+ }
22
149
  export {
150
+ generateHTMLTemplate,
23
151
  createHandler
24
152
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertz/cloudflare",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Cloudflare Workers adapter for vertz",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,15 +22,17 @@
22
22
  "test": "vitest run"
23
23
  },
24
24
  "dependencies": {
25
- "@vertz/core": "workspace:*"
25
+ "@vertz/core": "0.2.1"
26
26
  },
27
27
  "devDependencies": {
28
- "@cloudflare/workers-types": "^4.20250214.0",
28
+ "@cloudflare/workers-types": "^4.20260305.0",
29
+ "@vertz/ui-server": "0.2.1",
30
+ "@vitest/coverage-v8": "^4.0.18",
29
31
  "bunup": "latest",
30
32
  "typescript": "^5.7.3"
31
33
  },
32
34
  "peerDependencies": {
33
- "@vertz/ui-server": "workspace:*"
35
+ "@vertz/ui-server": "0.2.1"
34
36
  },
35
37
  "peerDependenciesMeta": {
36
38
  "@vertz/ui-server": {
package/src/handler.ts CHANGED
@@ -1,21 +1,152 @@
1
1
  import type { AppBuilder } from '@vertz/core';
2
+ import type { SSRModule } from '@vertz/ui-server/ssr';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Types
6
+ // ---------------------------------------------------------------------------
2
7
 
3
8
  export interface CloudflareHandlerOptions {
4
9
  basePath?: string;
5
10
  }
6
11
 
7
- export function createHandler(app: AppBuilder, options?: CloudflareHandlerOptions) {
12
+ /**
13
+ * SSR module configuration for zero-boilerplate server-side rendering.
14
+ *
15
+ * Pass the app module directly and the handler generates the HTML template,
16
+ * wires up createSSRHandler, and handles the full SSR pipeline.
17
+ */
18
+ export interface SSRModuleConfig {
19
+ /** App module exporting App, theme?, styles?, getInjectedCSS? */
20
+ module: SSRModule;
21
+ /** Client-side entry script path. Default: '/assets/entry-client.js' */
22
+ clientScript?: string;
23
+ /** HTML document title. Default: 'Vertz App' */
24
+ title?: string;
25
+ /** SSR query timeout in ms. Default: 5000 (generous for D1 cold starts). */
26
+ ssrTimeout?: number;
27
+ }
28
+
29
+ /**
30
+ * Full-stack configuration for createHandler.
31
+ *
32
+ * Supports lazy app initialization (for D1 bindings), SSR fallback,
33
+ * and automatic security headers.
34
+ */
35
+ export interface CloudflareHandlerConfig {
36
+ /**
37
+ * Factory that creates the AppBuilder. Receives the Worker env bindings.
38
+ * Called once on first request, then cached.
39
+ */
40
+ app: (env: unknown) => AppBuilder;
41
+
42
+ /** API path prefix. Requests matching this prefix go to the app handler. */
43
+ basePath: string;
44
+
45
+ /**
46
+ * SSR configuration for non-API routes.
47
+ *
48
+ * - `SSRModuleConfig` — zero-boilerplate: pass the app module directly
49
+ * - `(request: Request) => Promise<Response>` — custom SSR callback
50
+ * - `undefined` — non-API requests return 404
51
+ */
52
+ ssr?: SSRModuleConfig | ((request: Request) => Promise<Response>);
53
+
54
+ /** When true, adds standard security headers to all responses. */
55
+ securityHeaders?: boolean;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Security headers
60
+ // ---------------------------------------------------------------------------
61
+
62
+ const SECURITY_HEADERS: Record<string, string> = {
63
+ 'X-Content-Type-Options': 'nosniff',
64
+ 'X-Frame-Options': 'DENY',
65
+ 'X-XSS-Protection': '1; mode=block',
66
+ '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:;",
69
+ };
70
+
71
+ function withSecurityHeaders(response: Response): Response {
72
+ const headers = new Headers(response.headers);
73
+ for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
74
+ headers.set(key, value);
75
+ }
76
+ return new Response(response.body, {
77
+ status: response.status,
78
+ statusText: response.statusText,
79
+ headers,
80
+ });
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Helpers
85
+ // ---------------------------------------------------------------------------
86
+
87
+ function stripBasePath(request: Request, basePath: string): Request {
88
+ const url = new URL(request.url);
89
+ if (url.pathname.startsWith(basePath)) {
90
+ url.pathname = url.pathname.slice(basePath.length) || '/';
91
+ return new Request(url.toString(), request);
92
+ }
93
+ return request;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // createHandler overloads
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Create a Cloudflare Worker handler from a Vertz app.
102
+ *
103
+ * Simple form — wraps an AppBuilder directly:
104
+ * ```ts
105
+ * export default createHandler(app, { basePath: '/api' });
106
+ * ```
107
+ *
108
+ * Config form — full-stack with lazy init, SSR, and security headers:
109
+ * ```ts
110
+ * export default createHandler({
111
+ * app: (env) => createServer({ entities, db: createDb({ d1: env.DB }) }),
112
+ * basePath: '/api',
113
+ * ssr: (req) => renderToString(new URL(req.url).pathname),
114
+ * securityHeaders: true,
115
+ * });
116
+ * ```
117
+ */
118
+ /** Worker module shape returned by createHandler. */
119
+ export interface CloudflareWorkerModule {
120
+ fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response>;
121
+ }
122
+
123
+ export function createHandler(
124
+ appOrConfig: AppBuilder | CloudflareHandlerConfig,
125
+ options?: CloudflareHandlerOptions,
126
+ ): CloudflareWorkerModule {
127
+ // Config object form
128
+ if ('app' in appOrConfig && typeof appOrConfig.app === 'function') {
129
+ return createFullStackHandler(appOrConfig);
130
+ }
131
+
132
+ // Simple AppBuilder form (backward compat)
133
+ return createSimpleHandler(appOrConfig as AppBuilder, options);
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Simple handler (backward compat)
138
+ // ---------------------------------------------------------------------------
139
+
140
+ function createSimpleHandler(
141
+ app: AppBuilder,
142
+ options?: CloudflareHandlerOptions,
143
+ ): CloudflareWorkerModule {
8
144
  const handler = app.handler;
9
145
 
10
146
  return {
11
147
  async fetch(request: Request, _env: unknown, _ctx: ExecutionContext): Promise<Response> {
12
- // If basePath, strip it from the URL before routing
13
148
  if (options?.basePath) {
14
- const url = new URL(request.url);
15
- if (url.pathname.startsWith(options.basePath)) {
16
- url.pathname = url.pathname.slice(options.basePath.length) || '/';
17
- request = new Request(url.toString(), request);
18
- }
149
+ request = stripBasePath(request, options.basePath);
19
150
  }
20
151
  try {
21
152
  return await handler(request);
@@ -26,3 +157,137 @@ export function createHandler(app: AppBuilder, options?: CloudflareHandlerOption
26
157
  },
27
158
  };
28
159
  }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // HTML template generation
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export function generateHTMLTemplate(clientScript: string, title: string): string {
166
+ return `<!doctype html>
167
+ <html lang="en">
168
+ <head>
169
+ <meta charset="UTF-8">
170
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
171
+ <title>${title}</title>
172
+ </head>
173
+ <body>
174
+ <div id="app"><!--ssr-outlet--></div>
175
+ <script type="module" src="${clientScript}"></script>
176
+ </body>
177
+ </html>`;
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Full-stack handler
182
+ // ---------------------------------------------------------------------------
183
+
184
+ function isSSRModuleConfig(
185
+ ssr: SSRModuleConfig | ((request: Request) => Promise<Response>),
186
+ ): ssr is SSRModuleConfig {
187
+ return typeof ssr === 'object' && 'module' in ssr;
188
+ }
189
+
190
+ function createFullStackHandler(config: CloudflareHandlerConfig): CloudflareWorkerModule {
191
+ const { basePath, ssr, securityHeaders } = config;
192
+ let cachedApp: AppBuilder | null = null;
193
+ let ssrHandler: ((request: Request) => Promise<Response>) | null = null;
194
+ let ssrResolved = false;
195
+
196
+ function getApp(env: unknown): AppBuilder {
197
+ if (!cachedApp) {
198
+ cachedApp = config.app(env);
199
+ }
200
+ return cachedApp;
201
+ }
202
+
203
+ async function resolveSSR(): Promise<void> {
204
+ if (ssrResolved) return;
205
+ ssrResolved = true;
206
+
207
+ if (!ssr) return;
208
+
209
+ if (isSSRModuleConfig(ssr)) {
210
+ const { createSSRHandler } = await import('@vertz/ui-server/ssr');
211
+ const {
212
+ module,
213
+ clientScript = '/assets/entry-client.js',
214
+ title = 'Vertz App',
215
+ ssrTimeout = 5000,
216
+ } = ssr;
217
+ ssrHandler = createSSRHandler({
218
+ module,
219
+ template: generateHTMLTemplate(clientScript, title),
220
+ ssrTimeout,
221
+ });
222
+ } else {
223
+ ssrHandler = ssr;
224
+ }
225
+ }
226
+
227
+ function applyHeaders(response: Response): Response {
228
+ return securityHeaders ? withSecurityHeaders(response) : response;
229
+ }
230
+
231
+ return {
232
+ async fetch(request: Request, env: unknown, _ctx: ExecutionContext): Promise<Response> {
233
+ await resolveSSR();
234
+ const url = new URL(request.url);
235
+
236
+ // Route splitting: basePath/* → API handler (no URL rewriting — the
237
+ // app's own basePath/apiPrefix handles prefix matching internally)
238
+ if (url.pathname.startsWith(basePath)) {
239
+ try {
240
+ const app = getApp(env);
241
+ const response = await app.handler(request);
242
+ return applyHeaders(response);
243
+ } catch (error) {
244
+ console.error('Unhandled error in worker:', error);
245
+ return applyHeaders(new Response('Internal Server Error', { status: 500 }));
246
+ }
247
+ }
248
+
249
+ // Non-API routes → SSR or 404
250
+ if (ssrHandler) {
251
+ const app = getApp(env);
252
+ const origin = url.origin;
253
+ // Patch fetch during SSR so API requests (e.g. query() calling
254
+ // fetch('/api/todos')) are routed through the in-memory app handler
255
+ // instead of attempting a network self-fetch (which fails on Workers).
256
+ const originalFetch = globalThis.fetch;
257
+ globalThis.fetch = (input, init) => {
258
+ // Determine the pathname from the input (string, URL, or Request)
259
+ const rawUrl =
260
+ typeof input === 'string'
261
+ ? input
262
+ : input instanceof URL
263
+ ? input.href
264
+ : input.url;
265
+ const isRelative = rawUrl.startsWith('/');
266
+ const pathname = isRelative
267
+ ? rawUrl.split('?')[0]
268
+ : new URL(rawUrl).pathname;
269
+ const isLocal = isRelative || new URL(rawUrl).origin === origin;
270
+
271
+ if (isLocal && pathname.startsWith(basePath)) {
272
+ // Build an absolute URL so Request() doesn't reject relative URLs
273
+ const absoluteUrl = isRelative ? `${origin}${rawUrl}` : rawUrl;
274
+ const req = new Request(absoluteUrl, init);
275
+ return app.handler(req);
276
+ }
277
+ return originalFetch(input, init);
278
+ };
279
+ try {
280
+ const response = await ssrHandler(request);
281
+ return applyHeaders(response);
282
+ } catch (error) {
283
+ console.error('Unhandled error in worker:', error);
284
+ return applyHeaders(new Response('Internal Server Error', { status: 500 }));
285
+ } finally {
286
+ globalThis.fetch = originalFetch;
287
+ }
288
+ }
289
+
290
+ return applyHeaders(new Response('Not Found', { status: 404 }));
291
+ },
292
+ };
293
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,7 @@
1
- export type { CloudflareHandlerOptions } from './handler.js';
2
- export { createHandler } from './handler.js';
1
+ export type {
2
+ CloudflareHandlerConfig,
3
+ CloudflareHandlerOptions,
4
+ CloudflareWorkerModule,
5
+ SSRModuleConfig,
6
+ } from './handler.js';
7
+ export { createHandler, generateHTMLTemplate } from './handler.js';
@@ -1,6 +1,12 @@
1
1
  import type { AppBuilder } from '@vertz/core';
2
- import { describe, expect, it, vi } from 'vitest';
3
- import { createHandler } from '../src/handler.js';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { createHandler, generateHTMLTemplate } 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,330 @@ 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
+ });
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
  });