orez 0.1.36 → 0.1.38
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/dist/cli-entry.js +0 -0
- package/dist/cli.js +7 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -11
- package/dist/index.js.map +1 -1
- package/dist/pg-proxy.d.ts.map +1 -1
- package/dist/pg-proxy.js +8 -4
- package/dist/pg-proxy.js.map +1 -1
- package/dist/pglite-manager.d.ts +12 -0
- package/dist/pglite-manager.d.ts.map +1 -1
- package/dist/pglite-manager.js +81 -0
- package/dist/pglite-manager.js.map +1 -1
- package/dist/recovery.js +2 -2
- package/dist/recovery.js.map +1 -1
- package/dist/replication/change-tracker.js +9 -9
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts +12 -0
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +34 -6
- package/dist/replication/handler.js.map +1 -1
- package/dist/worker/browser-build-config.d.ts +59 -0
- package/dist/worker/browser-build-config.d.ts.map +1 -0
- package/dist/worker/browser-build-config.js +101 -0
- package/dist/worker/browser-build-config.js.map +1 -0
- package/dist/worker/browser-embed.d.ts +58 -0
- package/dist/worker/browser-embed.d.ts.map +1 -0
- package/dist/worker/browser-embed.js +195 -0
- package/dist/worker/browser-embed.js.map +1 -0
- package/dist/worker/cf-patches.d.ts +20 -0
- package/dist/worker/cf-patches.d.ts.map +1 -0
- package/dist/worker/cf-patches.js +94 -0
- package/dist/worker/cf-patches.js.map +1 -0
- package/dist/worker/index.d.ts +12 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +105 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/shims/fastify.d.ts +80 -0
- package/dist/worker/shims/fastify.d.ts.map +1 -0
- package/dist/worker/shims/fastify.js +223 -0
- package/dist/worker/shims/fastify.js.map +1 -0
- package/dist/worker/shims/http-service.d.ts +104 -0
- package/dist/worker/shims/http-service.d.ts.map +1 -0
- package/dist/worker/shims/http-service.js +198 -0
- package/dist/worker/shims/http-service.js.map +1 -0
- package/dist/worker/shims/node-stub.d.ts +147 -0
- package/dist/worker/shims/node-stub.d.ts.map +1 -0
- package/dist/worker/shims/node-stub.js +204 -0
- package/dist/worker/shims/node-stub.js.map +1 -0
- package/dist/worker/shims/postgres.d.ts +115 -0
- package/dist/worker/shims/postgres.d.ts.map +1 -0
- package/dist/worker/shims/postgres.js +1181 -0
- package/dist/worker/shims/postgres.js.map +1 -0
- package/dist/worker/shims/sqlite-browser.d.ts +54 -0
- package/dist/worker/shims/sqlite-browser.d.ts.map +1 -0
- package/dist/worker/shims/sqlite-browser.js +144 -0
- package/dist/worker/shims/sqlite-browser.js.map +1 -0
- package/dist/worker/shims/sqlite.d.ts +126 -0
- package/dist/worker/shims/sqlite.d.ts.map +1 -0
- package/dist/worker/shims/sqlite.js +599 -0
- package/dist/worker/shims/sqlite.js.map +1 -0
- package/dist/worker/shims/stream-browser.d.ts +9 -0
- package/dist/worker/shims/stream-browser.d.ts.map +1 -0
- package/dist/worker/shims/stream-browser.js +13 -0
- package/dist/worker/shims/stream-browser.js.map +1 -0
- package/dist/worker/shims/ws-browser.d.ts +50 -0
- package/dist/worker/shims/ws-browser.d.ts.map +1 -0
- package/dist/worker/shims/ws-browser.js +105 -0
- package/dist/worker/shims/ws-browser.js.map +1 -0
- package/dist/worker/shims/ws.d.ts +62 -0
- package/dist/worker/shims/ws.d.ts.map +1 -0
- package/dist/worker/shims/ws.js +310 -0
- package/dist/worker/shims/ws.js.map +1 -0
- package/dist/worker/types.d.ts +57 -0
- package/dist/worker/types.d.ts.map +1 -0
- package/dist/worker/types.js +9 -0
- package/dist/worker/types.js.map +1 -0
- package/dist/worker/zero-cache-embed-cf.d.ts +63 -0
- package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -0
- package/dist/worker/zero-cache-embed-cf.js +268 -0
- package/dist/worker/zero-cache-embed-cf.js.map +1 -0
- package/dist/worker/zero-cache-embed.d.ts +66 -0
- package/dist/worker/zero-cache-embed.d.ts.map +1 -0
- package/dist/worker/zero-cache-embed.js +200 -0
- package/dist/worker/zero-cache-embed.js.map +1 -0
- package/package.json +62 -3
- package/src/cli-entry.ts +0 -0
- package/src/cli.ts +8 -1
- package/src/config.ts +2 -0
- package/src/index.ts +15 -10
- package/src/integration/integration.test.ts +1 -1
- package/src/integration/restore-live-stress.test.ts +2 -2
- package/src/pg-proxy.ts +9 -4
- package/src/pglite-manager.ts +111 -0
- package/src/recovery.ts +2 -2
- package/src/replication/change-tracker.test.ts +1 -1
- package/src/replication/change-tracker.ts +9 -9
- package/src/replication/handler.test.ts +37 -0
- package/src/replication/handler.ts +46 -6
- package/src/wasm-sqlite.test.ts +2 -1
- package/src/worker/browser-build-config.test.ts +59 -0
- package/src/worker/browser-build-config.ts +105 -0
- package/src/worker/browser-embed.ts +306 -0
- package/src/worker/cf-patches.ts +114 -0
- package/src/worker/embed-integration.test.ts +321 -0
- package/src/worker/index.ts +138 -0
- package/src/worker/shims/fastify.test.ts +255 -0
- package/src/worker/shims/fastify.ts +292 -0
- package/src/worker/shims/http-service.test.ts +355 -0
- package/src/worker/shims/http-service.ts +293 -0
- package/src/worker/shims/node-stub.ts +223 -0
- package/src/worker/shims/postgres.test.ts +364 -0
- package/src/worker/shims/postgres.ts +1434 -0
- package/src/worker/shims/sqlite-browser.test.ts +233 -0
- package/src/worker/shims/sqlite-browser.ts +178 -0
- package/src/worker/shims/sqlite.test.ts +641 -0
- package/src/worker/shims/sqlite.ts +731 -0
- package/src/worker/shims/ws-browser.test.ts +184 -0
- package/src/worker/shims/ws-browser.ts +125 -0
- package/src/worker/shims/ws.test.ts +288 -0
- package/src/worker/shims/ws.ts +367 -0
- package/src/worker/types.ts +75 -0
- package/src/worker/worker-integration.test.ts +223 -0
- package/src/worker/worker.test.ts +136 -0
- package/src/worker/zero-cache-embed-cf.ts +367 -0
- package/src/worker/zero-cache-embed.ts +277 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fastify shim for cloudflare workers.
|
|
3
|
+
*
|
|
4
|
+
* minimal fastify replacement that captures route registrations and
|
|
5
|
+
* exposes them via inject() for request processing. zero-cache's
|
|
6
|
+
* HttpService creates a Fastify instance, registers routes, and calls
|
|
7
|
+
* listen(). on CF Workers we skip listen() and route DO fetch()
|
|
8
|
+
* through inject().
|
|
9
|
+
*
|
|
10
|
+
* supports { websocket: true } routes: when a handoff event arrives on
|
|
11
|
+
* the server, matches against websocket routes and calls the handler
|
|
12
|
+
* with the socket directly. this enables the serving-replicator's
|
|
13
|
+
* in-process WebSocket connection to the change-streamer.
|
|
14
|
+
*
|
|
15
|
+
* usage with bundler alias:
|
|
16
|
+
* alias: { 'fastify': './src/worker/shims/fastify.js' }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import EventEmitter from 'node:events'
|
|
20
|
+
|
|
21
|
+
import { WebSocket as WsShim, WebSocketServer as WsServerShim } from './ws.js'
|
|
22
|
+
|
|
23
|
+
// -- types matching fastify's minimal surface used by zero-cache --
|
|
24
|
+
|
|
25
|
+
interface FastifyRequest {
|
|
26
|
+
headers: Record<string, string | undefined>
|
|
27
|
+
url: string
|
|
28
|
+
method: string
|
|
29
|
+
body?: unknown
|
|
30
|
+
query?: Record<string, string>
|
|
31
|
+
params?: Record<string, string>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface FastifyReply {
|
|
35
|
+
code(statusCode: number): FastifyReply
|
|
36
|
+
header(name: string, value: string): FastifyReply
|
|
37
|
+
send(payload?: unknown): void
|
|
38
|
+
type(contentType: string): FastifyReply
|
|
39
|
+
status(statusCode: number): FastifyReply
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type RouteHandler = (
|
|
43
|
+
request: FastifyRequest,
|
|
44
|
+
reply: FastifyReply
|
|
45
|
+
) => unknown | Promise<unknown>
|
|
46
|
+
|
|
47
|
+
interface InjectOptions {
|
|
48
|
+
method: string
|
|
49
|
+
url: string
|
|
50
|
+
headers?: Record<string, string>
|
|
51
|
+
payload?: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface InjectResult {
|
|
55
|
+
statusCode: number
|
|
56
|
+
headers: Record<string, string>
|
|
57
|
+
body: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// -- fake http.Server replacement --
|
|
61
|
+
// uses EventEmitter with onMessageType for zero-cache's
|
|
62
|
+
// installWebSocketHandoff non-Server branch.
|
|
63
|
+
|
|
64
|
+
class FakeHttpServer extends EventEmitter {
|
|
65
|
+
#address = { address: '0.0.0.0', port: 0, family: 'IPv4' as const }
|
|
66
|
+
|
|
67
|
+
address() {
|
|
68
|
+
return this.#address
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** match the onMessageType pattern from zero-cache processes.js */
|
|
72
|
+
onMessageType(
|
|
73
|
+
type: string,
|
|
74
|
+
handler: (msg: unknown, sendHandle?: unknown) => void
|
|
75
|
+
): this {
|
|
76
|
+
this.on('message', (data: unknown, sendHandle?: unknown) => {
|
|
77
|
+
if (Array.isArray(data) && data.length === 2 && data[0] === type) {
|
|
78
|
+
handler(data[1], sendHandle)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
return this
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
listen() {
|
|
85
|
+
/* no-op on CF */
|
|
86
|
+
}
|
|
87
|
+
close() {
|
|
88
|
+
/* no-op on CF */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// use the real WebSocketServer from the WS shim — it wraps raw sockets
|
|
93
|
+
// in a proper WebSocket class with ping/pong/on/emit etc.
|
|
94
|
+
|
|
95
|
+
// -- route pattern matching --
|
|
96
|
+
// converts fastify route patterns like "/replication/:version/changes"
|
|
97
|
+
// to regex for matching incoming URLs
|
|
98
|
+
|
|
99
|
+
function patternToRegex(pattern: string): RegExp {
|
|
100
|
+
const escaped = pattern
|
|
101
|
+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
102
|
+
.replace(/:(\w+)/g, '(?<$1>[^/]+)')
|
|
103
|
+
return new RegExp(`^${escaped}$`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// -- fastify shim instance --
|
|
107
|
+
|
|
108
|
+
class FastifyShim {
|
|
109
|
+
server: FakeHttpServer
|
|
110
|
+
websocketServer: WsServerShim
|
|
111
|
+
#routes = new Map<string, RouteHandler>()
|
|
112
|
+
#wsRoutes: Array<{ pattern: RegExp; handler: (ws: unknown, req: any) => void }> = []
|
|
113
|
+
#readyResolvers: Array<() => void> = []
|
|
114
|
+
|
|
115
|
+
constructor() {
|
|
116
|
+
this.server = new FakeHttpServer()
|
|
117
|
+
this.websocketServer = new WsServerShim()
|
|
118
|
+
this.#installWsHandoffHandler()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// listen for in-process WebSocket handoff events on the server.
|
|
122
|
+
// when the WS shim creates an in-process connection, it emits a handoff
|
|
123
|
+
// event. we match the URL against registered { websocket: true } routes
|
|
124
|
+
// and call the handler with the socket.
|
|
125
|
+
#installWsHandoffHandler() {
|
|
126
|
+
this.server.onMessageType('handoff', (msg: any, socket?: any) => {
|
|
127
|
+
if (!socket || !msg?.message?.url) return
|
|
128
|
+
const url = msg.message.url
|
|
129
|
+
const parsedUrl = new URL(url, 'http://localhost')
|
|
130
|
+
const pathname = parsedUrl.pathname
|
|
131
|
+
|
|
132
|
+
for (const route of this.#wsRoutes) {
|
|
133
|
+
if (route.pattern.test(pathname)) {
|
|
134
|
+
const req = {
|
|
135
|
+
url,
|
|
136
|
+
headers: msg.message.headers || {},
|
|
137
|
+
method: msg.message.method || 'GET',
|
|
138
|
+
}
|
|
139
|
+
// wrap socket through handleUpgrade so it gets the full WS API
|
|
140
|
+
// (ping, on, once, terminate, etc.) needed by zero-cache's streamOut
|
|
141
|
+
this.websocketServer.handleUpgrade(
|
|
142
|
+
req,
|
|
143
|
+
socket,
|
|
144
|
+
Buffer.from(new Uint8Array(0)),
|
|
145
|
+
(ws: any) => {
|
|
146
|
+
route.handler(ws, req)
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// route registration — supports optional { websocket: true } option
|
|
156
|
+
get(path: string, optsOrHandler: any, handler?: any) {
|
|
157
|
+
if (typeof optsOrHandler === 'function') {
|
|
158
|
+
this.#routes.set(`GET:${path}`, optsOrHandler)
|
|
159
|
+
} else if (optsOrHandler?.websocket && handler) {
|
|
160
|
+
// websocket route — register for handoff matching
|
|
161
|
+
this.#wsRoutes.push({
|
|
162
|
+
pattern: patternToRegex(path),
|
|
163
|
+
handler,
|
|
164
|
+
})
|
|
165
|
+
} else if (handler) {
|
|
166
|
+
this.#routes.set(`GET:${path}`, handler)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
post(path: string, handler: RouteHandler) {
|
|
170
|
+
this.#routes.set(`POST:${path}`, handler)
|
|
171
|
+
}
|
|
172
|
+
put(path: string, handler: RouteHandler) {
|
|
173
|
+
this.#routes.set(`PUT:${path}`, handler)
|
|
174
|
+
}
|
|
175
|
+
delete(path: string, handler: RouteHandler) {
|
|
176
|
+
this.#routes.set(`DELETE:${path}`, handler)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// plugin registration (no-op — zero-cache registers @fastify/websocket here)
|
|
180
|
+
register(_plugin: unknown, _opts?: unknown): this {
|
|
181
|
+
return this
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// lifecycle
|
|
185
|
+
async ready(): Promise<void> {
|
|
186
|
+
for (const resolve of this.#readyResolvers) resolve()
|
|
187
|
+
this.#readyResolvers = []
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async listen(_opts?: { host?: string; port?: number }): Promise<string> {
|
|
191
|
+
await this.ready()
|
|
192
|
+
return '0.0.0.0:0'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async close(): Promise<void> {
|
|
196
|
+
// no-op on CF
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// inject — process a request through registered routes
|
|
200
|
+
async inject(opts: InjectOptions): Promise<InjectResult> {
|
|
201
|
+
const method = opts.method.toUpperCase()
|
|
202
|
+
const urlObj = new URL(opts.url, 'http://localhost')
|
|
203
|
+
const pathname = urlObj.pathname
|
|
204
|
+
|
|
205
|
+
// find matching route
|
|
206
|
+
const handler = this.#routes.get(`${method}:${pathname}`)
|
|
207
|
+
if (!handler) {
|
|
208
|
+
return { statusCode: 404, headers: {}, body: 'Not Found' }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// build fake request
|
|
212
|
+
const request: FastifyRequest = {
|
|
213
|
+
headers: opts.headers || {},
|
|
214
|
+
url: opts.url,
|
|
215
|
+
method,
|
|
216
|
+
body: opts.payload ? tryParseJson(opts.payload) : undefined,
|
|
217
|
+
query: Object.fromEntries(urlObj.searchParams),
|
|
218
|
+
params: {},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// build fake reply
|
|
222
|
+
let statusCode = 200
|
|
223
|
+
const headers: Record<string, string> = {}
|
|
224
|
+
let body = ''
|
|
225
|
+
let sent = false
|
|
226
|
+
|
|
227
|
+
const reply: FastifyReply = {
|
|
228
|
+
code(code: number) {
|
|
229
|
+
statusCode = code
|
|
230
|
+
return reply
|
|
231
|
+
},
|
|
232
|
+
status(code: number) {
|
|
233
|
+
statusCode = code
|
|
234
|
+
return reply
|
|
235
|
+
},
|
|
236
|
+
header(name: string, value: string) {
|
|
237
|
+
headers[name.toLowerCase()] = value
|
|
238
|
+
return reply
|
|
239
|
+
},
|
|
240
|
+
type(contentType: string) {
|
|
241
|
+
headers['content-type'] = contentType
|
|
242
|
+
return reply
|
|
243
|
+
},
|
|
244
|
+
send(payload?: unknown) {
|
|
245
|
+
sent = true
|
|
246
|
+
if (payload === undefined || payload === null) {
|
|
247
|
+
body = ''
|
|
248
|
+
} else if (typeof payload === 'string') {
|
|
249
|
+
body = payload
|
|
250
|
+
} else {
|
|
251
|
+
body = JSON.stringify(payload)
|
|
252
|
+
if (!headers['content-type']) {
|
|
253
|
+
headers['content-type'] = 'application/json'
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const result = await handler(request, reply)
|
|
261
|
+
// if handler returned a value and didn't call reply.send()
|
|
262
|
+
if (!sent && result !== undefined) {
|
|
263
|
+
reply.send(result)
|
|
264
|
+
}
|
|
265
|
+
} catch (err) {
|
|
266
|
+
statusCode = 500
|
|
267
|
+
body = String(err)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { statusCode, headers, body }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function tryParseJson(str: string): unknown {
|
|
275
|
+
try {
|
|
276
|
+
return JSON.parse(str)
|
|
277
|
+
} catch {
|
|
278
|
+
return str
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// -- default export matching fastify's API --
|
|
283
|
+
|
|
284
|
+
function Fastify(_opts?: unknown): FastifyShim {
|
|
285
|
+
const instance = new FastifyShim()
|
|
286
|
+
// register on globalThis so the CF embed can access it
|
|
287
|
+
;(globalThis as any).__orez_fastify_instance = instance
|
|
288
|
+
return instance
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export default Fastify
|
|
292
|
+
export type { FastifyRequest, FastifyReply, FastifyShim }
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
HttpServiceAdapter,
|
|
5
|
+
createHttpServiceAdapter,
|
|
6
|
+
type InjectableFastify,
|
|
7
|
+
type WebSocketHandler,
|
|
8
|
+
type WebSocketUpgradeResult,
|
|
9
|
+
} from './http-service.js'
|
|
10
|
+
|
|
11
|
+
/** minimal mock fastify that records inject() calls and returns canned responses */
|
|
12
|
+
function createMockFastify(
|
|
13
|
+
responses: Record<
|
|
14
|
+
string,
|
|
15
|
+
{ status: number; body: string; headers?: Record<string, string> }
|
|
16
|
+
>
|
|
17
|
+
): InjectableFastify {
|
|
18
|
+
return {
|
|
19
|
+
async ready() {},
|
|
20
|
+
async inject(opts) {
|
|
21
|
+
const key = `${opts.method} ${opts.url}`
|
|
22
|
+
const resp = responses[key]
|
|
23
|
+
if (!resp) {
|
|
24
|
+
return { statusCode: 404, headers: {}, body: 'not found' }
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
statusCode: resp.status,
|
|
28
|
+
headers: resp.headers ?? { 'content-type': 'text/plain' },
|
|
29
|
+
body: resp.body,
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** helper to create a Request (works in node/bun/vitest with fetch globals) */
|
|
36
|
+
function makeRequest(
|
|
37
|
+
url: string,
|
|
38
|
+
opts?: { method?: string; headers?: Record<string, string>; body?: string }
|
|
39
|
+
): Request {
|
|
40
|
+
return new Request(url, {
|
|
41
|
+
method: opts?.method ?? 'GET',
|
|
42
|
+
headers: opts?.headers,
|
|
43
|
+
body: opts?.body,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('HttpServiceAdapter', () => {
|
|
48
|
+
let adapter: HttpServiceAdapter
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
adapter = new HttpServiceAdapter()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('initialization', () => {
|
|
55
|
+
it('is not ready before initialize', () => {
|
|
56
|
+
expect(adapter.isReady).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('is ready after initialize', async () => {
|
|
60
|
+
await adapter.initialize(createMockFastify({}))
|
|
61
|
+
expect(adapter.isReady).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns 503 when not initialized', async () => {
|
|
65
|
+
const resp = await adapter.handleRequest(makeRequest('http://localhost/'))
|
|
66
|
+
expect(resp.status).toBe(503)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('HTTP routing via inject()', () => {
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
await adapter.initialize(
|
|
73
|
+
createMockFastify({
|
|
74
|
+
'GET /': { status: 200, body: 'ok' },
|
|
75
|
+
'GET /keepalive': { status: 200, body: 'alive' },
|
|
76
|
+
'GET /statz': {
|
|
77
|
+
status: 200,
|
|
78
|
+
body: '{"uptime":123}',
|
|
79
|
+
headers: { 'content-type': 'application/json' },
|
|
80
|
+
},
|
|
81
|
+
'POST /data': { status: 201, body: 'created' },
|
|
82
|
+
})
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('routes GET / to fastify', async () => {
|
|
87
|
+
const resp = await adapter.handleRequest(makeRequest('http://localhost/'))
|
|
88
|
+
expect(resp.status).toBe(200)
|
|
89
|
+
expect(await resp.text()).toBe('ok')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('routes GET /keepalive to fastify', async () => {
|
|
93
|
+
const resp = await adapter.handleRequest(makeRequest('http://localhost/keepalive'))
|
|
94
|
+
expect(resp.status).toBe(200)
|
|
95
|
+
expect(await resp.text()).toBe('alive')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('routes GET /statz with correct content-type', async () => {
|
|
99
|
+
const resp = await adapter.handleRequest(makeRequest('http://localhost/statz'))
|
|
100
|
+
expect(resp.status).toBe(200)
|
|
101
|
+
expect(resp.headers.get('content-type')).toBe('application/json')
|
|
102
|
+
expect(await resp.text()).toBe('{"uptime":123}')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('handles POST with body', async () => {
|
|
106
|
+
const resp = await adapter.handleRequest(
|
|
107
|
+
makeRequest('http://localhost/data', {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
body: '{"key":"value"}',
|
|
110
|
+
headers: { 'content-type': 'application/json' },
|
|
111
|
+
})
|
|
112
|
+
)
|
|
113
|
+
expect(resp.status).toBe(201)
|
|
114
|
+
expect(await resp.text()).toBe('created')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('returns 404 for unknown routes', async () => {
|
|
118
|
+
const resp = await adapter.handleRequest(makeRequest('http://localhost/nope'))
|
|
119
|
+
expect(resp.status).toBe(404)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('preserves query string', async () => {
|
|
123
|
+
const fastify = createMockFastify({
|
|
124
|
+
'GET /search?q=hello': { status: 200, body: 'found' },
|
|
125
|
+
})
|
|
126
|
+
await adapter.initialize(fastify)
|
|
127
|
+
const resp = await adapter.handleRequest(
|
|
128
|
+
makeRequest('http://localhost/search?q=hello')
|
|
129
|
+
)
|
|
130
|
+
expect(resp.status).toBe(200)
|
|
131
|
+
expect(await resp.text()).toBe('found')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('WebSocket route matching', () => {
|
|
136
|
+
const noopHandler: WebSocketHandler = () => {}
|
|
137
|
+
|
|
138
|
+
it('matches exact WebSocket routes', () => {
|
|
139
|
+
adapter.addWsRoute('/replication/v1/changes', noopHandler)
|
|
140
|
+
const match = adapter.matchWsRoute('/replication/v1/changes')
|
|
141
|
+
expect(match).not.toBeNull()
|
|
142
|
+
expect(match!.pattern).toBe('/replication/v1/changes')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('returns null for unmatched paths', () => {
|
|
146
|
+
adapter.addWsRoute('/replication/v1/changes', noopHandler)
|
|
147
|
+
expect(adapter.matchWsRoute('/other/path')).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('matches wildcard patterns', () => {
|
|
151
|
+
adapter.addWsRoute('/replication/v*/changes', noopHandler)
|
|
152
|
+
|
|
153
|
+
expect(adapter.matchWsRoute('/replication/v1/changes')).not.toBeNull()
|
|
154
|
+
expect(adapter.matchWsRoute('/replication/v2/changes')).not.toBeNull()
|
|
155
|
+
expect(adapter.matchWsRoute('/replication/v99/changes')).not.toBeNull()
|
|
156
|
+
|
|
157
|
+
// should not match different structure
|
|
158
|
+
expect(adapter.matchWsRoute('/replication/v1/snapshot')).toBeNull()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('matches multiple wildcard patterns', () => {
|
|
162
|
+
const changesHandler: WebSocketHandler = () => {}
|
|
163
|
+
const snapshotHandler: WebSocketHandler = () => {}
|
|
164
|
+
|
|
165
|
+
adapter.addWsRoute('/replication/v*/changes', changesHandler)
|
|
166
|
+
adapter.addWsRoute('/replication/v*/snapshot', snapshotHandler)
|
|
167
|
+
|
|
168
|
+
const changesMatch = adapter.matchWsRoute('/replication/v1/changes')
|
|
169
|
+
expect(changesMatch).not.toBeNull()
|
|
170
|
+
expect(changesMatch!.handler).toBe(changesHandler)
|
|
171
|
+
|
|
172
|
+
const snapshotMatch = adapter.matchWsRoute('/replication/v2/snapshot')
|
|
173
|
+
expect(snapshotMatch).not.toBeNull()
|
|
174
|
+
expect(snapshotMatch!.handler).toBe(snapshotHandler)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('prefers exact match over pattern', () => {
|
|
178
|
+
const exactHandler: WebSocketHandler = () => {}
|
|
179
|
+
const patternHandler: WebSocketHandler = () => {}
|
|
180
|
+
|
|
181
|
+
adapter.addWsRoute('/replication/v1/changes', exactHandler)
|
|
182
|
+
adapter.addWsRoute('/replication/v*/changes', patternHandler)
|
|
183
|
+
|
|
184
|
+
const match = adapter.matchWsRoute('/replication/v1/changes')
|
|
185
|
+
expect(match).not.toBeNull()
|
|
186
|
+
expect(match!.handler).toBe(exactHandler)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('wildcard does not match across slashes', () => {
|
|
190
|
+
adapter.addWsRoute('/api/v*/data', noopHandler)
|
|
191
|
+
expect(adapter.matchWsRoute('/api/v1/extra/data')).toBeNull()
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('WebSocket upgrade detection', () => {
|
|
196
|
+
beforeEach(async () => {
|
|
197
|
+
await adapter.initialize(createMockFastify({}))
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('detects upgrade header', async () => {
|
|
201
|
+
adapter.addWsRoute('/ws', () => {})
|
|
202
|
+
|
|
203
|
+
// WebSocketPair is a CF global — not available in vitest.
|
|
204
|
+
// we verify the adapter detects the upgrade and tries to use it.
|
|
205
|
+
const resp = await adapter.handleRequest(
|
|
206
|
+
makeRequest('http://localhost/ws', {
|
|
207
|
+
headers: { upgrade: 'websocket' },
|
|
208
|
+
})
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// without WebSocketPair in the runtime, we get a 500
|
|
212
|
+
// this confirms the adapter correctly detected the upgrade
|
|
213
|
+
// and attempted ws handling (rather than routing to fastify)
|
|
214
|
+
expect(resp.status).toBe(500)
|
|
215
|
+
expect(await resp.text()).toBe('WebSocketPair not available in this runtime')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('returns 404 for ws upgrade with no matching route', async () => {
|
|
219
|
+
const resp = await adapter.handleRequest(
|
|
220
|
+
makeRequest('http://localhost/unknown', {
|
|
221
|
+
headers: { upgrade: 'websocket' },
|
|
222
|
+
})
|
|
223
|
+
)
|
|
224
|
+
expect(resp.status).toBe(404)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('routes non-upgrade requests to fastify even if ws route exists', async () => {
|
|
228
|
+
adapter.addWsRoute('/dual', () => {})
|
|
229
|
+
|
|
230
|
+
// regular GET (no upgrade header) should go to fastify inject
|
|
231
|
+
const resp = await adapter.handleRequest(makeRequest('http://localhost/dual'))
|
|
232
|
+
// fastify returns 404 since we didn't register an HTTP route for /dual
|
|
233
|
+
expect(resp.status).toBe(404)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('WebSocket upgrade preparation with mock WebSocketPair', () => {
|
|
238
|
+
// tests use prepareWebSocketUpgrade() directly because the CF Workers
|
|
239
|
+
// Response constructor (status 101 + webSocket property) is not available
|
|
240
|
+
// in Node.js. this tests the full setup logic without the CF-specific part.
|
|
241
|
+
|
|
242
|
+
let originalWebSocketPair: unknown
|
|
243
|
+
|
|
244
|
+
beforeEach(async () => {
|
|
245
|
+
originalWebSocketPair = (globalThis as any).WebSocketPair
|
|
246
|
+
await adapter.initialize(createMockFastify({}))
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
afterEach(() => {
|
|
250
|
+
if (originalWebSocketPair !== undefined) {
|
|
251
|
+
;(globalThis as any).WebSocketPair = originalWebSocketPair
|
|
252
|
+
} else {
|
|
253
|
+
delete (globalThis as any).WebSocketPair
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('creates WebSocket pair and returns upgrade result', () => {
|
|
258
|
+
const mockServer = {
|
|
259
|
+
accept: vi.fn(),
|
|
260
|
+
close: vi.fn(),
|
|
261
|
+
send: vi.fn(),
|
|
262
|
+
addEventListener: vi.fn(),
|
|
263
|
+
}
|
|
264
|
+
const mockClient = { close: vi.fn() }
|
|
265
|
+
|
|
266
|
+
;(globalThis as any).WebSocketPair = class {
|
|
267
|
+
0 = mockClient
|
|
268
|
+
1 = mockServer
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const handler = vi.fn()
|
|
272
|
+
adapter.addWsRoute('/ws/test', handler)
|
|
273
|
+
|
|
274
|
+
const req = makeRequest('http://localhost/ws/test', {
|
|
275
|
+
headers: { upgrade: 'websocket' },
|
|
276
|
+
})
|
|
277
|
+
const url = new URL(req.url)
|
|
278
|
+
const result = adapter.prepareWebSocketUpgrade(req, url)
|
|
279
|
+
|
|
280
|
+
// should be an upgrade result, not a Response or null
|
|
281
|
+
expect(result).not.toBeNull()
|
|
282
|
+
expect(result).not.toBeInstanceOf(Response)
|
|
283
|
+
|
|
284
|
+
const upgrade = result as WebSocketUpgradeResult
|
|
285
|
+
expect(upgrade.client).toBe(mockClient)
|
|
286
|
+
expect(upgrade.server).toBe(mockServer)
|
|
287
|
+
expect(upgrade.handler).toBe(handler)
|
|
288
|
+
expect(upgrade.url.pathname).toBe('/ws/test')
|
|
289
|
+
expect(mockServer.accept).toHaveBeenCalled()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('returns null when no route matches', () => {
|
|
293
|
+
const req = makeRequest('http://localhost/nope')
|
|
294
|
+
const url = new URL(req.url)
|
|
295
|
+
expect(adapter.prepareWebSocketUpgrade(req, url)).toBeNull()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('returns Response(500) when WebSocketPair is unavailable', () => {
|
|
299
|
+
delete (globalThis as any).WebSocketPair
|
|
300
|
+
|
|
301
|
+
adapter.addWsRoute('/ws/test', () => {})
|
|
302
|
+
|
|
303
|
+
const req = makeRequest('http://localhost/ws/test')
|
|
304
|
+
const url = new URL(req.url)
|
|
305
|
+
const result = adapter.prepareWebSocketUpgrade(req, url)
|
|
306
|
+
|
|
307
|
+
expect(result).toBeInstanceOf(Response)
|
|
308
|
+
expect((result as Response).status).toBe(500)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('handler invocation catches errors and closes socket', async () => {
|
|
312
|
+
const mockServer = {
|
|
313
|
+
accept: vi.fn(),
|
|
314
|
+
close: vi.fn(),
|
|
315
|
+
send: vi.fn(),
|
|
316
|
+
addEventListener: vi.fn(),
|
|
317
|
+
}
|
|
318
|
+
const mockClient = { close: vi.fn() }
|
|
319
|
+
|
|
320
|
+
;(globalThis as any).WebSocketPair = class {
|
|
321
|
+
0 = mockClient
|
|
322
|
+
1 = mockServer
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const handler = vi.fn().mockRejectedValue(new Error('handler boom'))
|
|
326
|
+
adapter.addWsRoute('/ws/fail', handler)
|
|
327
|
+
|
|
328
|
+
const req = makeRequest('http://localhost/ws/fail')
|
|
329
|
+
const url = new URL(req.url)
|
|
330
|
+
const result = adapter.prepareWebSocketUpgrade(req, url) as WebSocketUpgradeResult
|
|
331
|
+
|
|
332
|
+
// simulate what handleWebSocket does: invoke handler and catch errors
|
|
333
|
+
await Promise.resolve(
|
|
334
|
+
result.handler(result.server as any, result.request, result.url)
|
|
335
|
+
).catch((err) => {
|
|
336
|
+
try {
|
|
337
|
+
;(result.server as any).close(1011, String(err))
|
|
338
|
+
} catch {
|
|
339
|
+
// socket may already be closed
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
expect(handler).toHaveBeenCalled()
|
|
344
|
+
expect(mockServer.close).toHaveBeenCalledWith(1011, 'Error: handler boom')
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe('createHttpServiceAdapter factory', () => {
|
|
349
|
+
it('returns a valid adapter', () => {
|
|
350
|
+
const adapter = createHttpServiceAdapter()
|
|
351
|
+
expect(adapter).toBeInstanceOf(HttpServiceAdapter)
|
|
352
|
+
expect(adapter.isReady).toBe(false)
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
})
|