@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.
- package/.turbo/turbo-build.log +9 -8
- package/.turbo/turbo-test.log +11 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/bunup.config.ts +20 -0
- package/dist/index.d.ts +67 -4
- package/dist/index.js +134 -6
- package/package.json +6 -4
- package/src/handler.ts +272 -7
- package/src/index.ts +7 -2
- package/tests/handler.test.ts +335 -2
- package/vitest.config.ts +1 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
$ bunup
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
6
|
+
src/index.ts
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Output Raw Gzip
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
dist/index.js 4.80 KB 1.56 KB
|
|
11
|
+
dist/index.d.ts 2.56 KB 1.12 KB
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
2 files 7.36 KB 2.67 KB
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
✓ Build completed in 341ms
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
$ vitest run
|
|
2
|
+
|
|
3
|
+
[1m[46m RUN [49m[22m [36mv4.0.18 [39m[90m/Users/viniciusdacal/vertz-dev/vertz/.claude/worktrees/poc-ssr-hmr/packages/cloudflare[39m
|
|
4
|
+
|
|
5
|
+
[32m✓[39m tests/handler.test.ts [2m([22m[2m23 tests[22m[2m)[22m[32m 72[2mms[22m[39m
|
|
6
|
+
|
|
7
|
+
[2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m
|
|
8
|
+
[2m Tests [22m [1m[32m23 passed[39m[22m[90m (23)[39m
|
|
9
|
+
[2m Start at [22m 23:53:54
|
|
10
|
+
[2m Duration [22m 397ms[2m (transform 146ms, setup 0ms, import 186ms, tests 72ms, environment 0ms)[22m
|
|
11
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
$ tsc --noEmit
|
package/bunup.config.ts
ADDED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
25
|
+
"@vertz/core": "0.2.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@cloudflare/workers-types": "^4.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
-
|
|
1
|
+
export type {
|
|
2
|
+
CloudflareHandlerConfig,
|
|
3
|
+
CloudflareHandlerOptions,
|
|
4
|
+
CloudflareWorkerModule,
|
|
5
|
+
SSRModuleConfig,
|
|
6
|
+
} from './handler.js';
|
|
7
|
+
export { createHandler, generateHTMLTemplate } 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 } 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
|
});
|