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

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.
@@ -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
  })
@@ -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