orez 0.1.43 → 0.1.44

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 (108) hide show
  1. package/dist/admin/http-proxy.d.ts.map +1 -1
  2. package/dist/admin/http-proxy.js +3 -1
  3. package/dist/admin/http-proxy.js.map +1 -1
  4. package/dist/admin/log-store.d.ts.map +1 -1
  5. package/dist/admin/log-store.js +5 -1
  6. package/dist/admin/log-store.js.map +1 -1
  7. package/dist/admin/server.d.ts.map +1 -1
  8. package/dist/admin/server.js +25 -25
  9. package/dist/admin/server.js.map +1 -1
  10. package/dist/browser.d.ts +54 -0
  11. package/dist/browser.d.ts.map +1 -0
  12. package/dist/browser.js +110 -0
  13. package/dist/browser.js.map +1 -0
  14. package/dist/cli.js +1 -1
  15. package/dist/cli.js.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +5 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/pg-proxy-browser.d.ts +26 -0
  21. package/dist/pg-proxy-browser.d.ts.map +1 -0
  22. package/dist/pg-proxy-browser.js +1460 -0
  23. package/dist/pg-proxy-browser.js.map +1 -0
  24. package/dist/pg-proxy.d.ts.map +1 -1
  25. package/dist/pg-proxy.js +48 -34
  26. package/dist/pg-proxy.js.map +1 -1
  27. package/dist/pglite-ipc.d.ts.map +1 -1
  28. package/dist/pglite-ipc.js +3 -2
  29. package/dist/pglite-ipc.js.map +1 -1
  30. package/dist/pglite-manager.d.ts.map +1 -1
  31. package/dist/pglite-manager.js +33 -85
  32. package/dist/pglite-manager.js.map +1 -1
  33. package/dist/pglite-web-proxy.d.ts +38 -0
  34. package/dist/pglite-web-proxy.d.ts.map +1 -0
  35. package/dist/pglite-web-proxy.js +155 -0
  36. package/dist/pglite-web-proxy.js.map +1 -0
  37. package/dist/pglite-web-worker.d.ts +24 -0
  38. package/dist/pglite-web-worker.d.ts.map +1 -0
  39. package/dist/pglite-web-worker.js +119 -0
  40. package/dist/pglite-web-worker.js.map +1 -0
  41. package/dist/recovery.js +2 -2
  42. package/dist/recovery.js.map +1 -1
  43. package/dist/replication/change-tracker.js +9 -9
  44. package/dist/replication/change-tracker.js.map +1 -1
  45. package/dist/replication/handler.d.ts.map +1 -1
  46. package/dist/replication/handler.js +34 -26
  47. package/dist/replication/handler.js.map +1 -1
  48. package/dist/worker/browser-build-config.d.ts.map +1 -1
  49. package/dist/worker/browser-build-config.js +5 -2
  50. package/dist/worker/browser-build-config.js.map +1 -1
  51. package/dist/worker/browser-embed.d.ts.map +1 -1
  52. package/dist/worker/browser-embed.js +31 -26
  53. package/dist/worker/browser-embed.js.map +1 -1
  54. package/dist/worker/shims/fastify.d.ts +1 -0
  55. package/dist/worker/shims/fastify.d.ts.map +1 -1
  56. package/dist/worker/shims/fastify.js +31 -20
  57. package/dist/worker/shims/fastify.js.map +1 -1
  58. package/dist/worker/shims/postgres-browser.d.ts +12 -0
  59. package/dist/worker/shims/postgres-browser.d.ts.map +1 -0
  60. package/dist/worker/shims/postgres-browser.js +52 -0
  61. package/dist/worker/shims/postgres-browser.js.map +1 -0
  62. package/dist/worker/shims/postgres-socket.d.ts +83 -0
  63. package/dist/worker/shims/postgres-socket.d.ts.map +1 -0
  64. package/dist/worker/shims/postgres-socket.js +278 -0
  65. package/dist/worker/shims/postgres-socket.js.map +1 -0
  66. package/dist/worker/shims/postgres.d.ts.map +1 -1
  67. package/dist/worker/shims/postgres.js +18 -9
  68. package/dist/worker/shims/postgres.js.map +1 -1
  69. package/dist/worker/shims/stream-browser.d.ts +5 -4
  70. package/dist/worker/shims/stream-browser.d.ts.map +1 -1
  71. package/dist/worker/shims/stream-browser.js +7 -6
  72. package/dist/worker/shims/stream-browser.js.map +1 -1
  73. package/dist/worker/shims/ws-browser.d.ts.map +1 -1
  74. package/dist/worker/shims/ws-browser.js +43 -21
  75. package/dist/worker/shims/ws-browser.js.map +1 -1
  76. package/dist/worker/shims/ws.d.ts.map +1 -1
  77. package/dist/worker/shims/ws.js +81 -17
  78. package/dist/worker/shims/ws.js.map +1 -1
  79. package/package.json +11 -58
  80. package/src/admin/http-proxy.ts +4 -1
  81. package/src/admin/log-store.ts +5 -1
  82. package/src/admin/server.ts +26 -25
  83. package/src/browser.ts +195 -0
  84. package/src/cli.ts +1 -1
  85. package/src/index.ts +5 -2
  86. package/src/integration/integration.test.ts +1 -1
  87. package/src/integration/restore-live-stress.test.ts +2 -2
  88. package/src/pg-proxy-browser.ts +1673 -0
  89. package/src/pg-proxy.ts +48 -40
  90. package/src/pglite-ipc.ts +3 -2
  91. package/src/pglite-manager.ts +45 -107
  92. package/src/pglite-web-proxy.ts +180 -0
  93. package/src/pglite-web-worker.ts +132 -0
  94. package/src/recovery.ts +2 -2
  95. package/src/replication/change-tracker.test.ts +1 -1
  96. package/src/replication/change-tracker.ts +9 -9
  97. package/src/replication/handler.ts +37 -26
  98. package/src/worker/browser-build-config.test.ts +1 -1
  99. package/src/worker/browser-build-config.ts +5 -2
  100. package/src/worker/browser-embed.ts +33 -30
  101. package/src/worker/shims/fastify.ts +37 -24
  102. package/src/worker/shims/postgres-browser.ts +59 -0
  103. package/src/worker/shims/postgres-socket.test.ts +576 -0
  104. package/src/worker/shims/postgres-socket.ts +310 -0
  105. package/src/worker/shims/postgres.ts +30 -15
  106. package/src/worker/shims/stream-browser.ts +15 -0
  107. package/src/worker/shims/ws-browser.ts +38 -20
  108. package/src/worker/shims/ws.ts +76 -21
@@ -0,0 +1,310 @@
1
+ /**
2
+ * MessagePort-backed socket for the postgres npm package.
3
+ *
4
+ * the postgres package (porsager/postgres) accepts a custom socket factory
5
+ * via options.socket. this provides a net.Socket-compatible object backed
6
+ * by a MessagePort that connects to pg-proxy-browser.
7
+ *
8
+ * usage:
9
+ * import postgres from 'postgres'
10
+ * import { createSocketFactory } from 'orez/worker/shims/postgres-socket'
11
+ *
12
+ * const sql = postgres({
13
+ * socket: createSocketFactory(proxyPort),
14
+ * // ... other options
15
+ * })
16
+ *
17
+ * this replaces the postgres.ts shim entirely — the real postgres package
18
+ * speaks wire protocol to pg-proxy-browser, just like orez-node speaks
19
+ * wire protocol to pg-proxy over TCP.
20
+ */
21
+
22
+ import { Buffer } from 'buffer'
23
+ import { EventEmitter } from 'events'
24
+
25
+ /**
26
+ * create a socket factory for the postgres npm package.
27
+ * each call to the factory creates a new MessageChannel,
28
+ * gives one port to the proxy via connectFn, and returns
29
+ * a Socket-like object backed by the other port.
30
+ */
31
+ export function createSocketFactory(connectFn: (port: MessagePort) => void) {
32
+ return () => new MessagePortSocket(connectFn)
33
+ }
34
+
35
+ /**
36
+ * net.Socket-compatible object backed by MessagePort.
37
+ * implements the full interface that the postgres package uses,
38
+ * plus reasonable net.Socket spec compliance for other consumers.
39
+ */
40
+ class MessagePortSocket extends EventEmitter {
41
+ private port: MessagePort | null = null
42
+ private channel: MessageChannel | null = null
43
+ private _destroyed = false
44
+ private _ended = false
45
+ private _readyState: 'opening' | 'open' | 'closed' = 'opening'
46
+
47
+ // pause/resume buffering for COPY protocol backpressure
48
+ private _paused = false
49
+ private _pauseBuffer: Buffer[] = []
50
+
51
+ // timeout tracking
52
+ private _timeoutMs = 0
53
+ private _timeoutTimer: ReturnType<typeof setTimeout> | null = null
54
+
55
+ // net.Socket compat properties
56
+ writable = true
57
+ readable = true
58
+ bytesRead = 0
59
+ bytesWritten = 0
60
+
61
+ // postgres may write these on native sockets (skipped for custom, but allow assignment)
62
+ ssl?: boolean
63
+ host?: string
64
+ // port is already used by MessagePort field, use _pgPort for postgres assignment
65
+ // actually postgres only writes these for non-custom sockets, so we just need
66
+ // the property to be settable without error
67
+
68
+ constructor(private connectFn: (port: MessagePort) => void) {
69
+ super()
70
+ this.channel = new MessageChannel()
71
+ this.port = this.channel.port1
72
+
73
+ // give server port to pg-proxy-browser
74
+ this.connectFn(this.channel.port2)
75
+
76
+ // forward incoming data from proxy — wrap as Buffer (postgres needs readUInt32BE etc.)
77
+ this.port.onmessage = (ev: MessageEvent) => {
78
+ if (this._destroyed) return
79
+
80
+ let buf: Buffer | null = null
81
+ if (ev.data instanceof ArrayBuffer) {
82
+ buf = Buffer.from(new Uint8Array(ev.data))
83
+ } else if (ev.data instanceof Uint8Array) {
84
+ buf = Buffer.from(ev.data)
85
+ }
86
+
87
+ if (!buf) return
88
+
89
+ this.bytesRead += buf.length
90
+ this._resetTimeout()
91
+
92
+ if (this._paused) {
93
+ this._pauseBuffer.push(buf)
94
+ return
95
+ }
96
+
97
+ this.emit('data', buf)
98
+ }
99
+
100
+ this.port.start()
101
+
102
+ // transition to open and fire connect event async
103
+ // for custom sockets postgres calls connected() directly (skips socket.on('connect')),
104
+ // but we emit for generic socket compat
105
+ queueMicrotask(() => {
106
+ if (!this._destroyed) {
107
+ this._readyState = 'open'
108
+ this.emit('connect')
109
+ this.emit('ready')
110
+ }
111
+ })
112
+ }
113
+
114
+ get destroyed() {
115
+ return this._destroyed
116
+ }
117
+
118
+ get readyState(): string {
119
+ return this._readyState
120
+ }
121
+
122
+ get connecting() {
123
+ return this._readyState === 'opening'
124
+ }
125
+
126
+ get pending() {
127
+ return this._readyState === 'opening'
128
+ }
129
+
130
+ // postgres calls socket.write(chunk, fn) — returns boolean for backpressure
131
+ write(
132
+ data: Uint8Array | Buffer | string,
133
+ encoding?: any,
134
+ callback?: Function
135
+ ): boolean {
136
+ if (this._destroyed || !this.port) {
137
+ if (typeof encoding === 'function') encoding()
138
+ else if (typeof callback === 'function') callback()
139
+ return false
140
+ }
141
+
142
+ const bytes: Uint8Array =
143
+ typeof data === 'string'
144
+ ? Buffer.from(data)
145
+ : data instanceof Uint8Array
146
+ ? data
147
+ : Buffer.from(data)
148
+
149
+ // copy before transfer — postgres may reference the buffer after write
150
+ const copy = new Uint8Array(bytes.length)
151
+ copy.set(bytes)
152
+
153
+ try {
154
+ this.port.postMessage(copy.buffer, [copy.buffer])
155
+ } catch (err) {
156
+ queueMicrotask(() => this.emit('error', err))
157
+ if (typeof encoding === 'function') encoding()
158
+ else if (typeof callback === 'function') callback()
159
+ return false
160
+ }
161
+
162
+ this.bytesWritten += bytes.length
163
+ this._resetTimeout()
164
+
165
+ if (typeof encoding === 'function') encoding()
166
+ else if (typeof callback === 'function') callback()
167
+
168
+ return true
169
+ }
170
+
171
+ // postgres calls socket.end(terminateMsg) in terminate() then
172
+ // registers socket.once('close', resolve). defer destroy so the
173
+ // 'close' listener is registered before the event fires.
174
+ end(data?: any, encoding?: any, callback?: Function) {
175
+ if (typeof data === 'function') {
176
+ callback = data
177
+ data = undefined
178
+ encoding = undefined
179
+ } else if (typeof encoding === 'function') {
180
+ callback = encoding
181
+ encoding = undefined
182
+ }
183
+
184
+ if (data != null) this.write(data, encoding)
185
+ this._ended = true
186
+
187
+ // defer destroy to next microtask — terminate() calls socket.end(X_msg)
188
+ // then line 408 of connection.js does socket.once('close', resolve).
189
+ // synchronous destroy would fire 'close' before that listener exists.
190
+ queueMicrotask(() => this.destroy())
191
+
192
+ if (typeof callback === 'function') callback()
193
+ }
194
+
195
+ destroy(err?: Error) {
196
+ if (this._destroyed) return this
197
+ this._destroyed = true
198
+ this._readyState = 'closed'
199
+ this.writable = false
200
+ this.readable = false
201
+
202
+ // clear timeout
203
+ if (this._timeoutTimer) {
204
+ clearTimeout(this._timeoutTimer)
205
+ this._timeoutTimer = null
206
+ }
207
+
208
+ // clear pause buffer
209
+ this._pauseBuffer.length = 0
210
+
211
+ if (this.port) {
212
+ // delay port.close() to allow pending messages (like Terminate/X)
213
+ // to be delivered. closing immediately after postMessage loses
214
+ // the message, preventing the proxy from releasing its mutex.
215
+ const p = this.port
216
+ this.port = null
217
+ setTimeout(() => p.close(), 50)
218
+ }
219
+
220
+ if (err) {
221
+ this.emit('error', err)
222
+ }
223
+ this.emit('end')
224
+ this.emit('close', !!err)
225
+ return this
226
+ }
227
+
228
+ // flow control — MessagePort doesn't natively support pause/resume,
229
+ // so we buffer incoming messages when paused and flush on resume.
230
+ // the postgres COPY protocol relies on this (CopyData calls socket.pause()
231
+ // when stream.push() returns false).
232
+ pause() {
233
+ this._paused = true
234
+ return this
235
+ }
236
+
237
+ resume() {
238
+ this._paused = false
239
+ // flush buffered messages — exit if data handler re-pauses
240
+ while (this._pauseBuffer.length && !this._paused) {
241
+ this.emit('data', this._pauseBuffer.shift()!)
242
+ }
243
+ return this
244
+ }
245
+
246
+ // timeout — emit 'timeout' after ms of inactivity (no reads or writes).
247
+ // postgres calls socket.setKeepAlive conditionally but doesn't use
248
+ // setTimeout on custom sockets. still useful for detecting hung connections.
249
+ setTimeout(ms: number, cb?: Function) {
250
+ this._timeoutMs = ms
251
+ if (this._timeoutTimer) {
252
+ clearTimeout(this._timeoutTimer)
253
+ this._timeoutTimer = null
254
+ }
255
+ if (cb) this.once('timeout', cb as (...args: any[]) => void)
256
+ if (ms > 0) this._resetTimeout()
257
+ return this
258
+ }
259
+
260
+ private _resetTimeout() {
261
+ if (this._timeoutTimer) clearTimeout(this._timeoutTimer)
262
+ if (this._timeoutMs > 0 && !this._destroyed) {
263
+ this._timeoutTimer = globalThis.setTimeout(
264
+ () => this.emit('timeout'),
265
+ this._timeoutMs
266
+ )
267
+ }
268
+ }
269
+
270
+ // no-ops — these configure TCP-level behavior that doesn't apply to MessagePort
271
+ setKeepAlive() {
272
+ return this
273
+ }
274
+ setNoDelay() {
275
+ return this
276
+ }
277
+ ref() {
278
+ return this
279
+ }
280
+ unref() {
281
+ return this
282
+ }
283
+ cork() {}
284
+ uncork() {}
285
+
286
+ // postgres skips connect() for custom sockets, but defensive for generic use
287
+ connect() {
288
+ return this
289
+ }
290
+
291
+ // net.Socket address info stubs
292
+ address() {
293
+ return { address: '127.0.0.1', family: 'IPv4', port: 0 }
294
+ }
295
+ get remoteAddress() {
296
+ return '127.0.0.1'
297
+ }
298
+ get remotePort() {
299
+ return 0
300
+ }
301
+ get remoteFamily() {
302
+ return 'IPv4'
303
+ }
304
+ get localAddress() {
305
+ return '127.0.0.1'
306
+ }
307
+ get localPort() {
308
+ return 0
309
+ }
310
+ }
@@ -701,15 +701,22 @@ async function executeQuery(
701
701
  if (intercepted) return intercepted
702
702
  }
703
703
 
704
- // strip FK constraints from CREATE TABLE in browser mode.
705
- // without a wrapping transaction, DEFERRABLE can't defer across separate
706
- // INSERT statements zero-cache inserts desires before queries exist.
707
- // single-connection browser dev preview doesn't need FK enforcement.
708
- if (/FOREIGN\s+KEY/i.test(text) && /CREATE\s+TABLE/i.test(text)) {
709
- text = text.replace(
710
- /,?\s*(?:CONSTRAINT\s+\w+\s+)?FOREIGN\s+KEY\s*\([^)]*\)\s*REFERENCES\s+[^,(]+(?:\s*\([^)]*\))?(?:\s+ON\s+(?:DELETE|UPDATE)\s+(?:CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION))*(?:\s+DEFERRABLE[^,)]*)?/gi,
711
- ''
712
- )
704
+ // strip FK constraints PGlite doesn't support cross-schema FKs,
705
+ // and browser single-process mode doesn't need FK enforcement.
706
+ // covers CREATE TABLE inline FKs and ALTER TABLE ADD CONSTRAINT FKs.
707
+ if (/FOREIGN\s+KEY/i.test(text)) {
708
+ if (/CREATE\s+TABLE/i.test(text)) {
709
+ text = text.replace(
710
+ /,?\s*(?:CONSTRAINT\s+\w+\s+)?FOREIGN\s+KEY\s*\([^)]*\)\s*REFERENCES\s+[^,(]+(?:\s*\([^)]*\))?(?:\s+ON\s+(?:DELETE|UPDATE)\s+(?:CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION))*(?:\s+DEFERRABLE[^,)]*)?/gi,
711
+ ''
712
+ )
713
+ }
714
+ if (/ALTER\s+TABLE/i.test(text) && /ADD\s+CONSTRAINT/i.test(text)) {
715
+ text = text.replace(
716
+ /ALTER\s+TABLE\s+[^\s]+\s+ADD\s+CONSTRAINT\s+[^\s]+\s*\n?\s*FOREIGN\s+KEY\s*\([^)]*\)\s*\n?\s*REFERENCES\s+[^;]+/gi,
717
+ 'SELECT 1'
718
+ )
719
+ }
713
720
  }
714
721
 
715
722
  const isMulti = hasMultipleStatements(text)
@@ -1147,12 +1154,20 @@ export function createPostgresShim(pglite: PGlite, opts?: PostgresShimOptions) {
1147
1154
  return createCopyPendingQuery(queryString, pglite)
1148
1155
  }
1149
1156
 
1150
- // strip FK constraints from CREATE TABLE (see executeQuery for why)
1151
- if (/FOREIGN\s+KEY/i.test(queryString) && /CREATE\s+TABLE/i.test(queryString)) {
1152
- queryString = queryString.replace(
1153
- /,?\s*(?:CONSTRAINT\s+\w+\s+)?FOREIGN\s+KEY\s*\([^)]*\)\s*REFERENCES\s+[^,(]+(?:\s*\([^)]*\))?(?:\s+ON\s+(?:DELETE|UPDATE)\s+(?:CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION))*(?:\s+DEFERRABLE[^,)]*)?/gi,
1154
- ''
1155
- )
1157
+ // strip FK constraints (see executeQuery for why)
1158
+ if (/FOREIGN\s+KEY/i.test(queryString)) {
1159
+ if (/CREATE\s+TABLE/i.test(queryString)) {
1160
+ queryString = queryString.replace(
1161
+ /,?\s*(?:CONSTRAINT\s+\w+\s+)?FOREIGN\s+KEY\s*\([^)]*\)\s*REFERENCES\s+[^,(]+(?:\s*\([^)]*\))?(?:\s+ON\s+(?:DELETE|UPDATE)\s+(?:CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION))*(?:\s+DEFERRABLE[^,)]*)?/gi,
1162
+ ''
1163
+ )
1164
+ }
1165
+ if (/ALTER\s+TABLE/i.test(queryString) && /ADD\s+CONSTRAINT/i.test(queryString)) {
1166
+ queryString = queryString.replace(
1167
+ /ALTER\s+TABLE\s+[^\s]+\s+ADD\s+CONSTRAINT\s+[^\s]+\s*\n?\s*FOREIGN\s+KEY\s*\([^)]*\)\s*\n?\s*REFERENCES\s+[^;]+/gi,
1168
+ 'SELECT 1'
1169
+ )
1170
+ }
1156
1171
  }
1157
1172
 
1158
1173
  const serializedParams = (params ?? []).map(serializeParam)
@@ -0,0 +1,15 @@
1
+ /**
2
+ * stream shim — re-exports readable-stream with missing Node.js stream functions.
3
+ *
4
+ * readable-stream/stream-browserify don't include getDefaultHighWaterMark
5
+ * which zero-cache uses (added in Node.js 18+).
6
+ */
7
+
8
+ // @ts-expect-error — readable-stream is CJS
9
+ export * from 'readable-stream'
10
+ // @ts-expect-error — readable-stream is CJS
11
+ export { default } from 'readable-stream'
12
+
13
+ export function getDefaultHighWaterMark(objectMode: boolean): number {
14
+ return objectMode ? 16 : 16 * 1024
15
+ }
@@ -47,37 +47,56 @@ interface WsCompatible {
47
47
  * listener — which would silently drop messages.
48
48
  */
49
49
  export function messagePortToWs(port: MessagePort): WsCompatible {
50
- const listeners = new Map<string, Set<(event: any) => void>>()
50
+ // separate listener sets for on() vs addEventListener() to match real ws behavior.
51
+ // in the ws package, on() is EventEmitter-style and addEventListener() is DOM-style.
52
+ // createWebSocketStream uses ws.on('message') exclusively for inbound data.
53
+ // streamOut uses ws.addEventListener('message') for ack handling.
54
+ // if they share the same set, ack messages reach both handlers and
55
+ // handleMessage tries to parse acks as protocol messages, closing the connection.
56
+ const onListeners = new Map<string, Set<(event: any) => void>>()
57
+ const domListeners = new Map<string, Set<(event: any) => void>>()
51
58
  let closed = false
52
59
 
53
- // buffer messages until a 'message' listener is registered
60
+ // buffer messages until a 'message' listener is registered (either kind)
54
61
  const pendingMessages: any[] = []
55
62
 
56
- function addListener(type: string, handler: (event: any) => void) {
57
- if (!listeners.has(type)) listeners.set(type, new Set())
58
- listeners.get(type)!.add(handler)
63
+ function addOnListener(type: string, handler: (event: any) => void) {
64
+ if (!onListeners.has(type)) onListeners.set(type, new Set())
65
+ onListeners.get(type)!.add(handler)
66
+ if (type === 'message' && pendingMessages.length > 0) {
67
+ const queued = pendingMessages.splice(0)
68
+ for (const event of queued) handler(event)
69
+ }
70
+ }
71
+
72
+ function removeOnListener(type: string, handler: (event: any) => void) {
73
+ onListeners.get(type)?.delete(handler)
74
+ }
59
75
 
60
- // flush buffered messages when first 'message' listener is added
76
+ function addDomListener(type: string, handler: (event: any) => void) {
77
+ if (!domListeners.has(type)) domListeners.set(type, new Set())
78
+ domListeners.get(type)!.add(handler)
61
79
  if (type === 'message' && pendingMessages.length > 0) {
62
80
  const queued = pendingMessages.splice(0)
63
81
  for (const event of queued) handler(event)
64
82
  }
65
83
  }
66
84
 
67
- function removeListener(type: string, handler: (event: any) => void) {
68
- listeners.get(type)?.delete(handler)
85
+ function removeDomListener(type: string, handler: (event: any) => void) {
86
+ domListeners.get(type)?.delete(handler)
69
87
  }
70
88
 
71
89
  function emit(type: string, event: any) {
72
- const handlers = listeners.get(type)
73
- if (!handlers || handlers.size === 0) {
74
- // no listeners yet — buffer message events
75
- if (type === 'message') {
76
- pendingMessages.push(event)
77
- }
90
+ const onHandlers = onListeners.get(type)
91
+ const domHandlers = domListeners.get(type)
92
+ const hasAny =
93
+ (onHandlers && onHandlers.size > 0) || (domHandlers && domHandlers.size > 0)
94
+ if (!hasAny) {
95
+ if (type === 'message') pendingMessages.push(event)
78
96
  return
79
97
  }
80
- for (const h of handlers) h(event)
98
+ if (onHandlers) for (const h of onHandlers) h(event)
99
+ if (domHandlers) for (const h of domHandlers) h(event)
81
100
  }
82
101
 
83
102
  // forward port messages → ws 'message' events
@@ -102,7 +121,6 @@ export function messagePortToWs(port: MessagePort): WsCompatible {
102
121
 
103
122
  send(data: string | ArrayBuffer | ArrayBufferView) {
104
123
  if (closed) return
105
- // MessagePort uses postMessage (structured clone)
106
124
  port.postMessage(data)
107
125
  },
108
126
 
@@ -113,10 +131,10 @@ export function messagePortToWs(port: MessagePort): WsCompatible {
113
131
  emit('close', { code: code ?? 1000, reason: '', wasClean: true })
114
132
  },
115
133
 
116
- addEventListener: addListener,
117
- removeEventListener: removeListener,
118
- on: addListener,
119
- off: removeListener,
134
+ addEventListener: addDomListener,
135
+ removeEventListener: removeDomListener,
136
+ on: addOnListener,
137
+ off: removeOnListener,
120
138
  }
121
139
  }
122
140
 
@@ -63,8 +63,10 @@ class WebSocket extends EventEmitter {
63
63
 
64
64
  if (isInProcess) {
65
65
  // in-process: connect via fastify server's handoff mechanism
66
- const fastifyInstance = (globalThis as any).__orez_fastify_instance
67
- if (fastifyInstance?.server) {
66
+ // try all registered fastify instances via tryHandoff, stop at first match
67
+ const instances: any[] = (globalThis as any).__orez_fastify_instances || []
68
+ const fallbackInstance = (globalThis as any).__orez_fastify_instance
69
+ if (instances.length > 0 || fallbackInstance?.server) {
68
70
  // create paired message channels for bidirectional communication
69
71
  // the client-side WS (this) and serverWs are cross-linked so
70
72
  // ping/pong, messages, and close propagate between them
@@ -74,7 +76,8 @@ class WebSocket extends EventEmitter {
74
76
  _listeners: {} as Record<string, Function[]>,
75
77
  send: (data: string | ArrayBuffer) => {
76
78
  // deliver to client side
77
- queueMicrotask(() => clientSide.emit('message', data))
79
+ // wrap in event object — zero-cache handleMessage reads event.data
80
+ queueMicrotask(() => clientSide.emit('message', { data }))
78
81
  },
79
82
  close: (code?: number, reason?: string) => {
80
83
  serverWs.readyState = 3
@@ -98,10 +101,13 @@ class WebSocket extends EventEmitter {
98
101
  },
99
102
  }
100
103
 
104
+ // client-side internal ws — forwards send() to server,
105
+ // receives messages from server via addEventListener.
106
+ // addEventListener MUST work because WebSocket#setupListeners registers here.
107
+ const clientWsListeners: Record<string, Function[]> = {}
101
108
  this.#ws = {
102
109
  accept: () => {},
103
110
  send: (data: string | ArrayBuffer) => {
104
- // deliver to server side
105
111
  const handlers = serverWs._listeners['message'] || []
106
112
  for (const h of handlers) h({ data })
107
113
  },
@@ -109,27 +115,49 @@ class WebSocket extends EventEmitter {
109
115
  const handlers = serverWs._listeners['close'] || []
110
116
  for (const h of handlers) h({ code, reason })
111
117
  },
112
- addEventListener: () => {},
113
- removeEventListener: () => {},
118
+ addEventListener: (type: string, handler: Function) => {
119
+ if (!clientWsListeners[type]) clientWsListeners[type] = []
120
+ clientWsListeners[type].push(handler)
121
+ },
122
+ removeEventListener: (type: string, handler: Function) => {
123
+ const arr = clientWsListeners[type]
124
+ if (arr) {
125
+ const idx = arr.indexOf(handler)
126
+ if (idx >= 0) arr.splice(idx, 1)
127
+ }
128
+ },
114
129
  get readyState() {
115
130
  return 1
116
131
  },
117
132
  } as CFWebSocket
118
133
 
119
- // emit handoff to fastify server
134
+ // wire server client: when server sends data, deliver to client's
135
+ // addEventListener handlers AND emit on the WebSocket instance
136
+ const origServerSend = serverWs.send
137
+ serverWs.send = (data: string | ArrayBuffer) => {
138
+ const handlers = clientWsListeners['message'] || []
139
+ for (const h of handlers) h({ data })
140
+ queueMicrotask(() => clientSide.emit('message', { data }))
141
+ }
142
+
143
+ // try handoff against all fastify instances, stop at first match
120
144
  const path = parsedUrl.pathname + parsedUrl.search
145
+ const handoffMsg = {
146
+ message: { url: path, headers: {}, method: 'GET' },
147
+ head: new Uint8Array(0),
148
+ }
121
149
  queueMicrotask(() => {
122
- fastifyInstance.server.emit(
123
- 'message',
124
- [
125
- 'handoff',
126
- {
127
- message: { url: path, headers: {}, method: 'GET' },
128
- head: new Uint8Array(0),
129
- },
130
- ],
131
- serverWs
132
- )
150
+ let handled = false
151
+ for (const inst of instances) {
152
+ if (inst?.tryHandoff?.(handoffMsg, serverWs)) {
153
+ handled = true
154
+ break
155
+ }
156
+ }
157
+ // fallback: if no instance handled it and we have a fallback, emit directly
158
+ if (!handled && fallbackInstance?.server) {
159
+ fallbackInstance.server.emit('message', ['handoff', handoffMsg], serverWs)
160
+ }
133
161
  this.emit('open')
134
162
  })
135
163
  } else {
@@ -228,13 +256,40 @@ class WebSocket extends EventEmitter {
228
256
  }
229
257
 
230
258
  // standard EventTarget-style addEventListener (used by Connection)
259
+ // for 'message' events, wrap the handler so EventEmitter-style (data, isBinary)
260
+ // args get converted to DOM-style { data } events. streamOut uses
261
+ // addEventListener('message', ({data}) => ...) which needs DOM-style events.
262
+ #adapterMap = new WeakMap<Function, Function>()
263
+
231
264
  addEventListener(type: string, handler: (event: any) => void): void {
232
- // wrap to emit EventEmitter-style
233
- this.on(type, handler)
265
+ if (type === 'message') {
266
+ const wrapper = (data: any, isBinary?: boolean) => {
267
+ // if already a DOM-style event object with .data, pass through
268
+ if (data && typeof data === 'object' && 'data' in data) {
269
+ handler(data)
270
+ } else {
271
+ handler({ data, isBinary })
272
+ }
273
+ }
274
+ this.#adapterMap.set(handler, wrapper)
275
+ this.on(type, wrapper)
276
+ } else {
277
+ this.on(type, handler)
278
+ }
234
279
  }
235
280
 
236
281
  removeEventListener(type: string, handler: (event: any) => void): void {
237
- this.off(type, handler)
282
+ if (type === 'message') {
283
+ const wrapper = this.#adapterMap.get(handler)
284
+ if (wrapper) {
285
+ this.off(type, wrapper as any)
286
+ this.#adapterMap.delete(handler)
287
+ } else {
288
+ this.off(type, handler)
289
+ }
290
+ } else {
291
+ this.off(type, handler)
292
+ }
238
293
  }
239
294
 
240
295
  #setupListeners(): void {