opencode-effect-enforcer 0.2.5 → 0.2.6
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/README.md +42 -140
- package/docs/effect-4.0.0-rc.116-changelog.md +2654 -0
- package/docs/effect-4.0.0-rc.116.md +102 -0
- package/guidance/effect-first-development.md +23 -14
- package/guidance/progressive-disclosure-guidance.md +3 -3
- package/package.json +2 -2
- package/patterns/avoid-direct-tag-checks.md +1 -1
- package/patterns/avoid-process-env.md +4 -4
- package/patterns/context-tag-extends.md +4 -4
- package/patterns/prefer-redacted-config.md +10 -10
- package/patterns/require-effect-concurrency.md +1 -1
- package/skills/effect-ai-chat/SKILL.md +2 -2
- package/skills/effect-ai-language-model/SKILL.md +36 -4
- package/skills/effect-ai-prompt/SKILL.md +1 -1
- package/skills/effect-ai-provider/SKILL.md +23 -12
- package/skills/effect-ai-tool/SKILL.md +13 -0
- package/skills/effect-atom-rpc/SKILL.md +7 -1
- package/skills/effect-atom-state/SKILL.md +8 -2
- package/skills/effect-cache/SKILL.md +10 -1
- package/skills/effect-cli/SKILL.md +105 -94
- package/skills/effect-command-executor/SKILL.md +7 -1
- package/skills/effect-config/SKILL.md +67 -44
- package/skills/effect-domain-modeling/SKILL.md +3 -3
- package/skills/effect-error-handling/SKILL.md +2 -2
- package/skills/effect-fiber/SKILL.md +2 -2
- package/skills/effect-filesystem/SKILL.md +34 -4
- package/skills/effect-http-api/SKILL.md +17 -2
- package/skills/effect-http-client/SKILL.md +11 -2
- package/skills/effect-http-server/SKILL.md +28 -11
- package/skills/effect-layer-design/SKILL.md +6 -2
- package/skills/effect-mcp-server/SKILL.md +21 -4
- package/skills/effect-observability/SKILL.md +2 -2
- package/skills/effect-optics/SKILL.md +1 -1
- package/skills/effect-parallelization/SKILL.md +2 -2
- package/skills/effect-pattern-matching/SKILL.md +1 -1
- package/skills/effect-platform-abstraction/SKILL.md +3 -3
- package/skills/effect-rpc-api/SKILL.md +3 -3
- package/skills/effect-rpc-client/SKILL.md +14 -13
- package/skills/effect-rpc-cluster/SKILL.md +15 -12
- package/skills/effect-rpc-server/SKILL.md +11 -13
- package/skills/effect-scheduling/SKILL.md +7 -0
- package/skills/effect-schema-composition/SKILL.md +12 -4
- package/skills/effect-schema-v4/SKILL.md +75 -20
- package/skills/effect-scope/SKILL.md +4 -4
- package/skills/effect-socket/SKILL.md +161 -658
- package/skills/effect-sql/SKILL.md +50 -12
- package/skills/effect-stream/SKILL.md +19 -24
- package/skills/effect-testing/SKILL.md +35 -22
- package/skills/effect-workflow/SKILL.md +13 -2
|
@@ -1,703 +1,206 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: effect-socket
|
|
3
|
-
description: Build
|
|
3
|
+
description: Build pull-based TCP, Unix, TLS, and WebSocket transports with Effect Socket readers and writers. Use for bidirectional connections, framing, reconnects, socket servers, or RPC socket transports.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# Effect Socket
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
## Source reference
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Read `packages/effect/src/unstable/socket/{Socket,SocketServer}.ts` at
|
|
11
|
+
`effect@4.0.0-rc.116` in the Effect reference. Platform adapters live in
|
|
12
|
+
`packages/platform/node-shared/src/{NodeSocket,NodeSocketServer}.ts`; Node and
|
|
13
|
+
Bun expose these as `NodeSocket` / `BunSocket` and their server counterparts.
|
|
14
|
+
Load `effect-scope` and `effect-fiber` for lifecycle ownership,
|
|
15
|
+
`effect-stream` for framing, and `effect-scheduling` for reconnect policy.
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
## Connection ownership
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
`Socket.Socket` is both the service tag and the interface. A client socket is
|
|
20
|
+
a connection recipe. Acquiring `socket.reader` establishes a connection and
|
|
21
|
+
returns a `Reader` with `pull` and `upgrade`. Its acquisition scope owns the
|
|
22
|
+
connection. A pull yields a non-empty batch of `string | Uint8Array` frames.
|
|
23
|
+
Every termination, including clean close, fails with `SocketError`.
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
- `packages/effect/src/unstable/socket/SocketServer.ts` — `SocketServer` service contract, `Address` (`TcpAddress` / `UnixAddress`), `SocketServerError`
|
|
18
|
-
- `packages/platform/node-shared/src/NodeSocket.ts` — `makeNet`, `fromDuplex`, `makeNetChannel`, `layerNet`, `NetSocket` service, `NodeWS` (`ws` re-export)
|
|
19
|
-
- `packages/platform/node-shared/src/NodeSocketServer.ts` — TCP/Unix server `make`/`layer`, WebSocket server `makeWebSocket`/`layerWebSocket`, `IncomingMessage` service
|
|
20
|
-
- `packages/platform/node/src/NodeSocket.ts` — re-exports shared module; adds `layerWebSocketConstructor`, `layerWebSocketConstructorWS`, `layerWebSocket`
|
|
21
|
-
- `packages/platform/bun/src/BunSocket.ts` — same shared re-export; Bun-global WebSocket constructor layers
|
|
22
|
-
- `packages/platform/node/test/NodeSocket.test.ts` — loopback echo server, WebSocket client semantics, transform-stream sockets
|
|
23
|
-
- `packages/effect/src/unstable/devtools/DevToolsClient.ts` — production example of NDJSON-framed request/response over a `Socket`
|
|
24
|
-
- `packages/effect/src/unstable/rpc/RpcClient.ts` (`makeProtocolSocket`) — production example of socket reconnect with retry schedules
|
|
25
|
+
`socket.writer` is an infallible scoped acquisition of a `Writer` object:
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
- `writer.write(frameOrCloseEvent)` sends one frame or a close request.
|
|
28
|
+
- `writer.writeAll(nonEmptyBatch)` batches outgoing frames with native backpressure.
|
|
29
|
+
- Both write operations may fail with `SocketError`.
|
|
30
|
+
- Writes while disconnected wait for a reader to establish the connection.
|
|
31
|
+
- Releasing the writer scope half-closes transports that support it.
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
One owner acquires the reader. Process each batch sequentially by default;
|
|
34
|
+
bounded concurrency is a deliberate application decision. Place a handshake
|
|
35
|
+
after reader acquisition and before the first pull so it runs per connection.
|
|
36
|
+
There are no callback-driven `run`, `runString`, or `runRaw` methods on a socket.
|
|
29
37
|
|
|
38
|
+
<!-- typecheck -->
|
|
30
39
|
```ts
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
readonly run: <_, E = never, R = never>(
|
|
34
|
-
handler: (data: Uint8Array) => Effect.Effect<_, E, R> | void,
|
|
35
|
-
options?: { readonly onOpen?: Effect.Effect<void> | undefined }
|
|
36
|
-
) => Effect.Effect<void, Socket.SocketError | E, R>;
|
|
37
|
-
readonly runString: (handler: (data: string) => ..., options?) => ...;
|
|
38
|
-
readonly runRaw: (handler: (data: string | Uint8Array) => ..., options?) => ...;
|
|
39
|
-
|
|
40
|
-
// Scoped writer: a function you call per outgoing chunk.
|
|
41
|
-
readonly writer: Effect.Effect<
|
|
42
|
-
(chunk: Uint8Array | string | Socket.CloseEvent) => Effect.Effect<void, Socket.SocketError>,
|
|
43
|
-
never,
|
|
44
|
-
Scope.Scope
|
|
45
|
-
>;
|
|
46
|
-
}
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
How to think about it:
|
|
50
|
-
|
|
51
|
-
- **`run*` drives everything.** It opens the connection, registers the handler for incoming frames, and only returns when the connection closes (or the open/read fails). Writes made through `writer` suspend on an internal latch until a `run` is active and the connection is open — calling `write` with no concurrent `run` waits forever.
|
|
52
|
-
- **The handler can return `void` or an Effect.** Returning `void` is the synchronous fast path (e.g. `Queue.offerUnsafe`). Returning an Effect forks it into a per-run `FiberSet` — handler effects run **concurrently and unordered**, and a failed handler effect fails the whole run loop with `E`.
|
|
53
|
-
- **`writer` is scoped.** Closing the writer's scope ends the writable side: Node TCP calls `socket.end()` (half-close), transform-stream sockets close the writable stream, WebSocket writers do nothing on release (the WS itself closes when the run ends).
|
|
54
|
-
- **`Socket` is a `Context.Service`** (`Socket.Socket` tag), so layers like `NodeSocket.layerNet` or `Socket.layerWebSocket` provide it to programs that just `yield* Socket.Socket`.
|
|
55
|
-
|
|
56
|
-
Imports used throughout:
|
|
57
|
-
|
|
58
|
-
```ts
|
|
59
|
-
import { Effect, Layer, Queue, Schedule, Schema, Scope, Stream } from 'effect';
|
|
60
|
-
import { Socket, SocketServer } from 'effect/unstable/socket';
|
|
61
|
-
import { Ndjson } from 'effect/unstable/encoding';
|
|
62
|
-
import { NodeSocket, NodeSocketServer } from '@effect/platform-node';
|
|
63
|
-
// Bun equivalents (same API, shared implementation):
|
|
64
|
-
// import { BunSocket, BunSocketServer } from '@effect/platform-bun';
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
---
|
|
68
|
-
|
|
69
|
-
## 1. The Socket Surface: run, runString, runRaw, writer
|
|
70
|
-
|
|
71
|
-
### Reading
|
|
72
|
-
|
|
73
|
-
```ts
|
|
74
|
-
const program = Effect.gen(function*() {
|
|
75
|
-
const socket = yield* Socket.Socket;
|
|
76
|
-
|
|
77
|
-
// Binary frames (strings arriving on the wire are UTF-8 encoded for you)
|
|
78
|
-
yield* socket.run((data: Uint8Array) => Effect.log(`got ${data.length} bytes`));
|
|
79
|
-
|
|
80
|
-
// Text frames (binary frames are decoded per-frame with TextDecoder)
|
|
81
|
-
yield* socket.runString((text) => Effect.log(text));
|
|
82
|
-
|
|
83
|
-
// Raw frames — exactly what the transport delivered (string | Uint8Array)
|
|
84
|
-
yield* socket.runRaw((data) => {
|
|
85
|
-
// returning void = synchronous handling, preserves frame order
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
The `onOpen` option runs an effect once the connection is open, before the run loop settles — the idiomatic place for subscribe/hello messages, since it re-runs on every reconnect. `onOpen` must be infallible (`Effect<void>`, error channel `never`), so wrap fallible writes with `Effect.orDie` (or `Effect.ignore` if a failed hello should not kill the run):
|
|
91
|
-
|
|
92
|
-
```ts
|
|
93
|
-
yield* socket.run(handleFrame, {
|
|
94
|
-
onOpen: Effect.orDie(write(JSON.stringify({ type: 'subscribe', channel: 'trades' })))
|
|
95
|
-
});
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
### Ordered processing of incoming frames
|
|
99
|
-
|
|
100
|
-
Effectful handlers are forked concurrently. When ordering matters, push synchronously into a `Queue` and consume it in your own fiber (this is exactly what `Socket.toChannelMap` does internally):
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
const queue = yield* Queue.unbounded<Uint8Array>();
|
|
104
|
-
yield* Effect.forkChild(
|
|
105
|
-
socket.run((data) => {
|
|
106
|
-
Queue.offerUnsafe(queue, data); // sync — no Effect returned
|
|
107
|
-
})
|
|
108
|
-
);
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
### Writing
|
|
112
|
-
|
|
113
|
-
`writer` yields a write function; each call returns an Effect that completes when the chunk is flushed (Node: the `write` callback) and fails with `SocketError` on write failure:
|
|
114
|
-
|
|
115
|
-
```ts
|
|
116
|
-
yield* Effect.gen(function*() {
|
|
117
|
-
const write = yield* socket.writer;
|
|
118
|
-
yield* write(new TextEncoder().encode('Hello'));
|
|
119
|
-
yield* write('strings are fine too');
|
|
120
|
-
yield* write(new Socket.CloseEvent(1000, 'done')); // request close
|
|
121
|
-
}).pipe(Effect.scoped);
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
`Socket.CloseEvent` is `new Socket.CloseEvent(code = 1000, reason?)`. On a WebSocket it calls `ws.close(code, reason)`; on TCP it destroys the connection — codes above 1000 destroy with a local `Error`, failing the *local* run loop with `SocketReadError`. Close codes are a WebSocket concept and are never transmitted over raw TCP: the peer just observes an ordinary EOF or ECONNRESET.
|
|
125
|
-
|
|
126
|
-
Write effects suspend on the internal latch until the connection is open, so it is safe to start writing fibers before (or while) `run` connects — but a `run` must eventually be active or the writes suspend forever.
|
|
127
|
-
|
|
128
|
-
### Building a Socket by hand
|
|
129
|
-
|
|
130
|
-
`Socket.make({ runRaw, run?, runString?, writer })` derives the missing read loops from `runRaw` (UTF-8 encoding/decoding per frame). A custom `writer` receives `Uint8Array | string | Socket.CloseEvent` and must branch with `Socket.isCloseEvent(chunk)` to separate close requests from data (every built-in writer does). You rarely need it directly — prefer `fromWebSocket`, `NodeSocket.fromDuplex`, or `Socket.fromTransformStream`.
|
|
131
|
-
|
|
132
|
-
---
|
|
133
|
-
|
|
134
|
-
## 2. The SocketError Taxonomy
|
|
135
|
-
|
|
136
|
-
All socket failures surface as a single tagged error `SocketError` (`_tag: 'SocketError'`) wrapping a `reason` union:
|
|
137
|
-
|
|
138
|
-
| `reason._tag` | When | Extra fields |
|
|
139
|
-
|---|---|---|
|
|
140
|
-
| `SocketOpenError` | connect/handshake failed | `kind: 'Unknown' \| 'Timeout'`, `cause` |
|
|
141
|
-
| `SocketReadError` | transport error while reading | `cause` |
|
|
142
|
-
| `SocketWriteError` | write callback failed | `cause` |
|
|
143
|
-
| `SocketCloseError` | connection closed | `code: number`, `closeReason?: string` |
|
|
144
|
-
|
|
145
|
-
```ts
|
|
146
|
-
program.pipe(
|
|
147
|
-
Effect.catchTag('SocketError', (error) => {
|
|
148
|
-
switch (error.reason._tag) {
|
|
149
|
-
case 'SocketOpenError':
|
|
150
|
-
return error.reason.kind === 'Timeout' ? retryLater : giveUp;
|
|
151
|
-
case 'SocketCloseError':
|
|
152
|
-
return error.reason.code === 1000 ? Effect.void : reconnect;
|
|
153
|
-
case 'SocketReadError':
|
|
154
|
-
case 'SocketWriteError':
|
|
155
|
-
return reconnect;
|
|
156
|
-
}
|
|
157
|
-
})
|
|
158
|
-
);
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
Useful helpers:
|
|
162
|
-
|
|
163
|
-
- `Socket.isSocketError(u)` / `Socket.SocketError.is(u)` — runtime guards
|
|
164
|
-
- `Socket.SocketErrorReason` — `Schema.Union` of the four reasons (reusable in your own schemas)
|
|
165
|
-
- `Socket.SocketCloseError.filterClean(isClean)` — a `Filter` for `Effect.catchFilter` that succeeds only for close errors whose code passes `isClean`:
|
|
166
|
-
|
|
167
|
-
```ts
|
|
168
|
-
// Treat a clean close (1000) as normal completion, keep everything else as failure
|
|
169
|
-
socket.run(handler).pipe(
|
|
170
|
-
Effect.catchFilter(
|
|
171
|
-
Socket.SocketCloseError.filterClean((code) => code === 1000),
|
|
172
|
-
() => Effect.void
|
|
173
|
-
)
|
|
174
|
-
);
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
Close-code semantics per transport:
|
|
178
|
-
|
|
179
|
-
- **WebSocket** (`fromWebSocket`/`makeWebSocket`): *every* close — including a clean 1000 — fails the run with `SocketCloseError` by default (`Socket.defaultCloseCodeIsError` returns `true` for all codes). Pass `closeCodeIsError: (code) => code !== 1000` to make clean closes complete the run with `void`.
|
|
180
|
-
- **TCP** (`NodeSocket.makeNet`/`fromDuplex`): a graceful peer FIN (`'end'` event) completes the run **successfully** with `void`; transport errors fail with `SocketReadError`; a close without a prior `'end'`/`'error'` (e.g. a local destroy via `write(CloseEvent)`) fails with `SocketCloseError` code `1000` (`1006` if errored — in practice preempted by `SocketReadError`, since `'error'` fires first and the run keeps the first settlement). There is no `closeCodeIsError` option here.
|
|
181
|
-
|
|
182
|
-
---
|
|
183
|
-
|
|
184
|
-
## 3. WebSocket Clients
|
|
185
|
-
|
|
186
|
-
### Constructor service
|
|
187
|
-
|
|
188
|
-
WebSocket creation is abstracted behind the `Socket.WebSocketConstructor` service so the same code runs in browsers, Node, and Bun:
|
|
189
|
-
|
|
190
|
-
```ts
|
|
191
|
-
// Browser / any runtime with a global WebSocket
|
|
192
|
-
Socket.layerWebSocketConstructorGlobal;
|
|
193
|
-
|
|
194
|
-
// Node: global WebSocket when available, otherwise the `ws` package
|
|
195
|
-
NodeSocket.layerWebSocketConstructor;
|
|
196
|
-
// Node: always the `ws` package
|
|
197
|
-
NodeSocket.layerWebSocketConstructorWS;
|
|
40
|
+
import { Effect } from 'effect';
|
|
41
|
+
import { Socket } from 'effect/unstable/socket';
|
|
198
42
|
|
|
199
|
-
|
|
200
|
-
|
|
43
|
+
const echo = Effect.fn('Socket.echo')(function* (socket: Socket.Socket) {
|
|
44
|
+
const { pull } = yield* socket.reader;
|
|
45
|
+
const writer = yield* socket.writer;
|
|
46
|
+
yield* pull.pipe(Effect.flatMap(writer.writeAll), Effect.forever);
|
|
47
|
+
}, Effect.scoped);
|
|
201
48
|
```
|
|
202
49
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
closeCodeIsError: (code) => code !== 1000, // default: every close is an error
|
|
208
|
-
openTimeout: '5 seconds', // default 10000 ms; SocketOpenError kind 'Timeout' on expiry
|
|
209
|
-
protocols: ['v1.protocol'] // optional subprotocols
|
|
210
|
-
});
|
|
211
|
-
// Effect<Socket, never, WebSocketConstructor> — connection NOT opened yet
|
|
212
|
-
```
|
|
50
|
+
`Socket.readerBytes(socket)` and `Socket.readerString(socket, encoding?)`
|
|
51
|
+
acquire a converted pull directly, rather than returning a Reader object.
|
|
52
|
+
The string adapter decodes each frame independently. For TCP, use a byte stream
|
|
53
|
+
and streaming `Stream.decodeText` to preserve split UTF-8 characters.
|
|
213
54
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
`Socket.fromWebSocket(acquire, options?)` is the lower-level form taking a scoped `Effect<globalThis.WebSocket, SocketError, R>` — `NodeSocketServer.makeWebSocket` uses it to wrap server-accepted sockets.
|
|
217
|
-
|
|
218
|
-
### Layers
|
|
55
|
+
## WebSocket clients
|
|
219
56
|
|
|
57
|
+
<!-- typecheck -->
|
|
220
58
|
```ts
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
// Platform conveniences: constructor already provided
|
|
225
|
-
NodeSocket.layerWebSocket(url, options?); // Layer<Socket, never, never>
|
|
226
|
-
BunSocket.layerWebSocket(url, options?); // Layer<Socket, never, never>
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
### Behavior details (verified in source/tests)
|
|
230
|
-
|
|
231
|
-
- The run loop waits for the `open` event before settling; incoming `Blob` frames are converted to `Uint8Array` automatically; text frames arrive as strings (use `run` to get bytes, `runString` to get text).
|
|
232
|
-
- When the run ends, the WebSocket is closed with code 1000 (the acquire's release in `makeWebSocket`).
|
|
233
|
-
- Handler effects can access the live `globalThis.WebSocket` via the `Socket.WebSocket` service (`yield* Socket.WebSocket`).
|
|
234
|
-
- An `error` event before open fails the run with `SocketOpenError`; after open, with `SocketReadError`.
|
|
59
|
+
import { Duration, Effect } from 'effect';
|
|
60
|
+
import { Socket } from 'effect/unstable/socket';
|
|
235
61
|
|
|
236
|
-
|
|
237
|
-
// Full client wiring
|
|
238
|
-
const program = Effect.gen(function*() {
|
|
62
|
+
const receive = Effect.gen(function* () {
|
|
239
63
|
const socket = yield* Socket.makeWebSocket('wss://example.com/feed', {
|
|
240
|
-
|
|
64
|
+
openTimeout: Duration.seconds(5),
|
|
65
|
+
highWaterMark: 64 * 1024,
|
|
66
|
+
protocols: ['v1']
|
|
241
67
|
});
|
|
242
|
-
const
|
|
243
|
-
yield*
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
yield* Effect.gen(function*() {
|
|
249
|
-
const write = yield* socket.writer;
|
|
250
|
-
yield* write('hello');
|
|
251
|
-
}).pipe(Effect.scoped);
|
|
252
|
-
return yield* Queue.take(messages);
|
|
253
|
-
}).pipe(Effect.provide(NodeSocket.layerWebSocketConstructor));
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
---
|
|
257
|
-
|
|
258
|
-
## 4. TCP and Unix-Domain Clients (NodeSocket)
|
|
259
|
-
|
|
260
|
-
`NodeSocket.makeNet(options)` opens a `net.createConnection` as a `Socket`. Options are Node's `NetConnectOpts` plus `openTimeout`:
|
|
261
|
-
|
|
262
|
-
```ts
|
|
263
|
-
// TCP
|
|
264
|
-
const tcp = yield* NodeSocket.makeNet({
|
|
265
|
-
host: 'localhost', // Net.TcpNetConnectOpts
|
|
266
|
-
port: 9000,
|
|
267
|
-
openTimeout: '3 seconds' // optional — no default; without it, connects wait forever
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
// Unix-domain socket
|
|
271
|
-
const unix = yield* NodeSocket.makeNet({ path: '/tmp/app.sock' });
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
`makeNet` returns `Effect<Socket>` — infallible, because connection happens per `run`. Connect failures surface from the run loop as `SocketOpenError`.
|
|
275
|
-
|
|
276
|
-
An explicitly supplied zero timeout is not treated as absence. `openTimeout: 0` or `openTimeout: Duration.zero` makes `NodeSocket.makeNet` / `fromDuplex` time out immediately with `SocketOpenError` kind `"Timeout"`. Omit `openTimeout` to allow an unbounded connect wait.
|
|
277
|
-
|
|
278
|
-
```ts
|
|
279
|
-
// As a layer
|
|
280
|
-
NodeSocket.layerNet({ port: 9000 }); // Layer<Socket, SocketError>
|
|
281
|
-
|
|
282
|
-
// As a Channel directly (see section 5)
|
|
283
|
-
NodeSocket.makeNetChannel<IE>({ port: 9000 });
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
`NodeSocket.fromDuplex(open, options?)` adapts any Node `Duplex` (child process stdio, TLS sockets via `tls.connect`, PTYs):
|
|
287
|
-
|
|
288
|
-
```ts
|
|
289
|
-
import * as Tls from 'node:tls';
|
|
290
|
-
|
|
291
|
-
const tlsSocket = yield* NodeSocket.fromDuplex(
|
|
292
|
-
Effect.sync(() => Tls.connect({ host: 'example.com', port: 443 })),
|
|
293
|
-
{ openTimeout: '5 seconds' }
|
|
294
|
-
);
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
The `open` effect is scoped — it re-runs per `run` call, and its scope closes when the run ends. Inside handler effects, the raw `net.Socket` is available as the `NodeSocket.NetSocket` service (e.g. to read `remoteAddress` or call `setKeepAlive`).
|
|
298
|
-
|
|
299
|
-
These constructors work identically from `BunSocket` (same shared implementation over `node:net`).
|
|
300
|
-
|
|
301
|
-
---
|
|
302
|
-
|
|
303
|
-
## 5. Sockets as Channels and Streams
|
|
304
|
-
|
|
305
|
-
A `Socket` converts to a bidirectional `Channel` — output is incoming frames, input is outgoing frames:
|
|
306
|
-
|
|
307
|
-
```ts
|
|
308
|
-
// Binary: incoming string frames are UTF-8 encoded to bytes
|
|
309
|
-
Socket.toChannel(socket);
|
|
310
|
-
// Channel<NonEmptyReadonlyArray<Uint8Array>, SocketError | IE, void,
|
|
311
|
-
// NonEmptyReadonlyArray<Uint8Array | string | CloseEvent>, IE>
|
|
312
|
-
|
|
313
|
-
// Text: incoming binary frames decoded per-frame (optional encoding arg; dual)
|
|
314
|
-
Socket.toChannelString(socket);
|
|
315
|
-
Socket.toChannelString(socket, 'utf-8');
|
|
316
|
-
|
|
317
|
-
// Fix the upstream error type explicitly (channels are invariant in IE)
|
|
318
|
-
Socket.toChannelWith<MyError>()(socket);
|
|
319
|
-
|
|
320
|
-
// Custom frame mapping
|
|
321
|
-
Socket.toChannelMap(socket, (data) => decodeFrame(data));
|
|
322
|
-
|
|
323
|
-
// From the Socket service in context
|
|
324
|
-
Socket.makeChannel<IE>(); // Channel<..., ..., ..., ..., ..., unknown, Socket>
|
|
325
|
-
|
|
326
|
-
// Node shortcut: TCP channel without an intermediate Socket value
|
|
327
|
-
NodeSocket.makeNetChannel<IE>({ port: 9000 });
|
|
328
|
-
|
|
329
|
-
// WebSocket shortcut (requires WebSocketConstructor)
|
|
330
|
-
Socket.makeWebSocketChannel<IE>(url, { closeCodeIsError });
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
`Stream.pipeThroughChannel` turns request/response over a socket into a stream pipeline — the loopback test does exactly this against an echo server:
|
|
334
|
-
|
|
335
|
-
```ts
|
|
336
|
-
const channel = NodeSocket.makeNetChannel({ port });
|
|
337
|
-
|
|
338
|
-
const output = yield* Stream.make('Hello', 'World').pipe(
|
|
339
|
-
Stream.encodeText, // Stream<string> → Stream<Uint8Array>
|
|
340
|
-
Stream.pipeThroughChannel(channel), // write upstream, read downstream
|
|
341
|
-
Stream.decodeText(), // streaming UTF-8 decode (safe across chunk splits)
|
|
342
|
-
Stream.mkString
|
|
343
|
-
);
|
|
344
|
-
// 'HelloWorld'
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
When the upstream stream ends, the channel's write side completes (Node: `.end()` half-close), and the channel keeps emitting until the peer closes. Prefer `Stream.decodeText` over `runString`/`toChannelString` for TCP byte streams — only `decodeText` reassembles multi-byte UTF-8 characters split across chunks (see Common Mistakes).
|
|
348
|
-
|
|
349
|
-
See the effect-stream skill for the full Stream/Channel surface.
|
|
350
|
-
|
|
351
|
-
---
|
|
352
|
-
|
|
353
|
-
## 6. Framing: NDJSON over a Socket
|
|
354
|
-
|
|
355
|
-
TCP delivers a byte stream, not messages — you must frame. `Ndjson.duplex*` from `effect/unstable/encoding` wraps a socket channel so you write/read typed values:
|
|
356
|
-
|
|
357
|
-
```ts
|
|
358
|
-
import { Ndjson } from 'effect/unstable/encoding';
|
|
359
|
-
|
|
360
|
-
const Request = Schema.Struct({ id: Schema.Number, op: Schema.String });
|
|
361
|
-
const Response = Schema.Struct({ id: Schema.Number, result: Schema.String });
|
|
362
|
-
|
|
363
|
-
const program = Effect.gen(function*() {
|
|
364
|
-
const socket = yield* NodeSocket.makeNet({ port: 9000 });
|
|
365
|
-
const requests = yield* Queue.unbounded<typeof Request.Type>();
|
|
366
|
-
|
|
367
|
-
yield* Stream.fromQueue(requests).pipe(
|
|
368
|
-
Stream.pipeThroughChannel(
|
|
369
|
-
Ndjson.duplexSchema(Socket.toChannel(socket), {
|
|
370
|
-
inputSchema: Request, // encodes outgoing values
|
|
371
|
-
outputSchema: Response // decodes incoming lines
|
|
372
|
-
})
|
|
373
|
-
),
|
|
374
|
-
Stream.runForEach((response) => Effect.log('response', response)),
|
|
375
|
-
Effect.forkScoped
|
|
376
|
-
);
|
|
377
|
-
|
|
378
|
-
yield* Queue.offer(requests, { id: 1, op: 'ping' });
|
|
379
|
-
});
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
Variants (all dual, options `{ ignoreEmptyLines?: boolean }`):
|
|
383
|
-
|
|
384
|
-
- `Ndjson.duplex(channel)` — untyped `unknown` values over a byte channel
|
|
385
|
-
- `Ndjson.duplexSchema(channel, { inputSchema, outputSchema })` — typed, byte channel (use for TCP)
|
|
386
|
-
- `Ndjson.duplexString` / `Ndjson.duplexSchemaString` — string-channel versions (fine for WebSocket text frames; `DevToolsClient` composes `Ndjson.duplexSchemaString(Socket.toChannelString(socket), ...)`)
|
|
387
|
-
|
|
388
|
-
Error channel gains `NdjsonError` (and `Schema.SchemaError` for the schema variants). `Msgpack.duplex`/`duplexSchema` provide analogous binary framing, but require `Uint8Array<ArrayBuffer>` channels — `Socket.toChannel`'s plain `Uint8Array` output does not directly satisfy them (a cast or mapping is needed) — and `Msgpack.duplex` is not dual. One-way encoding/decoding (`Ndjson.encode()`, `Ndjson.decode()`) composes with `Stream.pipeThroughChannel` as shown in the effect-stream skill.
|
|
389
|
-
|
|
390
|
-
---
|
|
391
|
-
|
|
392
|
-
## 7. Accepting Connections: SocketServer
|
|
393
|
-
|
|
394
|
-
`SocketServer.SocketServer` is a `Context.Service` with two members:
|
|
395
|
-
|
|
396
|
-
```ts
|
|
397
|
-
interface SocketServerService {
|
|
398
|
-
readonly address: SocketServer.Address; // TcpAddress | UnixAddress, known at construction
|
|
399
|
-
readonly run: <R, E, _>(
|
|
400
|
-
handler: (socket: Socket.Socket) => Effect.Effect<_, E, R>
|
|
401
|
-
) => Effect.Effect<never, SocketServer.SocketServerError, R>;
|
|
402
|
-
}
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
### TCP / Unix-domain (Node and Bun)
|
|
406
|
-
|
|
407
|
-
```ts
|
|
408
|
-
// Scoped construction — listening starts immediately; closes with the scope
|
|
409
|
-
const server = yield* NodeSocketServer.make({ port: 0 }); // port 0 = ephemeral
|
|
410
|
-
const unixServer = yield* NodeSocketServer.make({ path: '/tmp/app.sock' });
|
|
411
|
-
|
|
412
|
-
// Or as a layer
|
|
413
|
-
NodeSocketServer.layer({ port: 9000, host: '127.0.0.1' });
|
|
414
|
-
// Layer<SocketServer, SocketServerError>
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
Options are Node's `Net.ServerOpts & Net.ListenOptions`. Read the bound port from the address:
|
|
418
|
-
|
|
419
|
-
```ts
|
|
420
|
-
const port = (server.address as SocketServer.TcpAddress).port;
|
|
421
|
-
```
|
|
422
|
-
|
|
423
|
-
### The accept loop
|
|
424
|
-
|
|
425
|
-
`run(handler)` returns `Effect<never, ...>` — it never completes, so fork it. The handler is forked per connection; an errored handler is **logged** (via the `UnhandledLogLevel` reference: "Unhandled error in SocketServer"), never propagated to the accept loop. Connections accepted between `make` and `run` are queued and handled once `run` starts.
|
|
426
|
-
|
|
427
|
-
```ts
|
|
428
|
-
const EchoServer = Effect.gen(function*() {
|
|
429
|
-
const server = yield* NodeSocketServer.make({ port: 0 });
|
|
430
|
-
|
|
431
|
-
yield* server.run(
|
|
432
|
-
Effect.fnUntraced(function*(socket) {
|
|
433
|
-
const write = yield* socket.writer; // needs Scope —
|
|
434
|
-
yield* socket.run(write); // echo: write fn doubles as read handler
|
|
435
|
-
}, Effect.scoped) // — eliminate it per connection
|
|
436
|
-
).pipe(Effect.forkScoped);
|
|
437
|
-
|
|
438
|
-
return server;
|
|
439
|
-
});
|
|
440
|
-
```
|
|
441
|
-
|
|
442
|
-
Wrap handlers that use `socket.writer` (or any scoped resource) in `Effect.scoped`: the server captures the handler's context at `run` time **minus `Scope`**, so a handler still requiring `Scope` dies at runtime. Inside TCP handlers the raw `net.Socket` is provided as `NodeSocket.NetSocket`.
|
|
443
|
-
|
|
444
|
-
### WebSocket servers (`ws`-backed)
|
|
445
|
-
|
|
446
|
-
```ts
|
|
447
|
-
NodeSocketServer.makeWebSocket({ port: 8080 }); // ws.ServerOptions
|
|
448
|
-
NodeSocketServer.layerWebSocket({ port: 8080 });
|
|
449
|
-
```
|
|
450
|
-
|
|
451
|
-
Handlers receive the same `Socket.Socket` interface, plus two extra services in context: `NodeSocketServer.IncomingMessage` (the Node `http.IncomingMessage` — URL, headers, auth) and `Socket.WebSocket` (the raw `ws` socket). To attach WebSockets to an existing Effect HTTP server route instead, use `HttpServerRequest.upgrade` — see the effect-http-server skill.
|
|
452
|
-
|
|
453
|
-
`BunSocketServer` re-exports the identical API (Bun runs it over `node:net` compatibility).
|
|
454
|
-
|
|
455
|
-
`SocketServerError` wraps a `reason` of `SocketServerOpenError` (bind/listen failures) or `SocketServerUnknownError`, each carrying `cause`.
|
|
456
|
-
|
|
457
|
-
---
|
|
458
|
-
|
|
459
|
-
## 8. Transform-Stream Sockets and In-Memory Testing
|
|
460
|
-
|
|
461
|
-
`Socket.fromTransformStream` adapts a web `ReadableStream`/`WritableStream` pair — the basis for in-memory sockets in tests, WebTransport-style APIs, or piping through `CompressionStream`:
|
|
462
|
-
|
|
463
|
-
```ts
|
|
464
|
-
const socket = yield* Socket.fromTransformStream(
|
|
465
|
-
Effect.succeed({
|
|
466
|
-
readable: someReadableStream, // ReadableStream<Uint8Array | string>
|
|
467
|
-
writable: someWritableStream // WritableStream<Uint8Array>
|
|
468
|
-
}),
|
|
469
|
-
{ closeCodeIsError: (code) => code !== 1000 }
|
|
470
|
-
);
|
|
471
|
-
```
|
|
472
|
-
|
|
473
|
-
In-memory test socket from the test suite — feed the read side from a `Stream`, capture writes in an array:
|
|
474
|
-
|
|
475
|
-
```ts
|
|
476
|
-
const readable = Stream.make('A', 'B', 'C').pipe(
|
|
477
|
-
Stream.toReadableStream()
|
|
478
|
-
);
|
|
479
|
-
const chunks: Array<string> = [];
|
|
480
|
-
const decoder = new TextDecoder();
|
|
481
|
-
const writable = new WritableStream<Uint8Array>({
|
|
482
|
-
write(chunk) {
|
|
483
|
-
chunks.push(decoder.decode(chunk));
|
|
484
|
-
}
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
const socket = yield* Socket.fromTransformStream(
|
|
488
|
-
Effect.succeed({ readable, writable }),
|
|
489
|
-
{ closeCodeIsError: () => false }
|
|
490
|
-
);
|
|
491
|
-
```
|
|
492
|
-
|
|
493
|
-
When the readable ends, the run fails with `SocketCloseError` code 1000 — which `closeCodeIsError: () => false` converts to successful completion. Writing a `Socket.CloseEvent` ends the run with `SocketCloseError` carrying that code (subject to `closeCodeIsError`), without closing the writable stream. String writes are UTF-8 encoded before reaching the writable. Releasing the writer scope closes the writable stream.
|
|
494
|
-
|
|
495
|
-
For integration-level tests, prefer a real loopback server: `NodeSocketServer.make({ port: 0 })` + `NodeSocket.makeNet({ port: address.port })` (the pattern in `NodeSocket.test.ts`) — it exercises actual framing and close semantics.
|
|
496
|
-
|
|
497
|
-
---
|
|
498
|
-
|
|
499
|
-
## 9. Reconnect and Retry Patterns
|
|
500
|
-
|
|
501
|
-
Because each `run` acquires a fresh connection, **reconnect = retry the run loop**. This is exactly how `RpcClient.makeProtocolSocket` works in production: acquire the writer once (writes latch while disconnected), wrap the run in `Effect.suspend` to reset per-attempt state, and `Effect.retry` with a capped backoff:
|
|
502
|
-
|
|
503
|
-
```ts
|
|
504
|
-
const feed = Effect.gen(function*() {
|
|
505
|
-
const socket = yield* Socket.Socket;
|
|
506
|
-
const write = yield* socket.writer; // once — survives reconnects
|
|
507
|
-
|
|
508
|
-
yield* Effect.suspend(() => {
|
|
509
|
-
// reset per-connection state here (parsers, sequence numbers, ...)
|
|
510
|
-
return socket.run(handleFrame, {
|
|
511
|
-
onOpen: Effect.orDie(write(JSON.stringify({ type: 'subscribe', channel: 'trades' })))
|
|
512
|
-
});
|
|
513
|
-
}).pipe(
|
|
514
|
-
// a clean run completion is also a disconnect — fail so retry kicks in
|
|
515
|
-
Effect.flatMap(() =>
|
|
516
|
-
Effect.fail(
|
|
517
|
-
new Socket.SocketError({
|
|
518
|
-
reason: new Socket.SocketCloseError({ code: 1000 })
|
|
519
|
-
})
|
|
520
|
-
)
|
|
521
|
-
),
|
|
522
|
-
Effect.tapError((error) => Effect.logWarning('socket disconnected', error)),
|
|
523
|
-
Effect.retry(
|
|
524
|
-
// RpcClient's defaultRetryPolicy: exponential from 500ms, capped at 5s
|
|
525
|
-
Schedule.min([
|
|
526
|
-
Schedule.exponential('500 millis', 1.5),
|
|
527
|
-
Schedule.spaced('5 seconds')
|
|
528
|
-
])
|
|
529
|
-
)
|
|
68
|
+
const pull = yield* Socket.readerString(socket);
|
|
69
|
+
const writer = yield* socket.writer;
|
|
70
|
+
yield* writer.write('subscribe');
|
|
71
|
+
yield* pull.pipe(
|
|
72
|
+
Effect.flatMap((batch) => Effect.forEach(batch, Effect.logInfo)),
|
|
73
|
+
Effect.forever
|
|
530
74
|
);
|
|
531
|
-
}).pipe(Effect.provide(
|
|
75
|
+
}).pipe(Effect.scoped, Effect.provide(Socket.layerWebSocketConstructorGlobal));
|
|
532
76
|
```
|
|
533
77
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
-
|
|
540
|
-
|
|
541
|
-
---
|
|
78
|
+
Use `NodeSocket.layerWebSocketConstructor` or
|
|
79
|
+
`NodeSocket.layerWebSocketConstructorWS` on Node and
|
|
80
|
+
`BunSocket.layerWebSocketConstructor` on Bun. Platform `layerWebSocket(url, options)`
|
|
81
|
+
conveniences supply the constructor. Core `Socket.layerWebSocket` requires it.
|
|
82
|
+
Constructors accept compatible browser, Bun, and Node implementations without
|
|
83
|
+
casts; opening-handshake headers are available where the platform supports them.
|
|
542
84
|
|
|
543
|
-
|
|
85
|
+
An effectful URL is reevaluated on each connection. `fromWebSocket(acquire, options)`
|
|
86
|
+
wraps a scoped transport acquisition. Pausable transports pause at `highWaterMark`
|
|
87
|
+
(default 64 KiB) and resume when drained. Browser WebSockets cannot pause and
|
|
88
|
+
fail with `SocketReadError` when the configured bound is exceeded. Text-frame
|
|
89
|
+
buffering counts UTF-8 bytes, not string length.
|
|
544
90
|
|
|
545
|
-
|
|
91
|
+
## TCP, Unix, and TLS
|
|
546
92
|
|
|
547
93
|
```ts
|
|
548
|
-
|
|
549
|
-
RpcServer.layerProtocolSocketServer; // requires SocketServer + RpcSerialization
|
|
550
|
-
// e.g. Layer.provide(NodeSocketServer.layer({ port: 9000 }))
|
|
94
|
+
import { NodeSocket } from '@effect/platform-node';
|
|
551
95
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
96
|
+
const tcp = NodeSocket.makeNet({ host: 'localhost', port: 9000, openTimeout: '3 seconds' });
|
|
97
|
+
const unix = NodeSocket.makeNet({ path: '/tmp/app.sock' });
|
|
98
|
+
const tls = NodeSocket.makeTls({ host: 'example.com', port: 443, servername: 'example.com' });
|
|
555
99
|
```
|
|
556
100
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
101
|
+
`makeNet` / `makeTls` return recipes; connection failures occur during reader
|
|
102
|
+
acquisition. `layerNet`, `layerTls`, `makeNetChannel`, and `makeTlsChannel` provide
|
|
103
|
+
service/channel equivalents. Prefer `makeTls` over hand-wrapping `tls.connect`.
|
|
104
|
+
`fromDuplex` remains the adapter for an existing scoped Node duplex source.
|
|
105
|
+
Omitted TCP `openTimeout` is unbounded; zero means an immediate timeout.
|
|
562
106
|
|
|
563
|
-
|
|
107
|
+
For STARTTLS acquire the reader, complete the protocol handshake, then call
|
|
108
|
+
`reader.upgrade(options)`. TLS keys and passphrases are redacted. Server-role
|
|
109
|
+
upgrades require both key and certificate; unsupported transports and invalid
|
|
110
|
+
credentials fail with `SocketUpgradeError` inside `SocketError`.
|
|
564
111
|
|
|
565
|
-
|
|
112
|
+
## Errors, close, and reconnect
|
|
566
113
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
// Server: echo every frame back
|
|
574
|
-
const server = yield* NodeSocketServer.make({ port: 0 });
|
|
575
|
-
yield* server.run(
|
|
576
|
-
Effect.fnUntraced(function*(socket) {
|
|
577
|
-
const write = yield* socket.writer;
|
|
578
|
-
yield* socket.run(write);
|
|
579
|
-
}, Effect.scoped)
|
|
580
|
-
).pipe(Effect.forkScoped);
|
|
581
|
-
|
|
582
|
-
// Client: pipe a stream through the connection
|
|
583
|
-
const port = (server.address as SocketServer.TcpAddress).port;
|
|
584
|
-
const channel = NodeSocket.makeNetChannel({ port });
|
|
585
|
-
|
|
586
|
-
return yield* Stream.make('Hello', 'World').pipe(
|
|
587
|
-
Stream.encodeText,
|
|
588
|
-
Stream.pipeThroughChannel(channel),
|
|
589
|
-
Stream.decodeText(),
|
|
590
|
-
Stream.mkString
|
|
591
|
-
);
|
|
592
|
-
}).pipe(Effect.scoped);
|
|
593
|
-
```
|
|
114
|
+
`SocketError.reason` is one of `SocketOpenError`, `SocketReadError`,
|
|
115
|
+
`SocketWriteError`, `SocketCloseError`, or `SocketUpgradeError`. Narrow with
|
|
116
|
+
`Effect.catchReason`, schema matching, or the supplied guards. Open failures
|
|
117
|
+
distinguish `Unknown` and `Timeout`; close reasons carry `code` and `closeReason`.
|
|
118
|
+
Handle a normal close at the owning protocol boundary when completion is intended.
|
|
119
|
+
Close-code predicate options and `SocketCloseError.filterClean` are removed.
|
|
594
120
|
|
|
595
|
-
|
|
121
|
+
Reconnect by retrying the **scoped reader acquisition and consumption**, not just
|
|
122
|
+
the already-acquired pull. This releases each connection before the next attempt.
|
|
123
|
+
Retry only connection failures classified as transient for the protocol; replayed
|
|
124
|
+
handshakes and commands must be idempotent. Accepted server connections cannot
|
|
125
|
+
reconnect. Use a new socket value for an independent concurrent client connection.
|
|
596
126
|
|
|
127
|
+
<!-- typecheck -->
|
|
597
128
|
```ts
|
|
598
|
-
import {
|
|
599
|
-
import { Ndjson } from 'effect/unstable/encoding';
|
|
129
|
+
import { Duration, Effect, Schedule } from 'effect';
|
|
600
130
|
import { Socket } from 'effect/unstable/socket';
|
|
601
|
-
import { NodeSocket } from '@effect/platform-node';
|
|
602
131
|
|
|
603
|
-
const
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
// One fiber owns the connection: writes from the queue, reads responses
|
|
611
|
-
yield* Stream.fromQueue(requests).pipe(
|
|
612
|
-
Stream.pipeThroughChannel(
|
|
613
|
-
Ndjson.duplexSchema(Socket.toChannel(socket), {
|
|
614
|
-
inputSchema: Request,
|
|
615
|
-
outputSchema: Response
|
|
616
|
-
})
|
|
617
|
-
),
|
|
618
|
-
Stream.runForEach((response) => Effect.log('response', response)),
|
|
619
|
-
Effect.retry(
|
|
620
|
-
Schedule.min([
|
|
621
|
-
Schedule.exponential('500 millis', 1.5),
|
|
622
|
-
Schedule.spaced('5 seconds')
|
|
623
|
-
])
|
|
624
|
-
),
|
|
625
|
-
Effect.forkScoped
|
|
132
|
+
const consume = Effect.fn('Socket.consume')(function* (socket: Socket.Socket) {
|
|
133
|
+
const pull = yield* Socket.readerString(socket);
|
|
134
|
+
const writer = yield* socket.writer;
|
|
135
|
+
yield* writer.write('subscribe'); // protocol-specific idempotent handshake
|
|
136
|
+
yield* pull.pipe(
|
|
137
|
+
Effect.flatMap((batch) => Effect.forEach(batch, Effect.logInfo)),
|
|
138
|
+
Effect.forever
|
|
626
139
|
);
|
|
140
|
+
}, Effect.scoped);
|
|
627
141
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
### Per-connection NDJSON server
|
|
635
|
-
|
|
636
|
-
```ts
|
|
637
|
-
const NdjsonServer = Effect.gen(function*() {
|
|
638
|
-
const server = yield* NodeSocketServer.make({ port: 9000 });
|
|
639
|
-
|
|
640
|
-
yield* server.run(
|
|
641
|
-
Effect.fnUntraced(function*(socket) {
|
|
642
|
-
const responses = yield* Queue.unbounded<typeof Response.Type>();
|
|
643
|
-
yield* Stream.fromQueue(responses).pipe(
|
|
644
|
-
Stream.pipeThroughChannel(
|
|
645
|
-
Ndjson.duplexSchema(Socket.toChannel(socket), {
|
|
646
|
-
inputSchema: Response, // we write responses
|
|
647
|
-
outputSchema: Request // we read requests
|
|
648
|
-
})
|
|
649
|
-
),
|
|
650
|
-
Stream.runForEach((request) =>
|
|
651
|
-
Queue.offer(responses, { id: request.id, result: request.op.toUpperCase() })
|
|
652
|
-
)
|
|
653
|
-
);
|
|
654
|
-
}, Effect.scoped)
|
|
655
|
-
).pipe(Effect.forkScoped);
|
|
656
|
-
});
|
|
657
|
-
```
|
|
658
|
-
|
|
659
|
-
### Reconnecting WebSocket consumer
|
|
660
|
-
|
|
661
|
-
```ts
|
|
662
|
-
const tradeFeed = Effect.gen(function*() {
|
|
663
|
-
const socket = yield* Socket.makeWebSocket(
|
|
664
|
-
Effect.gen(function*() {
|
|
665
|
-
const token = yield* freshAuthToken; // re-evaluated per reconnect
|
|
666
|
-
return `wss://example.com/feed?token=${token}`;
|
|
667
|
-
}),
|
|
668
|
-
{ closeCodeIsError: (code) => code !== 1000, openTimeout: '5 seconds' }
|
|
669
|
-
);
|
|
670
|
-
const write = yield* socket.writer;
|
|
671
|
-
|
|
672
|
-
yield* socket.runString((text) => handleTrade(JSON.parse(text)), {
|
|
673
|
-
onOpen: Effect.orDie(write(JSON.stringify({ type: 'subscribe', channel: 'trades' })))
|
|
674
|
-
}).pipe(
|
|
675
|
-
Effect.retry({
|
|
676
|
-
schedule: Schedule.min([
|
|
677
|
-
Schedule.exponential('1 second'),
|
|
678
|
-
Schedule.spaced('30 seconds')
|
|
679
|
-
])
|
|
680
|
-
})
|
|
681
|
-
);
|
|
682
|
-
}).pipe(
|
|
683
|
-
Effect.scoped,
|
|
684
|
-
Effect.provide(NodeSocket.layerWebSocketConstructor)
|
|
142
|
+
const reconnect = (socket: Socket.Socket) => consume(socket).pipe(
|
|
143
|
+
Effect.retry({
|
|
144
|
+
schedule: Schedule.exponential(Duration.millis(500)).pipe(Schedule.upTo({ times: 5 })),
|
|
145
|
+
while: (error) => error.reason._tag === 'SocketCloseError' && error.reason.code !== 1000
|
|
146
|
+
})
|
|
685
147
|
);
|
|
686
148
|
```
|
|
687
149
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
150
|
+
`new Socket.CloseEvent(1000, 'done')` requests a close through `writer.write`.
|
|
151
|
+
WebSocket close codes are protocol data; TCP does not transmit them. Prefer
|
|
152
|
+
writer-scope release for a graceful TCP half-close rather than a close event.
|
|
153
|
+
|
|
154
|
+
## Streams, channels, and framing
|
|
155
|
+
|
|
156
|
+
`Socket.toStream(socket)` provides read-only byte consumption.
|
|
157
|
+
`Socket.toChannel(socket)` connects outgoing upstream frames to incoming byte
|
|
158
|
+
batches; `toChannelString` converts incoming frames to strings. These adapters
|
|
159
|
+
fail on close. A finite input alone does not imply successful completion of a
|
|
160
|
+
bidirectional protocol: bound expected responses or explicitly handle closure.
|
|
161
|
+
|
|
162
|
+
For TCP message boundaries use `Ndjson.duplexSchema(Socket.toChannel(socket),
|
|
163
|
+
{ inputSchema, outputSchema })`, or `SchemaBinary.duplex` with compatible schemas.
|
|
164
|
+
WebSocket text frames may use `Ndjson.duplexSchemaString` when NDJSON is the
|
|
165
|
+
agreed protocol. Decode unknown frames with schemas before domain processing.
|
|
166
|
+
MessagePack adapters are removed; SchemaBinary is a different wire format and
|
|
167
|
+
requires coordinated peer and persisted-data migration.
|
|
168
|
+
|
|
169
|
+
`Socket.make({ reader, writer })` is the custom-adapter boundary. Its reader must
|
|
170
|
+
fail suspended pulls when the acquisition scope closes; otherwise downstream
|
|
171
|
+
channel shutdown hangs. Prefer built-in adapters for platform transports.
|
|
172
|
+
|
|
173
|
+
## Servers and bound addresses
|
|
174
|
+
|
|
175
|
+
`NodeSocketServer.make({ port: 0 })` / `.layer(...)` bind TCP or Unix listeners.
|
|
176
|
+
Use `makeTls` / `layerTls` for TLS and `makeWebSocket` / `layerWebSocket` for
|
|
177
|
+
WebSockets. Bun exposes the shared API under `BunSocketServer`.
|
|
178
|
+
|
|
179
|
+
Fork `server.run(handler)` into the server scope; it does not complete normally.
|
|
180
|
+
Each accepted socket pauses until its reader takes ownership. Scope each handler's
|
|
181
|
+
reader/writer with `Effect.scoped`. Handler errors are reported by the server
|
|
182
|
+
rather than becoming accept-loop failures; add application-level supervision
|
|
183
|
+
where they must stop the service. HTTP upgrades yield the same Socket interface;
|
|
184
|
+
see `effect-http-server`.
|
|
185
|
+
|
|
186
|
+
Server addresses are `NetAddress.SocketAddress` from `effect/unstable/net`.
|
|
187
|
+
Narrow to `InetAddress` before reading `port`; format its IP with `formatIp`,
|
|
188
|
+
or use `formatHost` when passing a host and port separately (preserves IPv6 scopes).
|
|
189
|
+
Unix path addresses expose `path`. Use `inetAddressFromHostString` to parse numeric
|
|
190
|
+
hosts and `scopeIdsFromInterfaces` with supplied interface data for named zones.
|
|
191
|
+
URL helpers bracket IPv6 and reject scoped IPv6; do not assemble URLs by casting
|
|
192
|
+
an address to the removed `TcpAddress` type.
|
|
193
|
+
|
|
194
|
+
## Higher-level transports and tests
|
|
195
|
+
|
|
196
|
+
- RPC: `RpcClient.layerProtocolSocket` and `RpcServer.layerProtocolSocketServer`;
|
|
197
|
+
provide matching `RpcSerialization` layers and the socket/server layer.
|
|
198
|
+
- Cluster: `NodeClusterSocket` / `BunClusterSocket`; SchemaBinary is the default,
|
|
199
|
+
NDJSON is explicitly selectable.
|
|
200
|
+
- In-memory tests: `Socket.fromTransformStream` wraps a readable/writable pair;
|
|
201
|
+
end-of-input is still a close failure. Bound the read or handle the close reason.
|
|
202
|
+
- Integration tests: use a loopback listener on port 0, scope both sides, assert
|
|
203
|
+
message order, close/error propagation, and interruption cleanup.
|
|
204
|
+
|
|
205
|
+
Completion: every connection has one reader owner, writes have an active reader,
|
|
206
|
+
frames are decoded, retries reacquire resources, and shutdown releases both sides.
|