lopata 0.18.4 → 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.
- package/dist/types/api/dispatch.d.ts +2 -2
- package/dist/types/api/handlers/workflows.d.ts +2 -2
- package/dist/types/bindings/container-cleanup.d.ts +43 -0
- package/dist/types/bindings/container-docker.d.ts +24 -0
- package/dist/types/bindings/container.d.ts +4 -2
- package/dist/types/bindings/do-executor-worker.d.ts +73 -89
- package/dist/types/bindings/do-executor.d.ts +22 -1
- package/dist/types/bindings/do-worker-env.d.ts +16 -7
- package/dist/types/bindings/durable-object.d.ts +66 -4
- package/dist/types/bindings/queue.d.ts +4 -1
- package/dist/types/bindings/rpc-stub.d.ts +23 -1
- package/dist/types/bindings/scheduled.d.ts +13 -3
- package/dist/types/bindings/service-binding.d.ts +13 -4
- package/dist/types/bindings/static-assets.d.ts +1 -1
- package/dist/types/bindings/websocket-pair.d.ts +12 -3
- package/dist/types/bindings/workflow.d.ts +29 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/env.d.ts +6 -0
- package/dist/types/error-page-render.d.ts +6 -0
- package/dist/types/execution-context.d.ts +8 -0
- package/dist/types/generation-manager.d.ts +21 -2
- package/dist/types/generation.d.ts +16 -21
- package/dist/types/import-graph.d.ts +28 -0
- package/dist/types/lopata-config.d.ts +3 -3
- package/dist/types/plugin.d.ts +4 -1
- package/dist/types/rpc-validate.d.ts +9 -0
- package/dist/types/setup-globals.d.ts +5 -1
- package/dist/types/tracing/context.d.ts +12 -0
- package/dist/types/tracing/span.d.ts +21 -3
- package/dist/types/tracing/store.d.ts +17 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/vite-plugin/index.d.ts +8 -1
- package/dist/types/worker-registry.d.ts +11 -4
- package/dist/types/worker-thread/do-protocol.d.ts +256 -0
- package/dist/types/worker-thread/entry.d.ts +2 -0
- package/dist/types/worker-thread/execution-context.d.ts +22 -0
- package/dist/types/worker-thread/executor.d.ts +123 -0
- package/dist/types/worker-thread/protocol.d.ts +553 -0
- package/dist/types/worker-thread/remote-trace-store.d.ts +27 -0
- package/dist/types/worker-thread/rpc-client.d.ts +4 -0
- package/dist/types/worker-thread/rpc-shared.d.ts +138 -0
- package/dist/types/worker-thread/serialize.d.ts +12 -0
- package/dist/types/worker-thread/stream-shared.d.ts +147 -0
- package/dist/types/worker-thread/thread-env.d.ts +42 -0
- package/dist/types/worker-thread/wire-handlers.d.ts +16 -0
- package/dist/types/worker-thread/ws-bridge-shared.d.ts +163 -0
- package/package.json +1 -1
- package/src/api/handlers/containers.ts +2 -1
- package/src/api/handlers/workflows.ts +39 -34
- package/src/bindings/container-cleanup.ts +125 -0
- package/src/bindings/container-docker.ts +49 -34
- package/src/bindings/container.ts +24 -9
- package/src/bindings/do-executor-inprocess.ts +9 -5
- package/src/bindings/do-executor-worker.ts +386 -158
- package/src/bindings/do-executor.ts +23 -1
- package/src/bindings/do-worker-entry.ts +242 -60
- package/src/bindings/do-worker-env.ts +296 -11
- package/src/bindings/durable-object.ts +231 -27
- package/src/bindings/email.ts +6 -0
- package/src/bindings/queue.ts +11 -1
- package/src/bindings/rpc-stub.ts +79 -10
- package/src/bindings/scheduled.ts +37 -30
- package/src/bindings/service-binding.ts +87 -33
- package/src/bindings/static-assets.ts +3 -2
- package/src/bindings/websocket-pair.ts +19 -3
- package/src/bindings/workflow.ts +91 -0
- package/src/cli/dev.ts +106 -41
- package/src/config.ts +6 -3
- package/src/db.ts +1 -0
- package/src/env.ts +40 -21
- package/src/error-page-render.ts +21 -0
- package/src/execution-context.ts +13 -3
- package/src/generation-manager.ts +144 -143
- package/src/generation.ts +150 -306
- package/src/import-graph.ts +140 -0
- package/src/lopata-config.ts +3 -3
- package/src/plugin.ts +7 -0
- package/src/rpc-validate.ts +29 -0
- package/src/setup-globals.ts +6 -17
- package/src/testing/durable-object.ts +5 -3
- package/src/testing/index.ts +8 -4
- package/src/tracing/context.ts +18 -0
- package/src/tracing/span.ts +83 -57
- package/src/tracing/store.ts +41 -3
- package/src/virtual-modules.ts +2 -0
- package/src/vite-plugin/dev-server-plugin.ts +4 -0
- package/src/vite-plugin/index.ts +8 -1
- package/src/worker-registry.ts +15 -2
- package/src/worker-thread/do-protocol.ts +237 -0
- package/src/worker-thread/entry.ts +453 -0
- package/src/worker-thread/execution-context.ts +53 -0
- package/src/worker-thread/executor.ts +595 -0
- package/src/worker-thread/protocol.ts +552 -0
- package/src/worker-thread/remote-trace-store.ts +90 -0
- package/src/worker-thread/rpc-client.ts +5 -0
- package/src/worker-thread/rpc-shared.ts +503 -0
- package/src/worker-thread/serialize.ts +37 -0
- package/src/worker-thread/stream-shared.ts +414 -0
- package/src/worker-thread/thread-env.ts +350 -0
- package/src/worker-thread/wire-handlers.ts +80 -0
- package/src/worker-thread/ws-bridge-shared.ts +482 -0
- package/dist/types/bindings/do-websocket-bridge.d.ts +0 -60
- package/dist/types/module-cache.d.ts +0 -23
- package/src/bindings/do-websocket-bridge.ts +0 -79
- package/src/module-cache.ts +0 -58
- 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
|
-
}
|