@effect/platform-bun 4.0.2 → 4.0.4
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/AGENTS.md +24 -0
- package/CLAUDE.md +24 -0
- package/ai-docs/package.json +2 -2
- package/ai-docs/src/52_graphql/10_basics.ts +111 -0
- package/ai-docs/src/52_graphql/20_testing.ts +45 -0
- package/ai-docs/src/52_graphql/30_batching.ts +116 -0
- package/ai-docs/src/52_graphql/40_aliased-batching.ts +122 -0
- package/ai-docs/src/52_graphql/50_errors-and-retries.ts +74 -0
- package/ai-docs/src/52_graphql/index.md +6 -0
- package/dist/BunAddressResolver.d.ts +15 -0
- package/dist/BunAddressResolver.d.ts.map +1 -0
- package/dist/BunAddressResolver.js +15 -0
- package/dist/BunAddressResolver.js.map +1 -0
- package/dist/BunDatagramSocket.d.ts +227 -0
- package/dist/BunDatagramSocket.d.ts.map +1 -0
- package/dist/BunDatagramSocket.js +502 -0
- package/dist/BunDatagramSocket.js.map +1 -0
- package/dist/BunDns.d.ts +72 -0
- package/dist/BunDns.d.ts.map +1 -0
- package/dist/BunDns.js +66 -0
- package/dist/BunDns.js.map +1 -0
- package/dist/BunSocket.d.ts +4 -2
- package/dist/BunSocket.d.ts.map +1 -1
- package/dist/BunSocket.js +16 -6
- package/dist/BunSocket.js.map +1 -1
- package/dist/BunTerminal.d.ts +4 -3
- package/dist/BunTerminal.d.ts.map +1 -1
- package/dist/BunTerminal.js +4 -3
- package/dist/BunTerminal.js.map +1 -1
- package/dist/BunWorkerRunner.d.ts.map +1 -1
- package/dist/BunWorkerRunner.js +0 -1
- package/dist/BunWorkerRunner.js.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/package.json +7 -5
- package/src/BunAddressResolver.ts +15 -0
- package/src/BunDatagramSocket.ts +666 -0
- package/src/BunDns.ts +82 -0
- package/src/BunSocket.ts +25 -6
- package/src/BunTerminal.ts +4 -3
- package/src/BunWorkerRunner.ts +0 -1
- package/src/index.ts +18 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun UDP adapter for `effect/socket/DatagramSocket`, built on the native
|
|
3
|
+
* `Bun.udpSocket`. Requires Bun 1.4 or later: earlier versions throw `EAGAIN`
|
|
4
|
+
* from `send` instead of returning `false`, and never fire `drain`.
|
|
5
|
+
*
|
|
6
|
+
* `make` opens a new Bun socket for each reader acquisition, `fromUdpSocket`
|
|
7
|
+
* adopts a socket the caller creates, and `layer` provides the
|
|
8
|
+
* `DatagramSocket` service.
|
|
9
|
+
*
|
|
10
|
+
* The reader owns the native socket, so a send-only client must still acquire
|
|
11
|
+
* a reader, or its writes wait forever. Bun sends synchronously: a send error
|
|
12
|
+
* fails the write that caused it, and when the kernel buffer is full the write
|
|
13
|
+
* waits for `drain` and sends what is left.
|
|
14
|
+
*
|
|
15
|
+
* `writeAll` sends the batch with one `sendMany` call and fails with its first
|
|
16
|
+
* error. Which datagrams went out is unspecified, nothing is resent, and the
|
|
17
|
+
* error has no `address`, because Bun reports one error for the whole call.
|
|
18
|
+
*
|
|
19
|
+
* ICMP reports, such as a refused port, arrive after the write has completed,
|
|
20
|
+
* so they go to the `onError` option as a `DatagramSocketReadError` with no
|
|
21
|
+
* address.
|
|
22
|
+
*
|
|
23
|
+
* On macOS and Windows a poll error closes the socket without any event. The
|
|
24
|
+
* adapter notices on the next write, which fails with
|
|
25
|
+
* `DatagramSocketClosedError`, as do the reader and any write waiting for
|
|
26
|
+
* `drain`. Until then those waiting writes and `pull` keep waiting. The same
|
|
27
|
+
* applies when the caller closes a socket adopted with `fromUdpSocket`.
|
|
28
|
+
*
|
|
29
|
+
* **Example** (Echo server)
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { BunDatagramSocket } from "@effect/platform-bun"
|
|
33
|
+
* import { Effect } from "effect"
|
|
34
|
+
*
|
|
35
|
+
* const echo = Effect.gen(function*() {
|
|
36
|
+
* const socket = yield* BunDatagramSocket.make({ bind: { port: 9000 } })
|
|
37
|
+
* const reader = yield* socket.reader
|
|
38
|
+
* const writer = yield* socket.writer
|
|
39
|
+
* while (true) {
|
|
40
|
+
* for (const received of yield* reader.pull) {
|
|
41
|
+
* yield* writer.write({ payload: received.payload, address: received })
|
|
42
|
+
* }
|
|
43
|
+
* }
|
|
44
|
+
* }).pipe(Effect.scoped)
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* **Example** (Send-only client with a connected socket)
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* import { BunDatagramSocket } from "@effect/platform-bun"
|
|
51
|
+
* import { Effect } from "effect"
|
|
52
|
+
*
|
|
53
|
+
* const send = (payload: string) =>
|
|
54
|
+
* Effect.gen(function*() {
|
|
55
|
+
* const socket = yield* BunDatagramSocket.make({
|
|
56
|
+
* connect: { address: "localhost", port: 9000 }
|
|
57
|
+
* })
|
|
58
|
+
* // Required even for send-only clients.
|
|
59
|
+
* yield* socket.reader
|
|
60
|
+
* const writer = yield* socket.writer
|
|
61
|
+
* yield* writer.write({ payload })
|
|
62
|
+
* }).pipe(Effect.scoped)
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* **Example** (Adopting a socket)
|
|
66
|
+
*
|
|
67
|
+
* ```ts
|
|
68
|
+
* import { BunDatagramSocket } from "@effect/platform-bun"
|
|
69
|
+
* import { Effect } from "effect"
|
|
70
|
+
*
|
|
71
|
+
* const receive = Effect.gen(function*() {
|
|
72
|
+
* // `fromUdpSocket` replaces the socket's handlers with its own
|
|
73
|
+
* const socket = yield* BunDatagramSocket.fromUdpSocket(
|
|
74
|
+
* Effect.promise(() => Bun.udpSocket({ hostname: "127.0.0.1", port: 9000 }))
|
|
75
|
+
* )
|
|
76
|
+
* const reader = yield* socket.reader
|
|
77
|
+
* return yield* reader.pull
|
|
78
|
+
* }).pipe(Effect.scoped)
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* @stability experimental
|
|
82
|
+
* @since 4.0.0
|
|
83
|
+
*/
|
|
84
|
+
import * as Effect from "effect/Effect"
|
|
85
|
+
import * as Layer from "effect/Layer"
|
|
86
|
+
import * as NetAddress from "effect/net/NetAddress"
|
|
87
|
+
import * as Predicate from "effect/Predicate"
|
|
88
|
+
import type * as Scope from "effect/Scope"
|
|
89
|
+
import * as DatagramSocket from "effect/socket/DatagramSocket"
|
|
90
|
+
import * as Dns from "node:dns"
|
|
91
|
+
import * as Net from "node:net"
|
|
92
|
+
import * as Os from "node:os"
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* An open-time endpoint. Hostnames resolve once per reader acquisition via
|
|
96
|
+
* `node:dns.lookup`, avoiding Bun's synchronous `getaddrinfo`.
|
|
97
|
+
* An `InetAddress` can be passed directly.
|
|
98
|
+
*
|
|
99
|
+
* @stability experimental
|
|
100
|
+
* @category models
|
|
101
|
+
* @since 4.0.0
|
|
102
|
+
*/
|
|
103
|
+
export interface Endpoint {
|
|
104
|
+
readonly address: string | NetAddress.IpAddress
|
|
105
|
+
readonly port: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The local address and port to bind. Defaults to `0.0.0.0` (or `::` for
|
|
110
|
+
* `family: "ipv6"`) and an ephemeral port.
|
|
111
|
+
*
|
|
112
|
+
* @stability experimental
|
|
113
|
+
* @category models
|
|
114
|
+
* @since 4.0.0
|
|
115
|
+
*/
|
|
116
|
+
export interface BindOptions {
|
|
117
|
+
readonly address?: string | NetAddress.IpAddress | undefined
|
|
118
|
+
readonly port?: number | undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Options for `fromUdpSocket`. The caller configures the adopted socket, so
|
|
123
|
+
* only the options that live in JavaScript apply.
|
|
124
|
+
*
|
|
125
|
+
* @stability experimental
|
|
126
|
+
* @category models
|
|
127
|
+
* @since 4.0.0
|
|
128
|
+
*/
|
|
129
|
+
export interface AdoptOptions {
|
|
130
|
+
readonly peer?: Endpoint | undefined
|
|
131
|
+
readonly receiveBuffer?: DatagramSocket.ReceiveBufferOptions | undefined
|
|
132
|
+
readonly onError?: ((error: DatagramSocket.DatagramSocketError) => void) | undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Options for `make` and `layer`.
|
|
137
|
+
*
|
|
138
|
+
* **Details**
|
|
139
|
+
*
|
|
140
|
+
* `peer` is a default destination and doesn't filter senders. `connect`
|
|
141
|
+
* connects the socket when Bun creates it, so the kernel filters senders. The
|
|
142
|
+
* two can't be combined.
|
|
143
|
+
*
|
|
144
|
+
* Family selection, in order: explicit `family`, a `bind` IP literal, the
|
|
145
|
+
* `peer` or `connect` address, a `bind` hostname, then `"ipv4"`. Hostname
|
|
146
|
+
* lookups use the family selected so far, or prefer IPv4 if none is set.
|
|
147
|
+
*
|
|
148
|
+
* `reuseAddress` means `SO_REUSEADDR` on Linux and `SO_REUSEPORT` on BSD and
|
|
149
|
+
* macOS.
|
|
150
|
+
*
|
|
151
|
+
* `multicast.interface` is the egress interface for datagrams sent to a group,
|
|
152
|
+
* which is a different socket option from the ingress `interface` of
|
|
153
|
+
* `Reader.joinMulticast`.
|
|
154
|
+
*
|
|
155
|
+
* @stability experimental
|
|
156
|
+
* @category models
|
|
157
|
+
* @since 4.0.0
|
|
158
|
+
*/
|
|
159
|
+
export type Options =
|
|
160
|
+
& {
|
|
161
|
+
readonly bind?: BindOptions | undefined
|
|
162
|
+
readonly family?: "ipv4" | "ipv6" | undefined
|
|
163
|
+
readonly reuseAddress?: boolean | undefined
|
|
164
|
+
readonly reusePort?: boolean | undefined
|
|
165
|
+
readonly ipv6Only?: boolean | undefined
|
|
166
|
+
readonly broadcast?: boolean | undefined
|
|
167
|
+
readonly ttl?: number | undefined
|
|
168
|
+
readonly multicast?: {
|
|
169
|
+
readonly interface?: NetAddress.MulticastInterface | undefined
|
|
170
|
+
readonly loopback?: boolean | undefined
|
|
171
|
+
readonly ttl?: number | undefined
|
|
172
|
+
} | undefined
|
|
173
|
+
readonly receiveBuffer?: DatagramSocket.ReceiveBufferOptions | undefined
|
|
174
|
+
readonly onError?: ((error: DatagramSocket.DatagramSocketError) => void) | undefined
|
|
175
|
+
}
|
|
176
|
+
& (
|
|
177
|
+
| { readonly peer?: Endpoint | undefined; readonly connect?: undefined }
|
|
178
|
+
| { readonly connect: Endpoint; readonly peer?: undefined }
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A native Bun UDP socket, connected or not.
|
|
183
|
+
*
|
|
184
|
+
* @stability experimental
|
|
185
|
+
* @category models
|
|
186
|
+
* @since 4.0.0
|
|
187
|
+
*/
|
|
188
|
+
export type UdpSocket = Bun.udp.Socket<"buffer"> | Bun.udp.ConnectedSocket<"buffer">
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Creates a `DatagramSocket` that opens and binds a new Bun UDP socket for
|
|
192
|
+
* each reader acquisition.
|
|
193
|
+
*
|
|
194
|
+
* **Details**
|
|
195
|
+
*
|
|
196
|
+
* Creating the socket never fails. Binding, applying options and resolving
|
|
197
|
+
* names happen on reader acquisition and fail it with a `DatagramSocketError`.
|
|
198
|
+
* Hostnames in `bind`, `peer` and `connect` are resolved once per acquisition;
|
|
199
|
+
* sends never wait for DNS.
|
|
200
|
+
*
|
|
201
|
+
* Invalid `receiveBuffer.capacity` values cause a defect; use a positive
|
|
202
|
+
* safe integer.
|
|
203
|
+
*
|
|
204
|
+
* @stability experimental
|
|
205
|
+
* @category constructors
|
|
206
|
+
* @since 4.0.0
|
|
207
|
+
*/
|
|
208
|
+
export const make = (options: Options = {}): Effect.Effect<DatagramSocket.DatagramSocket> =>
|
|
209
|
+
DatagramSocket.makeFromBackingSocket((events) => open(options, events), options)
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Adopts a Bun UDP socket.
|
|
213
|
+
*
|
|
214
|
+
* **Details**
|
|
215
|
+
*
|
|
216
|
+
* `acquire` runs once per reader acquisition, inside the reader's scope, and
|
|
217
|
+
* the adapter closes the socket when that scope ends. Whether the socket is
|
|
218
|
+
* connected is checked once at open.
|
|
219
|
+
*
|
|
220
|
+
* The adapter installs its own `data`, `drain` and `error` handlers with
|
|
221
|
+
* `socket.reload`, which replaces the ones the caller passed to
|
|
222
|
+
* `Bun.udpSocket`. Packets that arrive before `acquire` completes still go to
|
|
223
|
+
* the caller's handlers.
|
|
224
|
+
*
|
|
225
|
+
* @stability experimental
|
|
226
|
+
* @category constructors
|
|
227
|
+
* @since 4.0.0
|
|
228
|
+
*/
|
|
229
|
+
export const fromUdpSocket = <R>(
|
|
230
|
+
acquire: Effect.Effect<UdpSocket, DatagramSocket.DatagramSocketError, R>,
|
|
231
|
+
options: AdoptOptions = {}
|
|
232
|
+
): Effect.Effect<DatagramSocket.DatagramSocket, never, Exclude<R, Scope.Scope>> =>
|
|
233
|
+
DatagramSocket.makeFromBackingSocket(
|
|
234
|
+
(events) => Effect.flatMap(acquire, (socket) => adopt(socket, options, events)),
|
|
235
|
+
options
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Provides a `DatagramSocket` built with `make`.
|
|
240
|
+
*
|
|
241
|
+
* @stability experimental
|
|
242
|
+
* @category layers
|
|
243
|
+
* @since 4.0.0
|
|
244
|
+
*/
|
|
245
|
+
export const layer = (options: Options = {}): Layer.Layer<DatagramSocket.DatagramSocket> =>
|
|
246
|
+
Layer.effect(DatagramSocket.DatagramSocket, make(options))
|
|
247
|
+
|
|
248
|
+
type Family = "ipv4" | "ipv6"
|
|
249
|
+
|
|
250
|
+
const noScopeIds: ReadonlyMap<string, number> = new Map()
|
|
251
|
+
|
|
252
|
+
// Windows reports numeric zones, which parse and format without a map
|
|
253
|
+
const scopeIdsFor = (family: Family): ReadonlyMap<string, number> => {
|
|
254
|
+
if (family === "ipv4" || process.platform === "win32") return noScopeIds
|
|
255
|
+
try {
|
|
256
|
+
return NetAddress.scopeIdsFromInterfaces(Object.entries(Os.networkInterfaces()))
|
|
257
|
+
} catch {
|
|
258
|
+
return noScopeIds
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const familyOf = (address: string | NetAddress.IpAddress): Family | undefined => {
|
|
263
|
+
if (typeof address !== "string") return NetAddress.isIpv4Address(address) ? "ipv4" : "ipv6"
|
|
264
|
+
const version = Net.isIP(address)
|
|
265
|
+
return version === 4 ? "ipv4" : version === 6 ? "ipv6" : undefined
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// An endpoint may be a whole `InetAddress`, whose IPv6 scope must survive
|
|
269
|
+
const formatHost = (
|
|
270
|
+
endpoint: { readonly address?: string | NetAddress.IpAddress | undefined },
|
|
271
|
+
scopeIds: ReadonlyMap<string, number>
|
|
272
|
+
): string | undefined =>
|
|
273
|
+
NetAddress.isInetAddress(endpoint)
|
|
274
|
+
? NetAddress.formatNativeHost(endpoint, scopeIds)
|
|
275
|
+
: endpoint.address === undefined || typeof endpoint.address === "string"
|
|
276
|
+
? endpoint.address
|
|
277
|
+
: NetAddress.formatIp(endpoint.address)
|
|
278
|
+
|
|
279
|
+
interface Resolved {
|
|
280
|
+
readonly host: string
|
|
281
|
+
readonly family: Family
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Resolves a hostname with one `lookup`, preferring IPv4 unless `family` is
|
|
285
|
+
// fixed. IP literals and `IpAddress` values need no lookup.
|
|
286
|
+
const resolve = (
|
|
287
|
+
address: string | NetAddress.IpAddress | undefined,
|
|
288
|
+
family: Family | undefined
|
|
289
|
+
): Effect.Effect<Resolved | undefined, DatagramSocket.DatagramSocketError> => {
|
|
290
|
+
if (typeof address !== "string") return Effect.undefined
|
|
291
|
+
const literal = familyOf(address)
|
|
292
|
+
if (literal !== undefined) return Effect.succeed({ host: address, family: literal })
|
|
293
|
+
return Effect.callback((resume) => {
|
|
294
|
+
const fail = (error: unknown) => resume(Effect.fail(openError(error, "AddressNotAvailable")))
|
|
295
|
+
try {
|
|
296
|
+
Dns.lookup(
|
|
297
|
+
address,
|
|
298
|
+
{ all: true, family: family === "ipv4" ? 4 : family === "ipv6" ? 6 : 0 },
|
|
299
|
+
(error, results) => {
|
|
300
|
+
if (error) return fail(error)
|
|
301
|
+
const result = results.find((result) => result.family === 4) ?? results[0]
|
|
302
|
+
if (result === undefined) return fail(new Error(`${address} has no addresses`))
|
|
303
|
+
resume(Effect.succeed({ host: result.address, family: result.family === 6 ? "ipv6" : "ipv4" }))
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
} catch (error) {
|
|
307
|
+
fail(error)
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Picks the family as documented on `Options` and turns `bind` and the remote
|
|
313
|
+
// endpoint into IP literals
|
|
314
|
+
const resolveOpen = Effect.fnUntraced(function*(options: Options) {
|
|
315
|
+
const bind = options.bind
|
|
316
|
+
const remote = options.connect ?? options.peer
|
|
317
|
+
const fixed = options.family ?? (bind?.address === undefined ? undefined : familyOf(bind.address))
|
|
318
|
+
const resolvedRemote = yield* resolve(remote?.address, fixed)
|
|
319
|
+
const remoteFamily = resolvedRemote?.family ?? (remote === undefined ? undefined : familyOf(remote.address))
|
|
320
|
+
const resolvedBind = yield* resolve(bind?.address, fixed ?? remoteFamily)
|
|
321
|
+
const family = fixed ?? remoteFamily ?? resolvedBind?.family ?? "ipv4"
|
|
322
|
+
const scopeIds = scopeIdsFor(family)
|
|
323
|
+
return {
|
|
324
|
+
scopeIds,
|
|
325
|
+
host: resolvedBind?.host ?? (bind === undefined ? undefined : formatHost(bind, scopeIds)) ??
|
|
326
|
+
(family === "ipv6" ? "::" : "0.0.0.0"),
|
|
327
|
+
remote: remote === undefined ? undefined : {
|
|
328
|
+
host: resolvedRemote?.host ?? formatHost(remote, scopeIds)!,
|
|
329
|
+
port: remote.port
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
const errorCode = (error: unknown): unknown => Predicate.hasProperty(error, "code") ? error.code : undefined
|
|
335
|
+
|
|
336
|
+
const openError = (
|
|
337
|
+
error: unknown,
|
|
338
|
+
kind?: DatagramSocket.DatagramSocketOpenError["kind"]
|
|
339
|
+
): DatagramSocket.DatagramSocketError => {
|
|
340
|
+
if (kind === undefined) {
|
|
341
|
+
const code = errorCode(error)
|
|
342
|
+
kind = code === "EADDRINUSE"
|
|
343
|
+
? "AddressInUse"
|
|
344
|
+
: code === "EADDRNOTAVAIL"
|
|
345
|
+
? "AddressNotAvailable"
|
|
346
|
+
: code === "EACCES" || code === "EPERM"
|
|
347
|
+
? "PermissionDenied"
|
|
348
|
+
: "Unknown"
|
|
349
|
+
}
|
|
350
|
+
return new DatagramSocket.DatagramSocketError({
|
|
351
|
+
reason: new DatagramSocket.DatagramSocketOpenError({ kind, cause: error })
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const ioKind = (error: unknown): DatagramSocket.IoErrorKind => {
|
|
356
|
+
switch (errorCode(error)) {
|
|
357
|
+
case "EMSGSIZE":
|
|
358
|
+
return "MessageTooLarge"
|
|
359
|
+
case "EHOSTUNREACH":
|
|
360
|
+
case "ENETUNREACH":
|
|
361
|
+
case "EHOSTDOWN":
|
|
362
|
+
case "ENETDOWN":
|
|
363
|
+
return "Unreachable"
|
|
364
|
+
case "ECONNREFUSED":
|
|
365
|
+
return "ConnectionRefused"
|
|
366
|
+
case "EACCES":
|
|
367
|
+
case "EPERM":
|
|
368
|
+
return "PermissionDenied"
|
|
369
|
+
default:
|
|
370
|
+
return "Unknown"
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const closedError = (): DatagramSocket.DatagramSocketError =>
|
|
375
|
+
new DatagramSocket.DatagramSocketError({ reason: new DatagramSocket.DatagramSocketClosedError() })
|
|
376
|
+
|
|
377
|
+
const writeError = (error: unknown): DatagramSocket.DatagramSocketError =>
|
|
378
|
+
new DatagramSocket.DatagramSocketError({
|
|
379
|
+
reason: new DatagramSocket.DatagramSocketWriteError({ kind: ioKind(error), cause: error })
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
const readError = (error: unknown): DatagramSocket.DatagramSocketError =>
|
|
383
|
+
new DatagramSocket.DatagramSocketError({
|
|
384
|
+
reason: new DatagramSocket.DatagramSocketReadError({ kind: ioKind(error), cause: error })
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
// Bun throws a plain `Error` with this message when sending on a closed socket
|
|
388
|
+
const isClosedError = (error: unknown): boolean =>
|
|
389
|
+
Predicate.hasProperty(error, "message") && error.message === "Socket is closed"
|
|
390
|
+
|
|
391
|
+
// uSockets `LIBUS_SOCKET_*` flags, not part of Bun's public API. Bun's own
|
|
392
|
+
// `node:dgram` passes them the same way.
|
|
393
|
+
// https://github.com/oven-sh/bun/blob/main/packages/bun-usockets/src/libusockets.h
|
|
394
|
+
const LIBUS_SOCKET_REUSE_PORT = 4
|
|
395
|
+
const LIBUS_SOCKET_IPV6_ONLY = 8
|
|
396
|
+
const LIBUS_SOCKET_REUSE_ADDR = 16
|
|
397
|
+
|
|
398
|
+
const open = Effect.fnUntraced(function*(options: Options, events: DatagramSocket.BackingEvents) {
|
|
399
|
+
const { host, remote, scopeIds } = yield* resolveOpen(options)
|
|
400
|
+
const backing = new BunBackingSocket(events)
|
|
401
|
+
const flags = (options.reusePort ? LIBUS_SOCKET_REUSE_PORT : 0) |
|
|
402
|
+
(options.ipv6Only ? LIBUS_SOCKET_IPV6_ONLY : 0) |
|
|
403
|
+
(options.reuseAddress ? LIBUS_SOCKET_REUSE_ADDR : 0)
|
|
404
|
+
const connect = options.connect === undefined ? undefined : { hostname: remote!.host, port: remote!.port }
|
|
405
|
+
const socket = yield* Effect.tryPromise({
|
|
406
|
+
try: () =>
|
|
407
|
+
Bun.udpSocket({
|
|
408
|
+
hostname: host,
|
|
409
|
+
port: options.bind?.port ?? 0,
|
|
410
|
+
socket: backing.handlers,
|
|
411
|
+
...(flags === 0 ? undefined : { flags }),
|
|
412
|
+
...(connect === undefined ? undefined : { connect })
|
|
413
|
+
} as Bun.udp.SocketOptions<"buffer">),
|
|
414
|
+
catch: (error) => openError(error)
|
|
415
|
+
})
|
|
416
|
+
// closes the new socket if `attach` or an option fails
|
|
417
|
+
backing.socket = socket
|
|
418
|
+
yield* Effect.try({
|
|
419
|
+
try: () => {
|
|
420
|
+
backing.attach(socket, scopeIds, remote, connect !== undefined)
|
|
421
|
+
if (options.broadcast !== undefined) socket.setBroadcast(options.broadcast)
|
|
422
|
+
if (options.ttl !== undefined) socket.setTTL(options.ttl)
|
|
423
|
+
const multicast = options.multicast
|
|
424
|
+
if (multicast?.ttl !== undefined) socket.setMulticastTTL(multicast.ttl)
|
|
425
|
+
if (multicast?.loopback !== undefined) socket.setMulticastLoopback(multicast.loopback)
|
|
426
|
+
if (multicast?.interface !== undefined) {
|
|
427
|
+
socket.setMulticastInterface(NetAddress.formatMulticastInterface(multicast.interface, scopeIds))
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
catch: (error) => openError(error)
|
|
431
|
+
}).pipe(Effect.onError(() => Effect.sync(() => backing.close())))
|
|
432
|
+
return backing
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const adopt = (
|
|
436
|
+
socket: UdpSocket,
|
|
437
|
+
options: AdoptOptions,
|
|
438
|
+
events: DatagramSocket.BackingEvents
|
|
439
|
+
): Effect.Effect<DatagramSocket.BackingSocket, DatagramSocket.DatagramSocketError> => {
|
|
440
|
+
const backing = new BunBackingSocket(events)
|
|
441
|
+
backing.socket = socket
|
|
442
|
+
return Effect.gen(function*() {
|
|
443
|
+
if (socket.closed) return yield* Effect.fail(openError(new Error("The adopted Bun UDP socket is closed")))
|
|
444
|
+
const family: Family = yield* Effect.try({
|
|
445
|
+
try: () => {
|
|
446
|
+
// Bun reads `{ socket }`, although the types declare the handler itself
|
|
447
|
+
socket.reload({ socket: backing.handlers } as any)
|
|
448
|
+
return socket.address.family === "IPv6" ? "ipv6" : "ipv4"
|
|
449
|
+
},
|
|
450
|
+
catch: (error) => openError(error)
|
|
451
|
+
})
|
|
452
|
+
const scopeIds = scopeIdsFor(family)
|
|
453
|
+
// an unconnected socket has the property, set to `undefined`
|
|
454
|
+
const remote = "remoteAddress" in socket ? socket.remoteAddress : undefined
|
|
455
|
+
if (remote !== undefined) {
|
|
456
|
+
return backing.attach(socket, scopeIds, { host: remote.address, port: remote.port }, true)
|
|
457
|
+
}
|
|
458
|
+
const peer = options.peer
|
|
459
|
+
const resolved = yield* resolve(peer?.address, family)
|
|
460
|
+
return backing.attach(
|
|
461
|
+
socket,
|
|
462
|
+
scopeIds,
|
|
463
|
+
peer === undefined ? undefined : { host: resolved?.host ?? formatHost(peer, scopeIds)!, port: peer.port },
|
|
464
|
+
false
|
|
465
|
+
)
|
|
466
|
+
}).pipe(Effect.onError(() => Effect.sync(() => backing.close())))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Sends the kernel refused while its buffer was full, in `sendMany`'s flat
|
|
470
|
+
// form: `[data, port, host]` per datagram, or plain payloads when connected
|
|
471
|
+
interface Waiting {
|
|
472
|
+
entries: ReadonlyArray<Uint8Array | string | number>
|
|
473
|
+
readonly done: (error?: DatagramSocket.DatagramSocketError) => void
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
class BunBackingSocket implements DatagramSocket.BackingSocket {
|
|
477
|
+
readonly events: DatagramSocket.BackingEvents
|
|
478
|
+
// installed when the socket is created, so packets can arrive before `attach`
|
|
479
|
+
readonly handlers: Bun.udp.SocketHandler<"buffer">
|
|
480
|
+
socket: UdpSocket | undefined = undefined
|
|
481
|
+
address: DatagramSocket.BackingAddress = { host: "", port: 0 }
|
|
482
|
+
scopeIds: ReadonlyMap<string, number> = noScopeIds
|
|
483
|
+
peer: DatagramSocket.BackingAddress | undefined = undefined
|
|
484
|
+
connected = false
|
|
485
|
+
// entries per datagram in `sendMany`'s flat list
|
|
486
|
+
stride = 3
|
|
487
|
+
// waiting sends, oldest first; later sends queue behind them to keep order
|
|
488
|
+
waiting: Array<Waiting> = []
|
|
489
|
+
closing = false
|
|
490
|
+
|
|
491
|
+
constructor(events: DatagramSocket.BackingEvents) {
|
|
492
|
+
this.events = events
|
|
493
|
+
this.handlers = {
|
|
494
|
+
data: (_socket, payload, port, host) => events.onPacket(payload, host, port),
|
|
495
|
+
// also fires once right after creation, when nothing is waiting
|
|
496
|
+
drain: () => this.flush(),
|
|
497
|
+
// Bun crashes the process without an `error` handler. It passes the
|
|
498
|
+
// error alone, despite the declared `(socket, error)`.
|
|
499
|
+
error: ((first: unknown, second?: unknown) => this.onNativeError(second === undefined ? first : second)) as any
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
attach(
|
|
504
|
+
socket: UdpSocket,
|
|
505
|
+
scopeIds: ReadonlyMap<string, number>,
|
|
506
|
+
peer: DatagramSocket.BackingAddress | undefined,
|
|
507
|
+
connected: boolean
|
|
508
|
+
): this {
|
|
509
|
+
this.socket = socket
|
|
510
|
+
this.address = { host: socket.address.address, port: socket.address.port }
|
|
511
|
+
this.scopeIds = scopeIds
|
|
512
|
+
this.peer = peer
|
|
513
|
+
this.connected = connected
|
|
514
|
+
this.stride = connected ? 1 : 3
|
|
515
|
+
return this
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
send(
|
|
519
|
+
payload: Uint8Array,
|
|
520
|
+
destination: DatagramSocket.BackingAddress | undefined,
|
|
521
|
+
done: (error?: DatagramSocket.DatagramSocketError) => void
|
|
522
|
+
) {
|
|
523
|
+
const entries = destination === undefined ? [payload] : [payload, destination.port, destination.host]
|
|
524
|
+
if (this.waiting.length !== 0) return this.wait(entries, done)
|
|
525
|
+
let sent: boolean
|
|
526
|
+
try {
|
|
527
|
+
sent = destination === undefined
|
|
528
|
+
? (this.socket as Bun.udp.ConnectedSocket<"buffer">).send(payload)
|
|
529
|
+
: (this.socket as Bun.udp.Socket<"buffer">).send(payload, destination.port, destination.host)
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return done(this.sendError(error))
|
|
532
|
+
}
|
|
533
|
+
if (sent) return done()
|
|
534
|
+
this.wait(entries, done)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
sendMany(
|
|
538
|
+
payloads: ReadonlyArray<Uint8Array>,
|
|
539
|
+
destinations: ReadonlyArray<DatagramSocket.BackingAddress | undefined>,
|
|
540
|
+
done: (error?: DatagramSocket.DatagramSocketError, index?: number) => void
|
|
541
|
+
) {
|
|
542
|
+
let entries: ReadonlyArray<Uint8Array | string | number> = payloads
|
|
543
|
+
if (!this.connected) {
|
|
544
|
+
const flat = new Array<Uint8Array | string | number>(payloads.length * 3)
|
|
545
|
+
for (let i = 0; i < payloads.length; i++) {
|
|
546
|
+
flat[i * 3] = payloads[i]
|
|
547
|
+
flat[i * 3 + 1] = destinations[i]!.port
|
|
548
|
+
flat[i * 3 + 2] = destinations[i]!.host
|
|
549
|
+
}
|
|
550
|
+
entries = flat
|
|
551
|
+
}
|
|
552
|
+
if (this.waiting.length !== 0) return this.wait(entries, done)
|
|
553
|
+
let sent: number
|
|
554
|
+
try {
|
|
555
|
+
sent = this.socket!.sendMany(entries as any)
|
|
556
|
+
} catch (error) {
|
|
557
|
+
// Bun throws for the whole call, so no index is known
|
|
558
|
+
return done(this.sendError(error))
|
|
559
|
+
}
|
|
560
|
+
if (sent === payloads.length) return done()
|
|
561
|
+
this.wait(entries.slice(sent * this.stride), done)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
joinMulticast<A extends NetAddress.IpAddress>({ group, interface: ingress, source }: {
|
|
565
|
+
readonly group: NetAddress.MulticastAddress<A>
|
|
566
|
+
readonly interface?: NetAddress.MulticastInterface<A> | undefined
|
|
567
|
+
readonly source?: A | undefined
|
|
568
|
+
}): Effect.Effect<() => Effect.Effect<void>, DatagramSocket.DatagramSocketError> {
|
|
569
|
+
return Effect.try({
|
|
570
|
+
try: () => {
|
|
571
|
+
const socket = this.socket!
|
|
572
|
+
const groupHost = NetAddress.formatIp(group)
|
|
573
|
+
const interfaceHost = ingress === undefined
|
|
574
|
+
? undefined
|
|
575
|
+
: NetAddress.formatMulticastInterface(ingress, this.scopeIds)
|
|
576
|
+
if (source === undefined) {
|
|
577
|
+
if (!socket.addMembership(groupHost, interfaceHost)) throw new Error(`Could not join ${groupHost}`)
|
|
578
|
+
return () => Effect.sync(() => socket.dropMembership(groupHost, interfaceHost))
|
|
579
|
+
}
|
|
580
|
+
const sourceHost = NetAddress.formatIp(source)
|
|
581
|
+
if (!socket.addSourceSpecificMembership(sourceHost, groupHost, interfaceHost)) {
|
|
582
|
+
throw new Error(`Could not join ${groupHost} from ${sourceHost}`)
|
|
583
|
+
}
|
|
584
|
+
return () => Effect.sync(() => socket.dropSourceSpecificMembership(sourceHost, groupHost, interfaceHost))
|
|
585
|
+
},
|
|
586
|
+
catch: (error) => openError(error)
|
|
587
|
+
})
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
close() {
|
|
591
|
+
if (this.closing) return
|
|
592
|
+
this.closing = true
|
|
593
|
+
this.failWaiting()
|
|
594
|
+
try {
|
|
595
|
+
this.socket?.close()
|
|
596
|
+
} catch {
|
|
597
|
+
// already closed
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
wait(
|
|
602
|
+
entries: ReadonlyArray<Uint8Array | string | number>,
|
|
603
|
+
done: (error?: DatagramSocket.DatagramSocketError) => void
|
|
604
|
+
) {
|
|
605
|
+
// a closed socket fires no event and never drains
|
|
606
|
+
if (this.socket?.closed) {
|
|
607
|
+
this.closedUnderneath()
|
|
608
|
+
return done(closedError())
|
|
609
|
+
}
|
|
610
|
+
this.waiting.push({ entries, done })
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// on `drain`, send what the kernel refused, in order, until it refuses again
|
|
614
|
+
flush() {
|
|
615
|
+
const socket = this.socket
|
|
616
|
+
if (socket === undefined) return
|
|
617
|
+
// re-read `waiting`: a completion can close the socket and replace it
|
|
618
|
+
while (!this.closing && this.waiting.length !== 0) {
|
|
619
|
+
const next = this.waiting[0]
|
|
620
|
+
let sent: number
|
|
621
|
+
try {
|
|
622
|
+
sent = socket.sendMany(next.entries as any)
|
|
623
|
+
} catch (error) {
|
|
624
|
+
this.waiting.shift()
|
|
625
|
+
next.done(this.sendError(error))
|
|
626
|
+
continue
|
|
627
|
+
}
|
|
628
|
+
const rest = sent * this.stride
|
|
629
|
+
if (rest < next.entries.length) {
|
|
630
|
+
next.entries = next.entries.slice(rest)
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
this.waiting.shift()
|
|
634
|
+
next.done()
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
sendError(error: unknown): DatagramSocket.DatagramSocketError {
|
|
639
|
+
// on macOS and Windows a poll error closes the socket silently
|
|
640
|
+
if (this.socket?.closed || isClosedError(error)) {
|
|
641
|
+
this.closedUnderneath()
|
|
642
|
+
return closedError()
|
|
643
|
+
}
|
|
644
|
+
return writeError(error)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
onNativeError(error: unknown) {
|
|
648
|
+
if (this.closing) return
|
|
649
|
+
if (this.socket?.closed) return this.closedUnderneath()
|
|
650
|
+
// an ICMP report, with no write left to fail
|
|
651
|
+
this.events.onError(readError(error))
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
closedUnderneath() {
|
|
655
|
+
if (this.closing) return
|
|
656
|
+
this.closing = true
|
|
657
|
+
this.failWaiting()
|
|
658
|
+
this.events.onClose()
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
failWaiting() {
|
|
662
|
+
const waiting = this.waiting
|
|
663
|
+
this.waiting = []
|
|
664
|
+
for (let i = 0; i < waiting.length; i++) waiting[i].done(closedError())
|
|
665
|
+
}
|
|
666
|
+
}
|