lopata 0.18.3 → 0.19.0

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 (106) hide show
  1. package/dist/types/api/dispatch.d.ts +2 -2
  2. package/dist/types/api/handlers/workflows.d.ts +2 -2
  3. package/dist/types/bindings/container-cleanup.d.ts +43 -0
  4. package/dist/types/bindings/container-docker.d.ts +24 -0
  5. package/dist/types/bindings/container.d.ts +4 -2
  6. package/dist/types/bindings/do-executor-worker.d.ts +73 -89
  7. package/dist/types/bindings/do-executor.d.ts +22 -1
  8. package/dist/types/bindings/do-worker-env.d.ts +16 -7
  9. package/dist/types/bindings/durable-object.d.ts +66 -4
  10. package/dist/types/bindings/queue.d.ts +4 -1
  11. package/dist/types/bindings/rpc-stub.d.ts +23 -1
  12. package/dist/types/bindings/scheduled.d.ts +13 -3
  13. package/dist/types/bindings/service-binding.d.ts +13 -4
  14. package/dist/types/bindings/static-assets.d.ts +1 -1
  15. package/dist/types/bindings/websocket-pair.d.ts +12 -3
  16. package/dist/types/bindings/workflow.d.ts +29 -0
  17. package/dist/types/config.d.ts +2 -0
  18. package/dist/types/env.d.ts +6 -0
  19. package/dist/types/error-page-render.d.ts +6 -0
  20. package/dist/types/execution-context.d.ts +8 -0
  21. package/dist/types/generation-manager.d.ts +21 -2
  22. package/dist/types/generation.d.ts +16 -21
  23. package/dist/types/import-graph.d.ts +28 -0
  24. package/dist/types/lopata-config.d.ts +3 -3
  25. package/dist/types/plugin.d.ts +4 -1
  26. package/dist/types/rpc-validate.d.ts +9 -0
  27. package/dist/types/setup-globals.d.ts +5 -1
  28. package/dist/types/tracing/context.d.ts +21 -0
  29. package/dist/types/tracing/span.d.ts +21 -3
  30. package/dist/types/tracing/store.d.ts +17 -0
  31. package/dist/types/tsconfig.tsbuildinfo +1 -1
  32. package/dist/types/vite-plugin/index.d.ts +8 -1
  33. package/dist/types/worker-registry.d.ts +11 -4
  34. package/dist/types/worker-thread/do-protocol.d.ts +256 -0
  35. package/dist/types/worker-thread/entry.d.ts +2 -0
  36. package/dist/types/worker-thread/execution-context.d.ts +22 -0
  37. package/dist/types/worker-thread/executor.d.ts +123 -0
  38. package/dist/types/worker-thread/protocol.d.ts +553 -0
  39. package/dist/types/worker-thread/remote-trace-store.d.ts +27 -0
  40. package/dist/types/worker-thread/rpc-client.d.ts +4 -0
  41. package/dist/types/worker-thread/rpc-shared.d.ts +138 -0
  42. package/dist/types/worker-thread/serialize.d.ts +12 -0
  43. package/dist/types/worker-thread/stream-shared.d.ts +147 -0
  44. package/dist/types/worker-thread/thread-env.d.ts +42 -0
  45. package/dist/types/worker-thread/wire-handlers.d.ts +16 -0
  46. package/dist/types/worker-thread/ws-bridge-shared.d.ts +163 -0
  47. package/package.json +1 -1
  48. package/src/api/handlers/containers.ts +2 -1
  49. package/src/api/handlers/workflows.ts +39 -34
  50. package/src/bindings/container-cleanup.ts +125 -0
  51. package/src/bindings/container-docker.ts +49 -34
  52. package/src/bindings/container.ts +24 -9
  53. package/src/bindings/do-executor-inprocess.ts +9 -5
  54. package/src/bindings/do-executor-worker.ts +386 -158
  55. package/src/bindings/do-executor.ts +23 -1
  56. package/src/bindings/do-worker-entry.ts +242 -60
  57. package/src/bindings/do-worker-env.ts +296 -11
  58. package/src/bindings/durable-object.ts +231 -27
  59. package/src/bindings/email.ts +6 -0
  60. package/src/bindings/queue.ts +11 -1
  61. package/src/bindings/rpc-stub.ts +79 -10
  62. package/src/bindings/scheduled.ts +37 -30
  63. package/src/bindings/service-binding.ts +96 -35
  64. package/src/bindings/static-assets.ts +3 -2
  65. package/src/bindings/websocket-pair.ts +19 -3
  66. package/src/bindings/workflow.ts +91 -0
  67. package/src/cli/dev.ts +106 -41
  68. package/src/config.ts +6 -3
  69. package/src/db.ts +1 -0
  70. package/src/env.ts +40 -21
  71. package/src/error-page-render.ts +21 -0
  72. package/src/execution-context.ts +13 -3
  73. package/src/generation-manager.ts +144 -143
  74. package/src/generation.ts +150 -306
  75. package/src/import-graph.ts +140 -0
  76. package/src/lopata-config.ts +3 -3
  77. package/src/plugin.ts +7 -0
  78. package/src/rpc-validate.ts +29 -0
  79. package/src/setup-globals.ts +6 -17
  80. package/src/testing/durable-object.ts +5 -3
  81. package/src/testing/index.ts +8 -4
  82. package/src/tracing/context.ts +28 -0
  83. package/src/tracing/span.ts +88 -56
  84. package/src/tracing/store.ts +41 -3
  85. package/src/virtual-modules.ts +2 -0
  86. package/src/vite-plugin/dev-server-plugin.ts +4 -0
  87. package/src/vite-plugin/index.ts +8 -1
  88. package/src/worker-registry.ts +15 -2
  89. package/src/worker-thread/do-protocol.ts +237 -0
  90. package/src/worker-thread/entry.ts +453 -0
  91. package/src/worker-thread/execution-context.ts +53 -0
  92. package/src/worker-thread/executor.ts +595 -0
  93. package/src/worker-thread/protocol.ts +552 -0
  94. package/src/worker-thread/remote-trace-store.ts +90 -0
  95. package/src/worker-thread/rpc-client.ts +5 -0
  96. package/src/worker-thread/rpc-shared.ts +503 -0
  97. package/src/worker-thread/serialize.ts +37 -0
  98. package/src/worker-thread/stream-shared.ts +414 -0
  99. package/src/worker-thread/thread-env.ts +350 -0
  100. package/src/worker-thread/wire-handlers.ts +80 -0
  101. package/src/worker-thread/ws-bridge-shared.ts +482 -0
  102. package/dist/types/bindings/do-websocket-bridge.d.ts +0 -60
  103. package/dist/types/module-cache.d.ts +0 -23
  104. package/src/bindings/do-websocket-bridge.ts +0 -79
  105. package/src/module-cache.ts +0 -58
  106. package/src/tracing/global.d.ts +0 -50
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Cross-thread WebSocket bridge — shared primitive.
3
+ *
4
+ * Used by both worker channels (main ↔ user-worker, main ↔ DO-instance worker)
5
+ * to ferry events for a CFWebSocket whose two halves live on opposite threads.
6
+ *
7
+ * Direction & terminology:
8
+ * - *Guest* — the side that owns the user-facing peer (worker thread).
9
+ * The user code created a `WebSocketPair` locally and shipped `pair[0]`
10
+ * in a `Response{webSocket}`. The guest keeps `pair[1]` and forwards
11
+ * everything the user does on it through to the host.
12
+ * - *Host* — the side that hands the upgraded `CFWebSocket` to `Bun.serve`
13
+ * (main thread). It owns a synthetic CFWebSocket whose `_peer` posts every
14
+ * inbound event (from the real client) back to the guest.
15
+ *
16
+ * Each bridged socket is identified by an opaque `wsId` generated on the
17
+ * guest side. The host-side `register()` call is the message that ferries
18
+ * the id over and creates the local half. A late `deliverRemote*` for a
19
+ * wsId that hasn't been `register()`ed yet is buffered until it appears —
20
+ * see `_pendingEvents` for why.
21
+ *
22
+ * Channel-specific envelopes (`ws-worker-send` vs `fetch-ws-outgoing`, etc.)
23
+ * are encoded by the `WsBridgeEnvelopes` callbacks each consumer supplies.
24
+ */
25
+
26
+ import { CFWebSocket, type WSEvent } from '../bindings/websocket-pair'
27
+ import { generateId } from '../tracing/context'
28
+
29
+ /**
30
+ * Channel-specific message builders. Each callback returns the exact envelope
31
+ * the consumer's transport expects; the bridge just calls `post(envelope)`.
32
+ */
33
+ export interface WsHostEnvelopes<O> {
34
+ /** Inbound from the real client → guest's user peer. */
35
+ clientMessage(wsId: string, data: string | ArrayBuffer): O
36
+ clientClose(wsId: string, code: number, reason: string, wasClean: boolean): O
37
+ }
38
+
39
+ export interface WsGuestEnvelopes<O> {
40
+ /** User-facing peer sent bytes → forward to real client via host. */
41
+ remoteMessage(wsId: string, data: string | ArrayBuffer): O
42
+ remoteClose(wsId: string, code: number, reason: string, wasClean: boolean): O
43
+ }
44
+
45
+ /** Cap on the "recently forgotten" set. A long-lived bridge (DO executors live
46
+ * until eviction and may serve many short-lived connections) would otherwise
47
+ * grow this set unbounded. It only needs to catch late `deliver*` events racing
48
+ * a recent close, so once over the cap, evicting the oldest id (Set preserves
49
+ * insertion order) is safe. */
50
+ const FORGOTTEN_CAP = 1024
51
+ function rememberForgotten(set: Set<string>, wsId: string): void {
52
+ set.add(wsId)
53
+ if (set.size > FORGOTTEN_CAP) {
54
+ const oldest = set.values().next().value
55
+ if (oldest !== undefined) set.delete(oldest)
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Host side: owns the `CFWebSocket` that gets handed to `Bun.serve.upgrade`
61
+ * and bridges events to/from the guest worker.
62
+ */
63
+ export class WsHostBridge<O> {
64
+ /** wsId → cfSocket (the side handed to `Bun.serve.upgrade`). */
65
+ private _sockets = new Map<string, CFWebSocket>()
66
+ /**
67
+ * Events from the guest that arrived before the matching `register()` /
68
+ * `adoptExisting()` was called. The guest's `accept()` flushes queued events
69
+ * synchronously and posts them; those posts race ahead of the binding-fetch /
70
+ * fetch result that triggers host-side registration. Without buffering, the
71
+ * first message would be lost.
72
+ */
73
+ private _pendingEvents = new Map<string, WSEvent[]>()
74
+ /**
75
+ * Subset of `_sockets` that was adopted with `bridgeEvents: true` — outbound
76
+ * `deliverRemoteMessage` must `cfSocket.send(data)` (dispatch on peer) for
77
+ * these, instead of `dispatchOrQueue` (dispatch on the adopted socket
78
+ * itself, which is the right behavior only for the `Bun.serve` passthrough).
79
+ */
80
+ private _bridgedAdoptions = new Set<string>()
81
+ /**
82
+ * wsIds that were previously registered/adopted and have since closed.
83
+ * Used to drop late `deliverRemote*` calls cleanly instead of stashing
84
+ * them in `_pendingEvents` (where they'd be unreachable — wsIds are
85
+ * unique per upgrade, so no future `register()` will ever match).
86
+ */
87
+ private _forgotten = new Set<string>()
88
+ private _post: (msg: O) => void
89
+ private _envelopes: WsHostEnvelopes<O>
90
+
91
+ constructor(post: (msg: O) => void, envelopes: WsHostEnvelopes<O>) {
92
+ this._post = post
93
+ this._envelopes = envelopes
94
+ }
95
+
96
+ private _forget(wsId: string): void {
97
+ this._sockets.delete(wsId)
98
+ this._pendingEvents.delete(wsId)
99
+ this._bridgedAdoptions.delete(wsId)
100
+ rememberForgotten(this._forgotten, wsId)
101
+ }
102
+
103
+ /**
104
+ * Build a host-side `CFWebSocket` paired with a bridge peer that posts every
105
+ * inbound event back to the guest. Drains any pending events queued before
106
+ * this call.
107
+ */
108
+ register(wsId: string): CFWebSocket {
109
+ const cfSocket = new CFWebSocket()
110
+ const peer = new BridgeWebSocketPeer(wsId, this._post, this._envelopes, id => this._forget(id))
111
+ cfSocket._peer = peer
112
+ peer._peer = cfSocket
113
+ this._sockets.set(wsId, cfSocket)
114
+ const pending = this._pendingEvents.get(wsId)
115
+ if (pending) {
116
+ cfSocket._eventQueue.push(...pending)
117
+ this._pendingEvents.delete(wsId)
118
+ // A close buffered before register() (user did send()+close() on the
119
+ // server peer before returning the Response) must carry the same
120
+ // bookkeeping deliverRemoteClose does: mark the socket CLOSED and forget
121
+ // the id. The queued close event still dispatches to the real client on
122
+ // accept(). Without this the entry lingered in `_sockets` until
123
+ // disposeAll and the socket reported OPEN despite being closed.
124
+ if (pending.some((ev) => ev.type === 'close')) {
125
+ cfSocket.readyState = CFWebSocket.CLOSED
126
+ this._forget(wsId)
127
+ }
128
+ }
129
+ return cfSocket
130
+ }
131
+
132
+ /**
133
+ * Adopt an already-real `CFWebSocket` (typically the client peer returned
134
+ * from a DO/service binding inside a nested binding fetch). The peer is
135
+ * already wired to its server counterpart, so we just need to keep it
136
+ * addressable by id when the guest echoes the response back up.
137
+ *
138
+ * For the *passthrough* use (user-worker channel): the guest never reads or
139
+ * writes through this socket — main eventually hands the same `cfSocket`
140
+ * to `Bun.serve.upgrade()`, which owns both directions.
141
+ *
142
+ * For the *guest-driven* use (DO-worker env-binding channel): pass
143
+ * `bridgeEvents: true` so we accept the socket here, forward every inbound
144
+ * message/close to the guest as `clientMessage` / `clientClose` envelopes,
145
+ * and `deliverRemoteMessage` routes outbound bytes via `cfSocket.send`
146
+ * (which dispatches on its real peer) instead of dispatching on the
147
+ * adopted socket itself.
148
+ */
149
+ adoptExisting(ws: CFWebSocket, options: { bridgeEvents?: boolean } = {}): string {
150
+ const wsId = generateId(8)
151
+ this._sockets.set(wsId, ws)
152
+ if (options.bridgeEvents) {
153
+ this._wireBridgedAdoption(wsId, ws)
154
+ } else {
155
+ // Passthrough adoption: still attach a close listener so the entry is
156
+ // removed from `_sockets` when the upstream peer goes away — otherwise
157
+ // the map grows for the executor's lifetime.
158
+ ws.addEventListener('close', () => this._forget(wsId))
159
+ }
160
+ return wsId
161
+ }
162
+
163
+ private _wireBridgedAdoption(wsId: string, cfSocket: CFWebSocket): void {
164
+ cfSocket.addEventListener('message', (ev: Event) => {
165
+ const data = (ev as MessageEvent).data
166
+ this._post(this._envelopes.clientMessage(wsId, data))
167
+ })
168
+ cfSocket.addEventListener('close', (ev: Event) => {
169
+ const ce = ev as CloseEvent
170
+ this._post(this._envelopes.clientClose(wsId, ce.code, ce.reason, ce.wasClean ?? true))
171
+ this._forget(wsId)
172
+ })
173
+ cfSocket.accept()
174
+ this._bridgedAdoptions.add(wsId)
175
+ }
176
+
177
+ /** Look up a previously-registered or adopted CFWebSocket. */
178
+ getSocket(wsId: string): CFWebSocket | undefined {
179
+ return this._sockets.get(wsId)
180
+ }
181
+
182
+ /** Guest reports its user-facing peer emitted a message → fire it on the host socket. */
183
+ deliverRemoteMessage(wsId: string, data: string | ArrayBuffer): void {
184
+ const cfSocket = this._sockets.get(wsId)
185
+ const evt: WSEvent = { type: 'message', data }
186
+ if (!cfSocket) {
187
+ this._bufferPending(wsId, evt)
188
+ return
189
+ }
190
+ if (this._bridgedAdoptions.has(wsId)) {
191
+ // Adopted host socket — dispatching on it would fire its own listeners
192
+ // again (we wired ones in `_wireBridgedAdoption`). Route via `send` so
193
+ // the data lands on its real peer instead.
194
+ cfSocket.send(data)
195
+ return
196
+ }
197
+ cfSocket.dispatchOrQueue(evt)
198
+ }
199
+
200
+ /** Guest reports its user-facing peer closed → fire close on the host socket. */
201
+ deliverRemoteClose(wsId: string, code: number, reason: string, wasClean: boolean): void {
202
+ const cfSocket = this._sockets.get(wsId)
203
+ const evt: WSEvent = { type: 'close', code, reason, wasClean }
204
+ if (!cfSocket) {
205
+ this._bufferPending(wsId, evt)
206
+ return
207
+ }
208
+ if (this._bridgedAdoptions.has(wsId)) {
209
+ cfSocket.close(code, reason)
210
+ this._forget(wsId)
211
+ return
212
+ }
213
+ // Mark CLOSED before dispatching (mirrors the guest side) so any close
214
+ // handler on the host socket observes a closed socket and a nested close()
215
+ // no-ops instead of re-dispatching.
216
+ cfSocket.readyState = CFWebSocket.CLOSED
217
+ cfSocket.dispatchOrQueue(evt)
218
+ this._forget(wsId)
219
+ }
220
+
221
+ private _bufferPending(wsId: string, evt: WSEvent): void {
222
+ // Late event for a wsId that was already forgotten (peer closed first,
223
+ // host received this guest-side event after). No future `register()` will
224
+ // match this id, so buffering would leak. Drop it.
225
+ if (this._forgotten.has(wsId)) return
226
+ let q = this._pendingEvents.get(wsId)
227
+ if (!q) {
228
+ q = []
229
+ this._pendingEvents.set(wsId, q)
230
+ }
231
+ q.push(evt)
232
+ }
233
+
234
+ /**
235
+ * Notify any active real clients that this generation is going away, then
236
+ * drop them. Mirrors the `1012 Service Restart` close code WebSockets use
237
+ * for planned restarts. Also drops any stranded pending events for ids that
238
+ * never reached `register()` (e.g. binding-fetch errored mid-flight).
239
+ */
240
+ disposeAll(): void {
241
+ for (const [wsId, cfSocket] of this._sockets) {
242
+ if (cfSocket.readyState === CFWebSocket.CLOSED) continue
243
+ if (this._bridgedAdoptions.has(wsId)) {
244
+ // Real socket — closing it propagates 1012 to the upstream peer
245
+ // (the synthetic side lives on the guest, which is going away).
246
+ // `close()` sets readyState to CLOSED for us.
247
+ cfSocket.close(1012, 'Service Restart')
248
+ continue
249
+ }
250
+ cfSocket.dispatchOrQueue({ type: 'close', code: 1012, reason: 'Service Restart', wasClean: true })
251
+ cfSocket.readyState = CFWebSocket.CLOSED
252
+ }
253
+ this._sockets.clear()
254
+ this._pendingEvents.clear()
255
+ this._bridgedAdoptions.clear()
256
+ this._forgotten.clear()
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Peer that lives next to a host-side `CFWebSocket` and posts every dispatched
262
+ * event back to the guest. Pinned to `OPEN`/`accepted` so the cli/dev.ts
263
+ * upgrade handler — which only dispatches on accepted peers — can always
264
+ * relay client traffic.
265
+ */
266
+ class BridgeWebSocketPeer<O> extends CFWebSocket {
267
+ private _wsId: string
268
+ private _post: (msg: O) => void
269
+ private _envelopes: WsHostEnvelopes<O>
270
+ private _onForget: (wsId: string) => void
271
+
272
+ constructor(wsId: string, post: (msg: O) => void, envelopes: WsHostEnvelopes<O>, onForget: (wsId: string) => void) {
273
+ super()
274
+ this._wsId = wsId
275
+ this._post = post
276
+ this._envelopes = envelopes
277
+ this._onForget = onForget
278
+ this._accepted = true
279
+ this.readyState = CFWebSocket.OPEN
280
+ }
281
+
282
+ override _dispatchWSEvent(evt: WSEvent): void {
283
+ if (evt.type === 'message' && evt.data !== undefined) {
284
+ // Drop late messages dispatched after a close (e.g. a queued event
285
+ // flushing after `_onForget` ran). The peer's state must stay
286
+ // coherent with the wsId having been forgotten on both sides.
287
+ if (this.readyState !== CFWebSocket.OPEN) return
288
+ this._post(this._envelopes.clientMessage(this._wsId, evt.data))
289
+ return
290
+ }
291
+ if (evt.type === 'close') {
292
+ if (this.readyState === CFWebSocket.CLOSED) return
293
+ this.readyState = CFWebSocket.CLOSED
294
+ this._post(this._envelopes.clientClose(this._wsId, evt.code ?? 1000, evt.reason ?? '', evt.wasClean ?? true))
295
+ this._onForget(this._wsId)
296
+ }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Guest side: hooks the CFWebSocket that's about to ship in a Response and
302
+ * forwards events between the user-facing peer and the host.
303
+ */
304
+ export class WsGuestBridge<O> {
305
+ private _sockets = new Map<string, { userPeer: CFWebSocket; closed: boolean }>()
306
+ /**
307
+ * Client events that arrived before the matching `createBridgedSocket()` was
308
+ * called. On the env-binding channel main adopts the upstream WS with
309
+ * `bridgeEvents: true`, whose `accept()` flushes already-queued client events
310
+ * synchronously — those posts race ahead of the `rpc-fetch-result` that
311
+ * triggers `createBridgedSocket` here. Without buffering, the first inbound
312
+ * message (or an early close) would be lost. Mirrors `WsHostBridge`.
313
+ */
314
+ private _pendingEvents = new Map<string, WSEvent[]>()
315
+ /** wsIds already created+closed; drop late client events instead of re-buffering. */
316
+ private _forgotten = new Set<string>()
317
+ private _post: (msg: O) => void
318
+ private _envelopes: WsGuestEnvelopes<O>
319
+
320
+ constructor(post: (msg: O) => void, envelopes: WsGuestEnvelopes<O>) {
321
+ this._post = post
322
+ this._envelopes = envelopes
323
+ }
324
+
325
+ private _forget(wsId: string): void {
326
+ this._sockets.delete(wsId)
327
+ this._pendingEvents.delete(wsId)
328
+ rememberForgotten(this._forgotten, wsId)
329
+ }
330
+
331
+ private _bufferPending(wsId: string, evt: WSEvent): void {
332
+ if (this._forgotten.has(wsId)) return
333
+ let q = this._pendingEvents.get(wsId)
334
+ if (!q) {
335
+ q = []
336
+ this._pendingEvents.set(wsId, q)
337
+ }
338
+ q.push(evt)
339
+ }
340
+
341
+ /**
342
+ * Reverse of `register`: build a fresh `CFWebSocket` for a wsId allocated on
343
+ * the host (e.g. main adopted an upstream WS returned from an env-binding
344
+ * fetch). The returned socket is what user code interacts with — calling
345
+ * `accept()` / `send()` / `addEventListener('message')` here drives the
346
+ * remote peer through the bridge.
347
+ */
348
+ createBridgedSocket(wsId: string): CFWebSocket {
349
+ const userPeer = new CFWebSocket()
350
+ const bridgePeer = new BridgeGuestPeer<O>(wsId, this._post, this._envelopes, id => this._forget(id))
351
+ userPeer._peer = bridgePeer
352
+ bridgePeer._peer = userPeer
353
+ this._sockets.set(wsId, { userPeer, closed: false })
354
+ // Replay events buffered before this socket existed (see `_pendingEvents`).
355
+ const pending = this._pendingEvents.get(wsId)
356
+ if (pending) {
357
+ this._pendingEvents.delete(wsId)
358
+ for (const evt of pending) {
359
+ if (evt.type === 'close') {
360
+ this.deliverClientClose(wsId, evt.code ?? 1000, evt.reason ?? '', evt.wasClean ?? true)
361
+ } else if (evt.data !== undefined) {
362
+ this.deliverClientMessage(wsId, evt.data)
363
+ }
364
+ }
365
+ }
366
+ return userPeer
367
+ }
368
+
369
+ /**
370
+ * Hook up a `CFWebSocket` that's about to ship in a Response. Listeners must
371
+ * be attached BEFORE `accept()` so the synchronous flush of any queued events
372
+ * (e.g. the user already called `server.send()` before returning the response)
373
+ * reaches the bridge instead of being lost.
374
+ *
375
+ * If the shipped peer was pre-accepted by user code (e.g. `client.accept()`
376
+ * before returning the Response), any events dispatched between that
377
+ * pre-accept and this `register()` call were emitted with no listeners
378
+ * attached — they're already gone. We still attach listeners so anything
379
+ * *after* this point flows correctly, and emit a console.warn so the user
380
+ * knows to drop the early `accept()`.
381
+ */
382
+ register(shipped: CFWebSocket): string {
383
+ const wsId = generateId(8)
384
+ const userPeer = shipped._peer
385
+ if (!userPeer) {
386
+ throw new Error('Response.webSocket has no peer — was it created via `new WebSocketPair()`?')
387
+ }
388
+ this._sockets.set(wsId, { userPeer, closed: false })
389
+
390
+ const wasPreAccepted = shipped._accepted
391
+
392
+ shipped.addEventListener('message', (ev: Event) => {
393
+ const data = (ev as MessageEvent).data
394
+ this._post(this._envelopes.remoteMessage(wsId, data))
395
+ })
396
+ shipped.addEventListener('close', (ev: Event) => {
397
+ const ce = ev as CloseEvent
398
+ this._post(this._envelopes.remoteClose(wsId, ce.code, ce.reason, ce.wasClean ?? true))
399
+ // `_forget` (not a bare delete) so the id enters `_forgotten`. The host
400
+ // closes the real socket in response and the `clientClose` echo comes
401
+ // back here; without the id in `_forgotten`, `_bufferPending` would stash
402
+ // that echo in `_pendingEvents` forever (wsIds are unique, so no future
403
+ // register matches it) — a leak on every server-initiated close.
404
+ this._forget(wsId)
405
+ })
406
+ shipped.accept()
407
+
408
+ if (wasPreAccepted) {
409
+ console.warn(
410
+ '[lopata] Response.webSocket was already accept()ed before returning; '
411
+ + 'any events sent between accept() and the response return were lost. '
412
+ + 'Remove the early accept() — lopata accepts the shipped peer for you.',
413
+ )
414
+ }
415
+
416
+ return wsId
417
+ }
418
+
419
+ /** Host delivered a message from the real client → fire it on the user peer. */
420
+ deliverClientMessage(wsId: string, data: string | ArrayBuffer): void {
421
+ const entry = this._sockets.get(wsId)
422
+ if (!entry) {
423
+ this._bufferPending(wsId, { type: 'message', data })
424
+ return
425
+ }
426
+ if (entry.closed) return
427
+ entry.userPeer.dispatchOrQueue({ type: 'message', data })
428
+ }
429
+
430
+ /** Host delivered a close from the real client → fire close on the user peer. */
431
+ deliverClientClose(wsId: string, code: number, reason: string, wasClean: boolean): void {
432
+ const entry = this._sockets.get(wsId)
433
+ if (!entry) {
434
+ this._bufferPending(wsId, { type: 'close', code, reason, wasClean })
435
+ return
436
+ }
437
+ if (entry.closed) return
438
+ entry.closed = true
439
+ // Mark CLOSED *before* dispatching so user code observes a closed socket
440
+ // inside its close handler. CF's hibernation template calls `ws.close()`
441
+ // from `webSocketClose`; with readyState still OPEN that re-enters
442
+ // `CFWebSocket.close()` and fires the handler a second time. Now the nested
443
+ // close() no-ops via its `readyState === CLOSED` early return.
444
+ entry.userPeer.readyState = CFWebSocket.CLOSED
445
+ entry.userPeer.dispatchOrQueue({ type: 'close', code, reason, wasClean })
446
+ this._forget(wsId)
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Peer attached to the user-facing `CFWebSocket` returned by
452
+ * {@link WsGuestBridge.createBridgedSocket}. Pinned to `OPEN`/`accepted` so
453
+ * bytes the user-peer sends (`peer._dispatchWSEvent` from `CFWebSocket.send`)
454
+ * are forwarded to the host immediately without waiting for accept().
455
+ */
456
+ class BridgeGuestPeer<O> extends CFWebSocket {
457
+ private _wsId: string
458
+ private _post: (msg: O) => void
459
+ private _envelopes: WsGuestEnvelopes<O>
460
+ private _onForget: (wsId: string) => void
461
+
462
+ constructor(wsId: string, post: (msg: O) => void, envelopes: WsGuestEnvelopes<O>, onForget: (wsId: string) => void) {
463
+ super()
464
+ this._wsId = wsId
465
+ this._post = post
466
+ this._envelopes = envelopes
467
+ this._onForget = onForget
468
+ this._accepted = true
469
+ this.readyState = CFWebSocket.OPEN
470
+ }
471
+
472
+ override _dispatchWSEvent(evt: WSEvent): void {
473
+ if (evt.type === 'message' && evt.data !== undefined) {
474
+ this._post(this._envelopes.remoteMessage(this._wsId, evt.data))
475
+ return
476
+ }
477
+ if (evt.type === 'close') {
478
+ this._post(this._envelopes.remoteClose(this._wsId, evt.code ?? 1000, evt.reason ?? '', evt.wasClean ?? true))
479
+ this._onForget(this._wsId)
480
+ }
481
+ }
482
+ }
@@ -1,60 +0,0 @@
1
- /**
2
- * WebSocket bridge for isolated DO mode.
3
- *
4
- * Real WebSocket lives in the main thread (Bun.serve). The worker thread
5
- * gets a BridgeWebSocket proxy that forwards send/close via postMessage.
6
- * The main thread forwards incoming message/close/error events to the worker.
7
- *
8
- * Each bridged WebSocket is identified by a unique wsId.
9
- */
10
- export type WsBridgeOutbound = {
11
- type: 'ws-send';
12
- wsId: string;
13
- data: string | ArrayBuffer;
14
- } | {
15
- type: 'ws-close';
16
- wsId: string;
17
- code?: number;
18
- reason?: string;
19
- } | {
20
- type: 'ws-accept';
21
- wsId: string;
22
- tags: string[];
23
- };
24
- export type WsBridgeInbound = {
25
- type: 'ws-message';
26
- wsId: string;
27
- data: string | ArrayBuffer;
28
- } | {
29
- type: 'ws-close';
30
- wsId: string;
31
- code: number;
32
- reason: string;
33
- wasClean: boolean;
34
- } | {
35
- type: 'ws-error';
36
- wsId: string;
37
- };
38
- /**
39
- * A WebSocket proxy that lives in the worker thread.
40
- * Implements enough of the WebSocket interface for DO's state.acceptWebSocket().
41
- */
42
- export declare class BridgeWebSocket extends EventTarget {
43
- readonly wsId: string;
44
- readyState: number;
45
- private _postMessage;
46
- private _attachment;
47
- constructor(wsId: string, postMessage: (msg: WsBridgeOutbound) => void);
48
- serializeAttachment(attachment: any): void;
49
- deserializeAttachment(): any | null;
50
- send(data: string | ArrayBuffer): void;
51
- close(code?: number, reason?: string): void;
52
- /** @internal Called by the worker entry when the main thread forwards a message */
53
- _onMessage(data: string | ArrayBuffer): void;
54
- /** @internal Called by the worker entry when the main thread forwards a close */
55
- _onClose(code: number, reason: string, wasClean: boolean): void;
56
- /** @internal Called by the worker entry when the main thread forwards an error */
57
- _onError(): void;
58
- /** Signal that this WS has been accepted by the DO (via state.acceptWebSocket) */
59
- _signalAccepted(tags: string[]): void;
60
- }
@@ -1,23 +0,0 @@
1
- /**
2
- * Invalidate the JavaScriptCore module registry for user-code files under
3
- * `baseDir`, excluding `node_modules`. This forces Bun to re-read and
4
- * re-evaluate every transitive dep on the next import.
5
- *
6
- * Why: lopata reloads the worker via `await import('<entry>?v=<ts>')`. That
7
- * query-string trick only invalidates the entry — Bun's module registry is
8
- * keyed by resolved specifier, so static imports inside the entry continue
9
- * to resolve to the originally cached transitive modules. Without this
10
- * call, edits to any file other than the entry itself silently no-op
11
- * across reloads (the watcher fires, `[lopata] Reloaded` prints, but the
12
- * active worker still references the old module graph).
13
- *
14
- * Caveats:
15
- * - Uses `globalThis.Loader`, an undocumented JSC/Bun internal. If Bun
16
- * ever renames or removes it, hot-reload regresses to the prior
17
- * entry-only behavior — `tests/hmr-e2e.test.ts` "transitive dep
18
- * change" cases will catch the regression.
19
- * - `node_modules` paths are skipped so dependency imports stay cached
20
- * across reloads (re-evaluating them every save would be slow and
21
- * pointless — they don't change in a dev session).
22
- */
23
- export declare function invalidateUserModules(baseDir: string): void;
@@ -1,79 +0,0 @@
1
- /**
2
- * WebSocket bridge for isolated DO mode.
3
- *
4
- * Real WebSocket lives in the main thread (Bun.serve). The worker thread
5
- * gets a BridgeWebSocket proxy that forwards send/close via postMessage.
6
- * The main thread forwards incoming message/close/error events to the worker.
7
- *
8
- * Each bridged WebSocket is identified by a unique wsId.
9
- */
10
-
11
- // --- Messages from worker → main ---
12
- export type WsBridgeOutbound =
13
- | { type: 'ws-send'; wsId: string; data: string | ArrayBuffer }
14
- | { type: 'ws-close'; wsId: string; code?: number; reason?: string }
15
- | { type: 'ws-accept'; wsId: string; tags: string[] }
16
-
17
- // --- Messages from main → worker ---
18
- export type WsBridgeInbound =
19
- | { type: 'ws-message'; wsId: string; data: string | ArrayBuffer }
20
- | { type: 'ws-close'; wsId: string; code: number; reason: string; wasClean: boolean }
21
- | { type: 'ws-error'; wsId: string }
22
-
23
- /**
24
- * A WebSocket proxy that lives in the worker thread.
25
- * Implements enough of the WebSocket interface for DO's state.acceptWebSocket().
26
- */
27
- export class BridgeWebSocket extends EventTarget {
28
- readonly wsId: string
29
- readyState = 1 // OPEN
30
- private _postMessage: (msg: WsBridgeOutbound) => void
31
- private _attachment: any = null
32
-
33
- constructor(wsId: string, postMessage: (msg: WsBridgeOutbound) => void) {
34
- super()
35
- this.wsId = wsId
36
- this._postMessage = postMessage
37
- }
38
-
39
- serializeAttachment(attachment: any): void {
40
- this._attachment = JSON.parse(JSON.stringify(attachment))
41
- }
42
-
43
- deserializeAttachment(): any | null {
44
- return this._attachment
45
- }
46
-
47
- send(data: string | ArrayBuffer): void {
48
- if (this.readyState !== 1) return
49
- this._postMessage({ type: 'ws-send', wsId: this.wsId, data })
50
- }
51
-
52
- close(code?: number, reason?: string): void {
53
- if (this.readyState >= 2) return
54
- this.readyState = 2 // CLOSING
55
- this._postMessage({ type: 'ws-close', wsId: this.wsId, code, reason })
56
- this.readyState = 3 // CLOSED
57
- }
58
-
59
- /** @internal Called by the worker entry when the main thread forwards a message */
60
- _onMessage(data: string | ArrayBuffer): void {
61
- this.dispatchEvent(new MessageEvent('message', { data }))
62
- }
63
-
64
- /** @internal Called by the worker entry when the main thread forwards a close */
65
- _onClose(code: number, reason: string, wasClean: boolean): void {
66
- this.readyState = 3
67
- this.dispatchEvent(new CloseEvent('close', { code, reason, wasClean }))
68
- }
69
-
70
- /** @internal Called by the worker entry when the main thread forwards an error */
71
- _onError(): void {
72
- this.dispatchEvent(new Event('error'))
73
- }
74
-
75
- /** Signal that this WS has been accepted by the DO (via state.acceptWebSocket) */
76
- _signalAccepted(tags: string[]): void {
77
- this._postMessage({ type: 'ws-accept', wsId: this.wsId, tags })
78
- }
79
- }