@effect/platform-node-shared 4.0.0-beta.102 → 4.0.0-beta.104

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 (40) hide show
  1. package/dist/NodeChildProcessSpawner.d.ts.map +1 -1
  2. package/dist/NodeChildProcessSpawner.js +4 -1
  3. package/dist/NodeChildProcessSpawner.js.map +1 -1
  4. package/dist/NodeClusterSocket.js.map +1 -1
  5. package/dist/NodeCrypto.js.map +1 -1
  6. package/dist/NodeFileSystem.d.ts.map +1 -1
  7. package/dist/NodeFileSystem.js +7 -7
  8. package/dist/NodeFileSystem.js.map +1 -1
  9. package/dist/NodeHttpCompression.d.ts +36 -0
  10. package/dist/NodeHttpCompression.d.ts.map +1 -0
  11. package/dist/NodeHttpCompression.js +146 -0
  12. package/dist/NodeHttpCompression.js.map +1 -0
  13. package/dist/NodePath.d.ts.map +1 -1
  14. package/dist/NodePath.js +23 -20
  15. package/dist/NodePath.js.map +1 -1
  16. package/dist/NodeRuntime.js.map +1 -1
  17. package/dist/NodeSink.js.map +1 -1
  18. package/dist/NodeSocket.js.map +1 -1
  19. package/dist/NodeSocketServer.d.ts.map +1 -1
  20. package/dist/NodeSocketServer.js +25 -15
  21. package/dist/NodeSocketServer.js.map +1 -1
  22. package/dist/NodeStdio.js.map +1 -1
  23. package/dist/NodeStream.js.map +1 -1
  24. package/dist/NodeTerminal.d.ts.map +1 -1
  25. package/dist/NodeTerminal.js +61 -10
  26. package/dist/NodeTerminal.js.map +1 -1
  27. package/dist/index.d.ts +4 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +4 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/internal/utils.js.map +1 -1
  32. package/package.json +6 -7
  33. package/src/NodeChildProcessSpawner.ts +3 -1
  34. package/src/NodeFileSystem.ts +16 -12
  35. package/src/NodeHttpCompression.ts +153 -0
  36. package/src/NodePath.ts +25 -27
  37. package/src/NodeSocketServer.ts +28 -15
  38. package/src/NodeTerminal.ts +60 -12
  39. package/src/index.ts +5 -0
  40. package/src/internal/utils.ts +1 -1
@@ -0,0 +1,153 @@
1
+ /**
2
+ * HTTP response compression backed by `node:zlib`, shared by the Node.js, Bun,
3
+ * and Deno platforms.
4
+ *
5
+ * Byte-array bodies are compressed in one shot with the asynchronous
6
+ * `node:zlib` APIs, preserving an exact `Content-Length`. Streaming bodies go
7
+ * through `node:zlib` transform streams that flush each input chunk.
8
+ *
9
+ * @since 4.0.0
10
+ */
11
+ import * as Effect from "effect/Effect"
12
+ import * as HttpBody from "effect/unstable/http/HttpBody"
13
+ import type * as Platform from "effect/unstable/http/HttpPlatform"
14
+ import * as Response from "effect/unstable/http/HttpServerResponse"
15
+ import type { Duplex } from "node:stream"
16
+ import { Readable } from "node:stream"
17
+ import * as Zlib from "node:zlib"
18
+
19
+ /**
20
+ * The compression algorithms supported by the runtime's `node:zlib`. `zstd`
21
+ * requires Node.js 22.15 or newer.
22
+ *
23
+ * @category constants
24
+ * @since 4.0.0
25
+ */
26
+ export const algorithms: ReadonlySet<Platform.CompressionAlgorithm> = new Set(
27
+ typeof Zlib.zstdCompress === "function"
28
+ ? ["gzip", "deflate", "br", "zstd"]
29
+ : ["gzip", "deflate", "br"]
30
+ )
31
+
32
+ const brotliParams = (level: number | undefined, sizeHint?: number): Zlib.BrotliOptions => {
33
+ const params: Record<number, number> = {}
34
+ if (level !== undefined) {
35
+ params[Zlib.constants.BROTLI_PARAM_QUALITY] = level
36
+ }
37
+ if (sizeHint !== undefined) {
38
+ params[Zlib.constants.BROTLI_PARAM_SIZE_HINT] = sizeHint
39
+ }
40
+ return { params }
41
+ }
42
+
43
+ const zstdParams = (level: number | undefined): Zlib.ZstdOptions | undefined =>
44
+ level === undefined || level === 3 ? undefined : { params: { [Zlib.constants.ZSTD_c_compressionLevel]: level } }
45
+
46
+ const compress = (
47
+ data: Uint8Array,
48
+ algorithm: Platform.CompressionAlgorithm,
49
+ options?: Platform.CompressionOptions | undefined
50
+ ): Effect.Effect<Uint8Array> =>
51
+ Effect.callback((resume) => {
52
+ const complete = (error: Error | null, result: Uint8Array) =>
53
+ resume(error === null ? Effect.succeed(result) : Effect.die(error))
54
+ switch (algorithm) {
55
+ case "gzip": {
56
+ Zlib.gzip(data, { level: options?.level }, complete)
57
+ break
58
+ }
59
+ case "deflate": {
60
+ Zlib.deflate(data, { level: options?.level }, complete)
61
+ break
62
+ }
63
+ case "br": {
64
+ Zlib.brotliCompress(data, brotliParams(options?.level, data.byteLength), complete)
65
+ break
66
+ }
67
+ case "zstd": {
68
+ const params = zstdParams(options?.level)
69
+ if (params === undefined) {
70
+ Zlib.zstdCompress(data, complete)
71
+ } else {
72
+ Zlib.zstdCompress(data, params, complete)
73
+ }
74
+ break
75
+ }
76
+ }
77
+ })
78
+
79
+ /**
80
+ * Creates a `Compression` that compresses byte-array bodies in one shot with
81
+ * the asynchronous `node:zlib` APIs, setting the exact `Content-Length` of the
82
+ * compressed body. All other bodies are delegated to `fallback`.
83
+ *
84
+ * @category constructors
85
+ * @since 4.0.0
86
+ */
87
+ export const make = (fallback: Platform.Compression): Platform.Compression => ({
88
+ algorithms: fallback.algorithms,
89
+ compressResponse(response, algorithm, options) {
90
+ const body = response.body
91
+ if (body._tag !== "Uint8Array") {
92
+ return fallback.compressResponse(response, algorithm, options)
93
+ }
94
+ return Effect.map(compress(body.body, algorithm, options), (result) =>
95
+ Response.setHeader(
96
+ Response.setBody(response, HttpBody.uint8Array(result, body.contentType)),
97
+ "content-length",
98
+ result.byteLength.toString()
99
+ ))
100
+ }
101
+ })
102
+
103
+ /**
104
+ * Creates a `node:zlib` compression transform stream that flushes each input
105
+ * chunk, for streaming response bodies.
106
+ *
107
+ * @category constructors
108
+ * @since 4.0.0
109
+ */
110
+ export const compressTransform = (
111
+ algorithm: Platform.CompressionAlgorithm,
112
+ options?: Platform.CompressionOptions | undefined
113
+ ): Duplex => {
114
+ switch (algorithm) {
115
+ case "gzip": {
116
+ return Zlib.createGzip({ level: options?.level, flush: Zlib.constants.Z_SYNC_FLUSH })
117
+ }
118
+ case "deflate": {
119
+ return Zlib.createDeflate({ level: options?.level, flush: Zlib.constants.Z_SYNC_FLUSH })
120
+ }
121
+ case "br": {
122
+ return Zlib.createBrotliCompress({
123
+ ...brotliParams(options?.level),
124
+ flush: Zlib.constants.BROTLI_OPERATION_FLUSH
125
+ })
126
+ }
127
+ case "zstd": {
128
+ return Zlib.createZstdCompress({
129
+ ...zstdParams(options?.level),
130
+ flush: Zlib.constants.ZSTD_e_flush
131
+ })
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * A Web `ReadableStream` version of `compressTransform`, for platforms that
138
+ * stream response bodies as Web streams.
139
+ *
140
+ * @category constructors
141
+ * @since 4.0.0
142
+ */
143
+ export const compressTransformWeb = (
144
+ algorithm: Platform.CompressionAlgorithm,
145
+ options?: Platform.CompressionOptions | undefined
146
+ ) =>
147
+ (stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> => {
148
+ const transform = compressTransform(algorithm, options)
149
+ const source = Readable.fromWeb(stream as any)
150
+ source.on("error", (cause) => transform.destroy(cause))
151
+ transform.on("close", () => source.destroy())
152
+ return Readable.toWeb(source.pipe(transform)) as unknown as ReadableStream<Uint8Array>
153
+ }
package/src/NodePath.ts CHANGED
@@ -15,27 +15,28 @@ import { BadArgument } from "effect/PlatformError"
15
15
  import * as NodePath from "node:path"
16
16
  import * as NodeUrl from "node:url"
17
17
 
18
- const fromFileUrl = (url: URL): Effect.Effect<string, BadArgument> =>
19
- Effect.try({
20
- try: () => NodeUrl.fileURLToPath(url),
21
- catch: (cause) =>
22
- new BadArgument({
23
- module: "Path",
24
- method: "fromFileUrl",
25
- cause
26
- })
27
- })
28
-
29
- const toFileUrl = (path: string): Effect.Effect<URL, BadArgument> =>
30
- Effect.try({
31
- try: () => NodeUrl.pathToFileURL(path),
32
- catch: (cause) =>
33
- new BadArgument({
34
- module: "Path",
35
- method: "toFileUrl",
36
- cause
37
- })
38
- })
18
+ const fileUrlOps = (windows: boolean | undefined) => ({
19
+ fromFileUrl: (url: URL): Effect.Effect<string, BadArgument> =>
20
+ Effect.try({
21
+ try: () => NodeUrl.fileURLToPath(url, { windows }),
22
+ catch: (cause) =>
23
+ new BadArgument({
24
+ module: "Path",
25
+ method: "fromFileUrl",
26
+ cause
27
+ })
28
+ }),
29
+ toFileUrl: (path: string): Effect.Effect<URL, BadArgument> =>
30
+ Effect.try({
31
+ try: () => NodeUrl.pathToFileURL(path, { windows }),
32
+ catch: (cause) =>
33
+ new BadArgument({
34
+ module: "Path",
35
+ method: "toFileUrl",
36
+ cause
37
+ })
38
+ })
39
+ })
39
40
 
40
41
  /**
41
42
  * Provides the `Path` service using Node's POSIX path implementation plus
@@ -47,8 +48,7 @@ const toFileUrl = (path: string): Effect.Effect<URL, BadArgument> =>
47
48
  export const layerPosix: Layer.Layer<Path> = Layer.succeed(Path)({
48
49
  [TypeId]: TypeId,
49
50
  ...NodePath.posix,
50
- fromFileUrl,
51
- toFileUrl
51
+ ...fileUrlOps(false)
52
52
  })
53
53
 
54
54
  /**
@@ -61,8 +61,7 @@ export const layerPosix: Layer.Layer<Path> = Layer.succeed(Path)({
61
61
  export const layerWin32: Layer.Layer<Path> = Layer.succeed(Path)({
62
62
  [TypeId]: TypeId,
63
63
  ...NodePath.win32,
64
- fromFileUrl,
65
- toFileUrl
64
+ ...fileUrlOps(true)
66
65
  })
67
66
 
68
67
  /**
@@ -75,6 +74,5 @@ export const layerWin32: Layer.Layer<Path> = Layer.succeed(Path)({
75
74
  export const layer: Layer.Layer<Path> = Layer.succeed(Path)({
76
75
  [TypeId]: TypeId,
77
76
  ...NodePath,
78
- fromFileUrl,
79
- toFileUrl
77
+ ...fileUrlOps(undefined)
80
78
  })
@@ -52,12 +52,14 @@ export const make = Effect.fnUntraced(function*(
52
52
  options: Net.ServerOpts & Net.ListenOptions
53
53
  ) {
54
54
  const errorDeferred = Deferred.makeUnsafe<never, Error>()
55
- const pending = new Set<Net.Socket>()
55
+ const pending = new Map<Net.Socket, () => void>()
56
56
  function defaultOnConnection(conn: Net.Socket) {
57
- pending.add(conn)
58
57
  const remove = () => {
59
58
  pending.delete(conn)
59
+ conn.off("close", remove)
60
+ conn.off("error", remove)
60
61
  }
62
+ pending.set(conn, remove)
61
63
  conn.on("close", remove)
62
64
  conn.on("error", remove)
63
65
  }
@@ -66,6 +68,10 @@ export const make = Effect.fnUntraced(function*(
66
68
  let server: Net.Server | undefined
67
69
  yield* Effect.addFinalizer(() =>
68
70
  Effect.callback<void>((resume) => {
71
+ pending.forEach((remove, conn) => {
72
+ remove()
73
+ conn.destroy()
74
+ })
69
75
  server?.close(() => resume(Effect.void))
70
76
  })
71
77
  )
@@ -129,12 +135,10 @@ export const make = Effect.fnUntraced(function*(
129
135
  trackFiber
130
136
  )
131
137
  }
132
- pending.forEach((conn) => {
133
- conn.removeAllListeners("error")
134
- conn.removeAllListeners("close")
138
+ pending.forEach((remove, conn) => {
139
+ remove()
135
140
  onConnection(conn)
136
141
  })
137
- pending.clear()
138
142
  return yield* Effect.callback<never>((_resume) => {
139
143
  return Effect.suspend(() => {
140
144
  onConnection = prevOnConnection
@@ -190,20 +194,29 @@ export const makeWebSocket: (
190
194
  > = Effect.fnUntraced(function*(
191
195
  options: NodeWS.ServerOptions
192
196
  ) {
197
+ const pendingConnections = new Map<
198
+ globalThis.WebSocket,
199
+ readonly [request: Http.IncomingMessage, remove: () => void]
200
+ >()
193
201
  const server = yield* Effect.acquireRelease(
194
202
  Effect.sync(() => new NodeWS.WebSocketServer(options)),
195
203
  (server) =>
196
204
  Effect.callback<void>((resume) => {
205
+ pendingConnections.forEach(([, remove], conn) => {
206
+ remove()
207
+ const socket = conn as unknown as NodeWS.WebSocket
208
+ socket.terminate()
209
+ })
197
210
  server.close(() => resume(Effect.void))
198
211
  })
199
212
  )
200
- const pendingConnections = new Set<readonly [globalThis.WebSocket, Http.IncomingMessage]>()
201
213
  function defaultHandler(conn: globalThis.WebSocket, req: Http.IncomingMessage) {
202
- const entry = [conn, req] as const
203
- pendingConnections.add(entry)
204
- conn.addEventListener("close", () => {
205
- pendingConnections.delete(entry)
206
- })
214
+ const remove = () => {
215
+ pendingConnections.delete(conn)
216
+ conn.removeEventListener("close", remove)
217
+ }
218
+ pendingConnections.set(conn, [req, remove])
219
+ conn.addEventListener("close", remove)
207
220
  }
208
221
  let onConnection = defaultHandler
209
222
  server.on("connection", (conn, req) => onConnection(conn as any, req))
@@ -248,10 +261,10 @@ export const makeWebSocket: (
248
261
  trackFiber
249
262
  )
250
263
  }
251
- for (const [conn, req] of pendingConnections) {
264
+ pendingConnections.forEach(([req, remove], conn) => {
265
+ remove()
252
266
  onConnection(conn, req)
253
- }
254
- pendingConnections.clear()
267
+ })
255
268
  return yield* Effect.callback<never>((_resume) => {
256
269
  return Effect.sync(() => {
257
270
  onConnection = prevOnConnection
@@ -35,25 +35,52 @@ export const make: (
35
35
  function*(shouldQuit: (input: Terminal.UserInput) => boolean = defaultShouldQuit) {
36
36
  const stdin = process.stdin
37
37
  const stdout = process.stdout
38
+ const lines = yield* Queue.make<string, Cause.Done>()
39
+
40
+ // stdin "end" fires once per process, so remember end-of-input for readers
41
+ // created after the event (Bun never sets `readableEnded`).
42
+ let inputEnded = stdin.readableEnded
43
+ let readlineActive = false
44
+ const onStdinEnd = () => {
45
+ inputEnded = true
46
+ if (!readlineActive) {
47
+ Queue.endUnsafe(lines)
48
+ }
49
+ }
50
+ stdin.once("end", onStdinEnd)
51
+ yield* Effect.addFinalizer(() => Effect.sync(() => stdin.off("end", onStdinEnd)))
38
52
 
39
- // Acquire readline interface with TTY setup/cleanup inside the scope
40
53
  const rlRef = yield* RcRef.make({
41
54
  acquire: Effect.acquireRelease(
42
55
  Effect.sync(() => {
43
56
  const rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 50 })
57
+ const onLine = (line: string) => Queue.offerUnsafe(lines, line)
58
+ const onClose = () => {
59
+ readlineActive = false
60
+ Queue.endUnsafe(lines)
61
+ }
62
+ readlineActive = true
44
63
  readline.emitKeypressEvents(stdin, rl)
64
+ rl.on("line", onLine)
65
+ rl.once("close", onClose)
45
66
 
46
67
  if (stdin.isTTY) {
47
68
  stdin.setRawMode(true)
48
69
  }
49
- return rl
70
+ return { rl, onClose, onLine }
50
71
  }),
51
- (rl) =>
72
+ ({ rl, onClose, onLine }) =>
52
73
  Effect.sync(() => {
74
+ readlineActive = false
75
+ rl.off("line", onLine)
76
+ rl.off("close", onClose)
53
77
  if (stdin.isTTY) {
54
78
  stdin.setRawMode(false)
55
79
  }
56
80
  rl.close()
81
+ if (inputEnded) {
82
+ Queue.endUnsafe(lines)
83
+ }
57
84
  })
58
85
  )
59
86
  })
@@ -62,7 +89,6 @@ export const make: (
62
89
  const rows = Effect.sync(() => stdout.rows ?? 0)
63
90
 
64
91
  const readInput = Effect.gen(function*() {
65
- yield* RcRef.get(rlRef)
66
92
  const queue = yield* Queue.make<Terminal.UserInput, Cause.Done>()
67
93
  const handleKeypress = (s: string | undefined, k: readline.Key) => {
68
94
  const userInput = {
@@ -74,18 +100,40 @@ export const make: (
74
100
  Queue.endUnsafe(queue)
75
101
  }
76
102
  }
77
- yield* Effect.addFinalizer(() => Effect.sync(() => stdin.off("keypress", handleKeypress)))
103
+ // Deno's `process.stdin` shim does not keep the event loop alive, so a
104
+ // program blocked on input can exit before `end` is ever delivered. A
105
+ // timer holds the loop open for as long as this reader is active.
106
+ const keepAlive = setInterval(() => {}, 2147483647)
107
+ // Without this, consumers (e.g. `Prompt.run`) hang forever on closed stdin.
108
+ const handleEnd = () => {
109
+ clearInterval(keepAlive)
110
+ Queue.endUnsafe(queue)
111
+ }
112
+ yield* Effect.addFinalizer(() =>
113
+ Effect.sync(() => {
114
+ clearInterval(keepAlive)
115
+ stdin.off("keypress", handleKeypress)
116
+ stdin.off("end", handleEnd)
117
+ })
118
+ )
78
119
  stdin.on("keypress", handleKeypress)
120
+ if (inputEnded) {
121
+ handleEnd()
122
+ } else {
123
+ yield* RcRef.get(rlRef)
124
+ stdin.once("end", handleEnd)
125
+ }
79
126
  return queue as Queue.Dequeue<Terminal.UserInput, Cause.Done>
80
127
  })
81
128
 
82
- const readLine = Effect.scoped(
83
- Effect.flatMap(RcRef.get(rlRef), (readlineInterface) =>
84
- Effect.callback<string, Terminal.QuitError>((resume) => {
85
- const onLine = (line: string) => resume(Effect.succeed(line))
86
- readlineInterface.once("line", onLine)
87
- return Effect.sync(() => readlineInterface.off("line", onLine))
88
- }))
129
+ const readLine = Effect.suspend(() =>
130
+ Queue.poll(lines).pipe(
131
+ Effect.flatMap(Option.match({
132
+ onNone: () => Effect.scoped(Effect.andThen(RcRef.get(rlRef), Queue.take(lines))),
133
+ onSome: Effect.succeed
134
+ })),
135
+ Effect.mapError(() => new Terminal.QuitError({}))
136
+ )
89
137
  )
90
138
 
91
139
  const display = (prompt: string) =>
package/src/index.ts CHANGED
@@ -24,6 +24,11 @@ export * as NodeCrypto from "./NodeCrypto.ts"
24
24
  */
25
25
  export * as NodeFileSystem from "./NodeFileSystem.ts"
26
26
 
27
+ /**
28
+ * @since 4.0.0
29
+ */
30
+ export * as NodeHttpCompression from "./NodeHttpCompression.ts"
31
+
27
32
  /**
28
33
  * @since 4.0.0
29
34
  */
@@ -6,7 +6,7 @@ import type { PathLike } from "node:fs"
6
6
  export const handleErrnoException = (module: SystemError["module"], method: string) =>
7
7
  (
8
8
  err: NodeJS.ErrnoException,
9
- [path]: [path: PathLike | number | string | readonly string[], ...args: Array<any>]
9
+ [path]: [path: PathLike | number | string | ReadonlyArray<string>, ...args: Array<any>]
10
10
  ): PlatformError.PlatformError => {
11
11
  let reason: SystemErrorTag = "Unknown"
12
12