@effect/platform-bun 4.0.0-rc.112 → 4.0.0-rc.114

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 (49) hide show
  1. package/AGENTS.md +24 -9
  2. package/CLAUDE.md +24 -9
  3. package/ai-docs/package.json +2 -2
  4. package/ai-docs/src/01_effect/01_basics/02_effect-fn.ts +18 -5
  5. package/ai-docs/src/01_effect/01_basics/index.md +5 -3
  6. package/ai-docs/src/01_effect/03_services/20_layer-composition.ts +1 -1
  7. package/ai-docs/src/01_effect/03_services/20_layer-unwrap.ts +2 -2
  8. package/ai-docs/src/01_effect/05_resources/10_acquire-release.ts +2 -2
  9. package/ai-docs/src/03_stream/30_encoding.ts +5 -7
  10. package/ai-docs/src/08_observability/10_logging.ts +1 -1
  11. package/ai-docs/src/70_cli/10_basics.ts +7 -7
  12. package/ai-docs/src/71_ai/10_language-model.ts +2 -2
  13. package/ai-docs/src/71_ai/20_tools.ts +1 -1
  14. package/ai-docs/src/71_ai/30_chat.ts +1 -1
  15. package/dist/BunClusterHttp.d.ts +2 -2
  16. package/dist/BunClusterHttp.d.ts.map +1 -1
  17. package/dist/BunClusterHttp.js +7 -4
  18. package/dist/BunClusterHttp.js.map +1 -1
  19. package/dist/BunClusterSocket.d.ts +1 -1
  20. package/dist/BunClusterSocket.d.ts.map +1 -1
  21. package/dist/BunClusterSocket.js +2 -2
  22. package/dist/BunClusterSocket.js.map +1 -1
  23. package/dist/BunHttpPlatform.d.ts.map +1 -1
  24. package/dist/BunHttpPlatform.js +7 -4
  25. package/dist/BunHttpPlatform.js.map +1 -1
  26. package/dist/BunHttpServer.d.ts +9 -6
  27. package/dist/BunHttpServer.d.ts.map +1 -1
  28. package/dist/BunHttpServer.js +138 -35
  29. package/dist/BunHttpServer.js.map +1 -1
  30. package/dist/BunRedis.d.ts +1 -1
  31. package/dist/BunRedis.d.ts.map +1 -1
  32. package/dist/BunRedis.js +3 -12
  33. package/dist/BunRedis.js.map +1 -1
  34. package/dist/BunSocket.d.ts +3 -3
  35. package/dist/BunSocket.d.ts.map +1 -1
  36. package/dist/BunSocket.js +6 -3
  37. package/dist/BunSocket.js.map +1 -1
  38. package/dist/BunStream.d.ts +1 -1
  39. package/dist/BunStream.d.ts.map +1 -1
  40. package/dist/BunStream.js +8 -2
  41. package/dist/BunStream.js.map +1 -1
  42. package/package.json +5 -5
  43. package/src/BunClusterHttp.ts +4 -4
  44. package/src/BunClusterSocket.ts +4 -2
  45. package/src/BunHttpPlatform.ts +8 -4
  46. package/src/BunHttpServer.ts +153 -56
  47. package/src/BunRedis.ts +2 -1
  48. package/src/BunSocket.ts +7 -4
  49. package/src/BunStream.ts +12 -3
@@ -12,6 +12,7 @@
12
12
  * @since 4.0.0
13
13
  */
14
14
  import type { Server as BunServer, ServerWebSocket } from "bun"
15
+ import type * as Arr from "effect/Array"
15
16
  import * as Config from "effect/Config"
16
17
  import type { ConfigError } from "effect/Config"
17
18
  import * as Context from "effect/Context"
@@ -20,14 +21,15 @@ import * as Duration from "effect/Duration"
20
21
  import * as Effect from "effect/Effect"
21
22
  import * as Exit from "effect/Exit"
22
23
  import * as Fiber from "effect/Fiber"
23
- import * as FiberSet from "effect/FiberSet"
24
24
  import type * as FileSystem from "effect/FileSystem"
25
- import { flow } from "effect/Function"
25
+ import { constVoid, flow } from "effect/Function"
26
26
  import * as Inspectable from "effect/Inspectable"
27
27
  import * as Layer from "effect/Layer"
28
28
  import * as Option from "effect/Option"
29
29
  import type * as Path from "effect/Path"
30
30
  import type * as Record from "effect/Record"
31
+ import * as Result from "effect/Result"
32
+ import * as Scheduler from "effect/Scheduler"
31
33
  import type * as Schema from "effect/Schema"
32
34
  import * as Scope from "effect/Scope"
33
35
  import * as Semaphore from "effect/Semaphore"
@@ -47,6 +49,7 @@ import * as ServerRequest from "effect/unstable/http/HttpServerRequest"
47
49
  import type * as ServerResponse from "effect/unstable/http/HttpServerResponse"
48
50
  import type * as Multipart from "effect/unstable/http/Multipart"
49
51
  import * as UrlParams from "effect/unstable/http/UrlParams"
52
+ import * as NetAddress from "effect/unstable/net/NetAddress"
50
53
  import * as Socket from "effect/unstable/socket/Socket"
51
54
  import * as Platform from "./BunHttpPlatform.ts"
52
55
  import * as BunMultipart from "./BunMultipart.ts"
@@ -114,6 +117,22 @@ export const make = Effect.fnUntraced(
114
117
  }
115
118
  ) {
116
119
  const scope = yield* Effect.scope
120
+ let listenOptions = options
121
+ if (!("unix" in options) || options.unix === undefined) {
122
+ const internetOptions = options as Bun.Serve.HostnamePortServeOptions<WebSocketContext>
123
+ let hostname = internetOptions.hostname ?? "::"
124
+ if (Result.isFailure(NetAddress.ipFromString(hostname))) {
125
+ hostname = yield* Effect.tryPromise({
126
+ try: async () => {
127
+ const result = await Bun.dns.lookup(hostname, { socketType: "tcp" })
128
+ if (result.length === 0) throw new globalThis.Error(`Could not resolve hostname: ${hostname}`)
129
+ return result[0].address
130
+ },
131
+ catch: (cause) => new Error.ServeError({ cause })
132
+ })
133
+ }
134
+ listenOptions = { ...options, hostname }
135
+ }
117
136
  const { compressionThreshold = MIN_COMPRESSIBLE_SIZE, ...websocket } = options.websocket ?? {}
118
137
  const handlerStack: Array<(request: Request, server: BunServer<WebSocketContext>) => Response | Promise<Response>> =
119
138
  [
@@ -122,7 +141,7 @@ export const make = Effect.fnUntraced(
122
141
  }
123
142
  ]
124
143
  const server = Bun.serve<WebSocketContext, R>({
125
- ...options as ServeOptions<R>,
144
+ ...listenOptions as ServeOptions<R>,
126
145
  fetch: handlerStack[0],
127
146
  websocket: {
128
147
  ...websocket,
@@ -134,16 +153,11 @@ export const make = Effect.fnUntraced(
134
153
  },
135
154
  close(ws, code, closeReason) {
136
155
  code = typeof code === "number" ? code : 1001
137
- Deferred.doneUnsafe(
138
- ws.data.closeDeferred,
139
- Socket.defaultCloseCodeIsError(code)
140
- ? Exit.fail(
141
- new Socket.SocketError({
142
- reason: new Socket.SocketCloseError({ code, closeReason })
143
- })
144
- )
145
- : Exit.void
146
- )
156
+ const error = new Socket.SocketError({
157
+ reason: new Socket.SocketCloseError({ code, closeReason })
158
+ })
159
+ ws.data.closeError = error
160
+ ws.data.onClose(error)
147
161
  }
148
162
  }
149
163
  })
@@ -158,8 +172,14 @@ export const make = Effect.fnUntraced(
158
172
 
159
173
  yield* Scope.addFinalizer(scope, shutdown)
160
174
 
175
+ const address = "unix" in options && options.unix !== undefined
176
+ ? NetAddress.unixPathAddress(options.unix)
177
+ : yield* Effect.fromResult(NetAddress.inetAddressFromIpString(server.hostname!, server.port!)).pipe(
178
+ Effect.mapError((cause) => new Error.ServeError({ cause }))
179
+ )
180
+
161
181
  return Server.make({
162
- address: { _tag: "TcpAddress", port: server.port!, hostname: server.hostname! },
182
+ address,
163
183
  serve: Effect.fnUntraced(function*(httpApp, middleware) {
164
184
  const parent = yield* Effect.fiber
165
185
  const services = parent.context
@@ -188,11 +208,17 @@ export const make = Effect.fnUntraced(
188
208
  yield* Scope.addFinalizerExit(serveScope, () => {
189
209
  const index = handlerStack.indexOf(handler)
190
210
  if (index !== -1) handlerStack.splice(index, 1)
191
- server.reload({ fetch: handlerStack[handlerStack.length - 1] })
211
+ server.reload({
212
+ fetch: handlerStack[handlerStack.length - 1],
213
+ ...(options.routes === undefined ? undefined : { routes: options.routes })
214
+ })
192
215
  return handlerStack.length === 1 ? preemptiveShutdown : Effect.void
193
216
  })
194
217
  handlerStack.push(handler)
195
- server.reload({ fetch: handler })
218
+ server.reload({
219
+ fetch: handler,
220
+ ...(options.routes === undefined ? undefined : { routes: options.routes })
221
+ })
196
222
  })
197
223
  })
198
224
  }
@@ -234,7 +260,9 @@ const makeResponse = (
234
260
  case "Empty": {
235
261
  return new Response(undefined, fields)
236
262
  }
237
- case "Uint8Array":
263
+ case "Uint8Array": {
264
+ return new Response(body.text ?? body.body as any, fields)
265
+ }
238
266
  case "Raw": {
239
267
  if (body.body instanceof Response) {
240
268
  for (const [key, value] of fields.headers.entries()) {
@@ -274,7 +302,7 @@ export const layerServer: <R extends string>(
274
302
  readonly gracefulShutdownTimeout?: Duration.Input | undefined
275
303
  readonly websocket?: WebSocketOptions | undefined
276
304
  }
277
- ) => Layer.Layer<Server.HttpServer> = flow(make, Layer.effect(Server.HttpServer)) as any
305
+ ) => Layer.Layer<Server.HttpServer, Error.ServeError> = flow(make, Layer.effect(Server.HttpServer)) as any
278
306
 
279
307
  /**
280
308
  * Layer that provides Bun HTTP support services: `HttpPlatform`, weak ETag generation, and `BunServices`.
@@ -308,7 +336,8 @@ export const layer = <R extends string>(
308
336
  | Server.HttpServer
309
337
  | HttpPlatform
310
338
  | Etag.Generator
311
- | BunServices.BunServices
339
+ | BunServices.BunServices,
340
+ Error.ServeError
312
341
  > => Layer.mergeAll(layerServer(options), layerHttpServices)
313
342
 
314
343
  /**
@@ -323,7 +352,7 @@ export const layerTest: Layer.Layer<
323
352
  Layer.provide(FetchHttpClient.layer.pipe(
324
353
  Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false }))
325
354
  )),
326
- Layer.provideMerge(layer({ port: 0 }))
355
+ Layer.provideMerge(Layer.orDie(layer({ hostname: "127.0.0.1", port: 0 })))
327
356
  )
328
357
 
329
358
  /**
@@ -342,7 +371,7 @@ export const layerConfig = <R extends string>(
342
371
  >
343
372
  ): Layer.Layer<
344
373
  Server.HttpServer | HttpPlatform | FileSystem.FileSystem | Etag.Generator | Path.Path,
345
- ConfigError
374
+ ConfigError | Error.ServeError
346
375
  > =>
347
376
  Layer.mergeAll(
348
377
  Layer.effect(Server.HttpServer)(Effect.flatMap(Config.unwrap(options), make)),
@@ -355,9 +384,10 @@ export const layerConfig = <R extends string>(
355
384
 
356
385
  interface WebSocketContext {
357
386
  readonly deferred: Deferred.Deferred<ServerWebSocket<WebSocketContext>>
358
- readonly closeDeferred: Deferred.Deferred<void, Socket.SocketError>
359
387
  readonly buffer: Array<Uint8Array | string>
388
+ closeError: Socket.SocketError | undefined
360
389
  run: (_: Uint8Array | string) => void
390
+ onClose: (error: Socket.SocketError) => void
361
391
  }
362
392
 
363
393
  function wsDefaultRun(this: WebSocketContext, _: Uint8Array | string) {
@@ -560,15 +590,15 @@ class BunServerRequest extends Inspectable.Class implements ServerRequest.HttpSe
560
590
  get upgrade(): Effect.Effect<Socket.Socket, Error.HttpServerError> {
561
591
  return Effect.callback<Socket.Socket, Error.HttpServerError>((resume) => {
562
592
  const deferred = Deferred.makeUnsafe<ServerWebSocket<WebSocketContext>>()
563
- const closeDeferred = Deferred.makeUnsafe<void, Socket.SocketError>()
564
593
  const semaphore = Semaphore.makeUnsafe(1)
565
594
 
566
595
  const success = this.bunServer.upgrade(this.source, {
567
596
  data: {
568
597
  deferred,
569
- closeDeferred,
570
598
  buffer: [],
571
- run: wsDefaultRun
599
+ closeError: undefined,
600
+ run: wsDefaultRun,
601
+ onClose: constVoid
572
602
  }
573
603
  })
574
604
  if (!success) {
@@ -582,49 +612,116 @@ class BunServerRequest extends Inspectable.Class implements ServerRequest.HttpSe
582
612
  ))
583
613
  return
584
614
  }
615
+ const compressionThreshold = this.compressionThreshold
585
616
  resume(Effect.map(Deferred.await(deferred), (ws) => {
586
617
  const write = (chunk: Uint8Array | string | Socket.CloseEvent) =>
587
618
  Effect.sync(() => {
588
619
  if (typeof chunk === "string") {
589
- ws.sendText(chunk, chunk.length >= this.compressionThreshold)
620
+ ws.sendText(chunk, chunk.length >= compressionThreshold)
590
621
  } else if (Socket.isCloseEvent(chunk)) {
591
622
  ws.close(chunk.code, chunk.reason)
592
623
  } else {
593
- ws.sendBinary(chunk, chunk.byteLength >= this.compressionThreshold)
624
+ ws.sendBinary(chunk, chunk.byteLength >= compressionThreshold)
594
625
  }
595
-
596
- return true
597
626
  })
598
- const writer = Effect.succeed(write)
599
- const runRaw = Effect.fnUntraced(
600
- function*<R, E, _>(
601
- handler: (_: Uint8Array | string) => Effect.Effect<_, E, R> | void,
602
- opts?: { readonly onOpen?: Effect.Effect<void> | undefined }
603
- ) {
604
- const set = yield* FiberSet.make<any, E>()
605
- const run = yield* FiberSet.runtime(set)<R>()
606
- function runRaw(data: Uint8Array | string) {
607
- const result = handler(data)
608
- if (Effect.isEffect(result)) {
609
- run(result)
627
+ const writeAll = (chunks: ReadonlyArray<Uint8Array | string>) =>
628
+ Effect.sync(() => {
629
+ for (let i = 0; i < chunks.length; i++) {
630
+ const chunk = chunks[i]
631
+ if (typeof chunk === "string") {
632
+ ws.sendText(chunk, chunk.length >= compressionThreshold)
633
+ } else {
634
+ ws.sendBinary(chunk, chunk.byteLength >= compressionThreshold)
610
635
  }
611
636
  }
612
- ws.data.run = runRaw
613
- ws.data.buffer.forEach(runRaw)
614
- ws.data.buffer.length = 0
615
- if (opts?.onOpen) yield* opts.onOpen
616
- return yield* FiberSet.join(set)
617
- },
618
- Effect.scoped,
619
- Effect.onExit((exit) => Effect.sync(() => ws.close(exit._tag === "Success" ? 1000 : 1011))),
620
- Effect.raceFirst(Deferred.await(closeDeferred)),
621
- semaphore.withPermits(1)
622
- )
623
-
624
- return Socket.make({
625
- runRaw,
626
- writer
637
+ })
638
+ const writer: Socket.Socket["writer"] = Effect.succeed({ write, writeAll })
639
+
640
+ const reader: Socket.Socket["reader"] = Effect.gen(function*() {
641
+ const dispatcher = (yield* Scheduler.Scheduler).makeDispatcher()
642
+ yield* Effect.acquireRelease(semaphore.take(1), () => semaphore.release(1))
643
+ const closeError = ws.data.closeError ?? (ws.readyState >= 2
644
+ ? new Socket.SocketError({
645
+ reason: new Socket.SocketCloseError({ code: 1006 })
646
+ })
647
+ : undefined)
648
+ if (closeError !== undefined && ws.data.buffer.length === 0) {
649
+ return yield* closeError
650
+ }
651
+ const scope = yield* Effect.scope
652
+
653
+ type ReadResume = (
654
+ effect: Effect.Effect<Arr.NonEmptyReadonlyArray<Uint8Array | string>, Socket.SocketError>
655
+ ) => void
656
+
657
+ let buffer: Array<Uint8Array | string> = ws.data.buffer.splice(0)
658
+ let error: Socket.SocketError | undefined = closeError
659
+ let waiter: ReadResume | undefined
660
+ let flushScheduled = false
661
+
662
+ function takeBuffer(): Arr.NonEmptyReadonlyArray<Uint8Array | string> {
663
+ const chunk = buffer
664
+ buffer = []
665
+ return chunk as unknown as Arr.NonEmptyReadonlyArray<Uint8Array | string>
666
+ }
667
+ function deliver() {
668
+ flushScheduled = false
669
+ if (waiter === undefined || buffer.length === 0) return
670
+ const resumeRead = waiter
671
+ waiter = undefined
672
+ resumeRead(Effect.succeed(takeBuffer()))
673
+ }
674
+ function push(data: Uint8Array | string) {
675
+ buffer.push(data)
676
+ if (waiter !== undefined && !flushScheduled) {
677
+ flushScheduled = true
678
+ dispatcher.scheduleTask(deliver, 0)
679
+ }
680
+ }
681
+ function fail(err: Socket.SocketError) {
682
+ if (error === undefined) error = err
683
+ if (waiter !== undefined) {
684
+ const resumeRead = waiter
685
+ waiter = undefined
686
+ resumeRead(buffer.length > 0 ? Effect.succeed(takeBuffer()) : Effect.fail(error))
687
+ }
688
+ }
689
+
690
+ ws.data.run = push
691
+ ws.data.onClose = fail
692
+ yield* Scope.addFinalizer(
693
+ scope,
694
+ Effect.suspend(() => {
695
+ // resume a pull blocked in another fiber before detaching
696
+ fail(
697
+ new Socket.SocketError({
698
+ reason: new Socket.SocketCloseError({ code: 1006 })
699
+ })
700
+ )
701
+ ws.data.run = wsDefaultRun
702
+ ws.data.onClose = constVoid
703
+ ws.close(1000)
704
+ return Effect.void
705
+ })
706
+ )
707
+
708
+ return {
709
+ pull: Effect.callback<
710
+ Arr.NonEmptyReadonlyArray<Uint8Array | string>,
711
+ Socket.SocketError
712
+ >((resumeRead) => {
713
+ if (buffer.length > 0) return resumeRead(Effect.succeed(takeBuffer()))
714
+ if (error !== undefined) return resumeRead(Effect.fail(error))
715
+ waiter = resumeRead
716
+ return Effect.sync(() => {
717
+ if (waiter === resumeRead) waiter = undefined
718
+ })
719
+ }),
720
+ upgrade: Socket.SocketUpgradeError.unsupported
721
+ }
627
722
  })
723
+
724
+ return Socket.make({ reader, writer })
628
725
  }))
629
726
  })
630
727
  }
package/src/BunRedis.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * @since 4.0.0
11
11
  */
12
- import { RedisClient, type RedisOptions } from "bun"
12
+ import type { RedisClient, RedisOptions } from "bun"
13
13
  import * as Config from "effect/Config"
14
14
  import * as Context from "effect/Context"
15
15
  import * as Deferred from "effect/Deferred"
@@ -35,6 +35,7 @@ const make = Effect.fnUntraced(function*(
35
35
  readonly url?: string
36
36
  } & RedisOptions
37
37
  ) {
38
+ const { RedisClient } = yield* Effect.promise(() => import("bun"))
38
39
  const scope = yield* Effect.scope
39
40
  yield* Scope.addFinalizer(scope, Effect.sync(() => client.close()))
40
41
  const client = new RedisClient(options?.url, options)
package/src/BunSocket.ts CHANGED
@@ -29,13 +29,16 @@ export * from "@effect/platform-node-shared/NodeSocket"
29
29
  export const layerWebSocketConstructor: Layer.Layer<
30
30
  Socket.WebSocketConstructor
31
31
  > = Layer.succeed(Socket.WebSocketConstructor)(
32
- (url, protocols) => new globalThis.WebSocket(url, protocols)
32
+ (url, options) =>
33
+ // Bun accepts `WebSocketOptions`, but `bun-types` selects the DOM overload
34
+ // when `lib.dom` is loaded and hides those constructor options.
35
+ new globalThis.WebSocket(url, options as string | Array<string> | undefined)
33
36
  )
34
37
 
35
38
  /**
36
39
  * Creates a `Socket.Socket` layer for a WebSocket URL using Bun's global
37
- * `WebSocket` constructor, honoring protocol, open-timeout, and close-code
38
- * error options.
40
+ * `WebSocket` constructor, honoring protocol, open-timeout, and
41
+ * high-water-mark options.
39
42
  *
40
43
  * @category layers
41
44
  * @since 4.0.0
@@ -43,9 +46,9 @@ export const layerWebSocketConstructor: Layer.Layer<
43
46
  export const layerWebSocket: (
44
47
  url: string | Effect<string>,
45
48
  options?: {
46
- readonly closeCodeIsError?: ((code: number) => boolean) | undefined
47
49
  readonly openTimeout?: Duration.Input | undefined
48
50
  readonly protocols?: string | Array<string> | undefined
51
+ readonly highWaterMark?: number | undefined
49
52
  } | undefined
50
53
  ) => Layer.Layer<Socket.Socket, never, never> = flow(
51
54
  Socket.makeWebSocket,
package/src/BunStream.ts CHANGED
@@ -13,7 +13,7 @@ import * as Arr from "effect/Array"
13
13
  import * as Cause from "effect/Cause"
14
14
  import * as Channel from "effect/Channel"
15
15
  import * as Effect from "effect/Effect"
16
- import type { LazyArg } from "effect/Function"
16
+ import { constVoid, type LazyArg } from "effect/Function"
17
17
  import type * as Pull from "effect/Pull"
18
18
  import * as Scope from "effect/Scope"
19
19
  import * as Stream from "effect/Stream"
@@ -41,10 +41,19 @@ export const fromReadableStream = <A, E>(
41
41
  const reader = options.evaluate().getReader()
42
42
  yield* Scope.addFinalizer(
43
43
  scope,
44
- options.releaseLockOnEnd ? Effect.sync(() => reader.releaseLock()) : Effect.promise(() => reader.cancel())
44
+ options.releaseLockOnEnd
45
+ ? Effect.sync(() => reader.releaseLock())
46
+ : Effect.promise(() => reader.cancel().catch(constVoid))
45
47
  )
46
48
  function readMany(): Pull.Pull<Arr.NonEmptyReadonlyArray<A>, E> {
47
- const result = reader.readMany()
49
+ let result:
50
+ | Bun.ReadableStreamDefaultReadManyResult<A>
51
+ | Promise<Bun.ReadableStreamDefaultReadManyResult<A>>
52
+ try {
53
+ result = reader.readMany()
54
+ } catch (error) {
55
+ return Effect.fail(options.onError(error))
56
+ }
48
57
  if ("then" in result) {
49
58
  return Effect.callback<Arr.NonEmptyReadonlyArray<A>, E | Cause.Done>((resume) => {
50
59
  result.then((_) => resume(handleResult(_)), (e) => resume(Effect.fail(options.onError(e))))