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.
Files changed (130) hide show
  1. package/dist/cli-entry.js +0 -0
  2. package/dist/cli.js +7 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +1 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +1 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +14 -11
  10. package/dist/index.js.map +1 -1
  11. package/dist/pg-proxy.d.ts.map +1 -1
  12. package/dist/pg-proxy.js +8 -4
  13. package/dist/pg-proxy.js.map +1 -1
  14. package/dist/pglite-manager.d.ts +12 -0
  15. package/dist/pglite-manager.d.ts.map +1 -1
  16. package/dist/pglite-manager.js +81 -0
  17. package/dist/pglite-manager.js.map +1 -1
  18. package/dist/recovery.js +2 -2
  19. package/dist/recovery.js.map +1 -1
  20. package/dist/replication/change-tracker.js +9 -9
  21. package/dist/replication/change-tracker.js.map +1 -1
  22. package/dist/replication/handler.d.ts +12 -0
  23. package/dist/replication/handler.d.ts.map +1 -1
  24. package/dist/replication/handler.js +34 -6
  25. package/dist/replication/handler.js.map +1 -1
  26. package/dist/worker/browser-build-config.d.ts +59 -0
  27. package/dist/worker/browser-build-config.d.ts.map +1 -0
  28. package/dist/worker/browser-build-config.js +101 -0
  29. package/dist/worker/browser-build-config.js.map +1 -0
  30. package/dist/worker/browser-embed.d.ts +58 -0
  31. package/dist/worker/browser-embed.d.ts.map +1 -0
  32. package/dist/worker/browser-embed.js +195 -0
  33. package/dist/worker/browser-embed.js.map +1 -0
  34. package/dist/worker/cf-patches.d.ts +20 -0
  35. package/dist/worker/cf-patches.d.ts.map +1 -0
  36. package/dist/worker/cf-patches.js +94 -0
  37. package/dist/worker/cf-patches.js.map +1 -0
  38. package/dist/worker/index.d.ts +12 -0
  39. package/dist/worker/index.d.ts.map +1 -0
  40. package/dist/worker/index.js +105 -0
  41. package/dist/worker/index.js.map +1 -0
  42. package/dist/worker/shims/fastify.d.ts +80 -0
  43. package/dist/worker/shims/fastify.d.ts.map +1 -0
  44. package/dist/worker/shims/fastify.js +223 -0
  45. package/dist/worker/shims/fastify.js.map +1 -0
  46. package/dist/worker/shims/http-service.d.ts +104 -0
  47. package/dist/worker/shims/http-service.d.ts.map +1 -0
  48. package/dist/worker/shims/http-service.js +198 -0
  49. package/dist/worker/shims/http-service.js.map +1 -0
  50. package/dist/worker/shims/node-stub.d.ts +147 -0
  51. package/dist/worker/shims/node-stub.d.ts.map +1 -0
  52. package/dist/worker/shims/node-stub.js +204 -0
  53. package/dist/worker/shims/node-stub.js.map +1 -0
  54. package/dist/worker/shims/postgres.d.ts +115 -0
  55. package/dist/worker/shims/postgres.d.ts.map +1 -0
  56. package/dist/worker/shims/postgres.js +1181 -0
  57. package/dist/worker/shims/postgres.js.map +1 -0
  58. package/dist/worker/shims/sqlite-browser.d.ts +54 -0
  59. package/dist/worker/shims/sqlite-browser.d.ts.map +1 -0
  60. package/dist/worker/shims/sqlite-browser.js +144 -0
  61. package/dist/worker/shims/sqlite-browser.js.map +1 -0
  62. package/dist/worker/shims/sqlite.d.ts +126 -0
  63. package/dist/worker/shims/sqlite.d.ts.map +1 -0
  64. package/dist/worker/shims/sqlite.js +599 -0
  65. package/dist/worker/shims/sqlite.js.map +1 -0
  66. package/dist/worker/shims/stream-browser.d.ts +9 -0
  67. package/dist/worker/shims/stream-browser.d.ts.map +1 -0
  68. package/dist/worker/shims/stream-browser.js +13 -0
  69. package/dist/worker/shims/stream-browser.js.map +1 -0
  70. package/dist/worker/shims/ws-browser.d.ts +50 -0
  71. package/dist/worker/shims/ws-browser.d.ts.map +1 -0
  72. package/dist/worker/shims/ws-browser.js +105 -0
  73. package/dist/worker/shims/ws-browser.js.map +1 -0
  74. package/dist/worker/shims/ws.d.ts +62 -0
  75. package/dist/worker/shims/ws.d.ts.map +1 -0
  76. package/dist/worker/shims/ws.js +310 -0
  77. package/dist/worker/shims/ws.js.map +1 -0
  78. package/dist/worker/types.d.ts +57 -0
  79. package/dist/worker/types.d.ts.map +1 -0
  80. package/dist/worker/types.js +9 -0
  81. package/dist/worker/types.js.map +1 -0
  82. package/dist/worker/zero-cache-embed-cf.d.ts +63 -0
  83. package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -0
  84. package/dist/worker/zero-cache-embed-cf.js +268 -0
  85. package/dist/worker/zero-cache-embed-cf.js.map +1 -0
  86. package/dist/worker/zero-cache-embed.d.ts +66 -0
  87. package/dist/worker/zero-cache-embed.d.ts.map +1 -0
  88. package/dist/worker/zero-cache-embed.js +200 -0
  89. package/dist/worker/zero-cache-embed.js.map +1 -0
  90. package/package.json +62 -3
  91. package/src/cli-entry.ts +0 -0
  92. package/src/cli.ts +8 -1
  93. package/src/config.ts +2 -0
  94. package/src/index.ts +15 -10
  95. package/src/integration/integration.test.ts +1 -1
  96. package/src/integration/restore-live-stress.test.ts +2 -2
  97. package/src/pg-proxy.ts +9 -4
  98. package/src/pglite-manager.ts +111 -0
  99. package/src/recovery.ts +2 -2
  100. package/src/replication/change-tracker.test.ts +1 -1
  101. package/src/replication/change-tracker.ts +9 -9
  102. package/src/replication/handler.test.ts +37 -0
  103. package/src/replication/handler.ts +46 -6
  104. package/src/wasm-sqlite.test.ts +2 -1
  105. package/src/worker/browser-build-config.test.ts +59 -0
  106. package/src/worker/browser-build-config.ts +105 -0
  107. package/src/worker/browser-embed.ts +306 -0
  108. package/src/worker/cf-patches.ts +114 -0
  109. package/src/worker/embed-integration.test.ts +321 -0
  110. package/src/worker/index.ts +138 -0
  111. package/src/worker/shims/fastify.test.ts +255 -0
  112. package/src/worker/shims/fastify.ts +292 -0
  113. package/src/worker/shims/http-service.test.ts +355 -0
  114. package/src/worker/shims/http-service.ts +293 -0
  115. package/src/worker/shims/node-stub.ts +223 -0
  116. package/src/worker/shims/postgres.test.ts +364 -0
  117. package/src/worker/shims/postgres.ts +1434 -0
  118. package/src/worker/shims/sqlite-browser.test.ts +233 -0
  119. package/src/worker/shims/sqlite-browser.ts +178 -0
  120. package/src/worker/shims/sqlite.test.ts +641 -0
  121. package/src/worker/shims/sqlite.ts +731 -0
  122. package/src/worker/shims/ws-browser.test.ts +184 -0
  123. package/src/worker/shims/ws-browser.ts +125 -0
  124. package/src/worker/shims/ws.test.ts +288 -0
  125. package/src/worker/shims/ws.ts +367 -0
  126. package/src/worker/types.ts +75 -0
  127. package/src/worker/worker-integration.test.ts +223 -0
  128. package/src/worker/worker.test.ts +136 -0
  129. package/src/worker/zero-cache-embed-cf.ts +367 -0
  130. 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
+ })