starpc 0.51.0 → 0.52.1

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 (93) hide show
  1. package/cmd/protoc-gen-es-starpc/typescript.ts +37 -0
  2. package/dist/cmd/protoc-gen-es-starpc/typescript.js +24 -0
  3. package/dist/echo/client-test.d.ts +1 -1
  4. package/dist/echo/client-test.js +110 -2
  5. package/dist/echo/echo_srpc.pb.d.ts +44 -1
  6. package/dist/echo/server.d.ts +9 -8
  7. package/dist/echo/server.js +6 -6
  8. package/dist/integration/cross-language/tcp-packet-stream.d.ts +3 -0
  9. package/dist/integration/cross-language/tcp-packet-stream.js +112 -0
  10. package/dist/integration/cross-language/tcp-packet-stream.test.d.ts +1 -0
  11. package/dist/integration/cross-language/tcp-packet-stream.test.js +121 -0
  12. package/dist/integration/cross-language/ts-client.js +50 -37
  13. package/dist/integration/cross-language/ts-server.js +1 -36
  14. package/dist/mock/mock_srpc.pb.d.ts +14 -1
  15. package/dist/rpcstream/rpcstream.d.ts +5 -1
  16. package/dist/rpcstream/rpcstream.js +75 -28
  17. package/dist/rpcstream/rpcstream.test.d.ts +1 -0
  18. package/dist/rpcstream/rpcstream.test.js +92 -0
  19. package/dist/srpc/channel.js +6 -3
  20. package/dist/srpc/channel.test.js +20 -1
  21. package/dist/srpc/client.js +18 -4
  22. package/dist/srpc/common-rpc.test.js +2 -0
  23. package/dist/srpc/handler.d.ts +12 -3
  24. package/dist/srpc/index.d.ts +2 -0
  25. package/dist/srpc/index.js +1 -0
  26. package/dist/srpc/invoker.d.ts +2 -1
  27. package/dist/srpc/invoker.js +2 -2
  28. package/dist/srpc/packet-codec.test.d.ts +1 -0
  29. package/dist/srpc/packet-codec.test.js +75 -0
  30. package/dist/srpc/packet.d.ts +1 -1
  31. package/dist/srpc/packet.js +11 -1
  32. package/dist/srpc/server-context.d.ts +11 -0
  33. package/dist/srpc/server-context.js +28 -0
  34. package/dist/srpc/server-rpc.js +3 -1
  35. package/dist/srpc/server.js +19 -6
  36. package/dist/srpc/server.test.js +78 -6
  37. package/dist/srpc/stream.d.ts +4 -1
  38. package/dist/srpc/stream.js +62 -3
  39. package/dist/srpc/stream.test.js +110 -1
  40. package/dist/srpc/termination.d.ts +27 -0
  41. package/dist/srpc/termination.js +56 -0
  42. package/dist/srpc/termination.test.d.ts +1 -0
  43. package/dist/srpc/termination.test.js +24 -0
  44. package/dist/srpc/watchdog.test.js +1 -0
  45. package/dist/testdata/packet-codec-vectors.json +64 -0
  46. package/echo/client-test.ts +124 -2
  47. package/echo/echo_pb2.py +40 -0
  48. package/echo/echo_pb2.pyi +13 -0
  49. package/echo/echo_srpc.pb.ts +74 -0
  50. package/echo/echo_srpc.py +306 -0
  51. package/echo/echo_srpc.pyi +85 -0
  52. package/echo/server.ts +24 -5
  53. package/go.mod +2 -2
  54. package/go.sum +14 -0
  55. package/integration/cross-language/go-client/main.go +79 -3
  56. package/integration/cross-language/python-client.py +146 -0
  57. package/integration/cross-language/python-server.py +140 -0
  58. package/integration/cross-language/run.bash +190 -65
  59. package/integration/cross-language/tcp-packet-stream.test.ts +154 -0
  60. package/integration/cross-language/tcp-packet-stream.ts +121 -0
  61. package/integration/cross-language/ts-client.ts +62 -40
  62. package/integration/cross-language/ts-server.ts +1 -45
  63. package/mock/mock_pb2.py +38 -0
  64. package/mock/mock_pb2.pyi +11 -0
  65. package/mock/mock_srpc.pb.ts +19 -1
  66. package/mock/mock_srpc.py +71 -0
  67. package/mock/mock_srpc.pyi +27 -0
  68. package/package.json +20 -6
  69. package/srpc/__init__.py +0 -0
  70. package/srpc/channel.test.ts +21 -1
  71. package/srpc/channel.ts +7 -3
  72. package/srpc/client.ts +20 -4
  73. package/srpc/codec.rs +6 -0
  74. package/srpc/common-rpc.test.ts +2 -0
  75. package/srpc/handler.ts +54 -4
  76. package/srpc/index.ts +7 -0
  77. package/srpc/invoker.ts +23 -6
  78. package/srpc/packet-codec-vectors_test.go +195 -0
  79. package/srpc/packet-codec.test.ts +139 -0
  80. package/srpc/packet-rw.go +9 -2
  81. package/srpc/packet.ts +15 -2
  82. package/srpc/py.typed +0 -0
  83. package/srpc/rpcproto_pb2.py +40 -0
  84. package/srpc/rpcproto_pb2.pyi +40 -0
  85. package/srpc/server-context.ts +55 -0
  86. package/srpc/server-rpc.ts +4 -1
  87. package/srpc/server.test.ts +100 -5
  88. package/srpc/server.ts +22 -6
  89. package/srpc/stream.test.ts +132 -1
  90. package/srpc/stream.ts +65 -9
  91. package/srpc/termination.test.ts +30 -0
  92. package/srpc/termination.ts +70 -0
  93. package/srpc/watchdog.test.ts +1 -0
@@ -1,11 +1,13 @@
1
1
  #!/bin/bash
2
2
  # Cross-language integration tests for starpc.
3
- # Runs all 12 server/client combinations across Go, TypeScript, Rust, and C++.
3
+ # Runs all 21 server/client combinations across Go, TypeScript, Rust, C++, and Python.
4
4
  #
5
5
  # Usage:
6
- # ./run.bash # Run all pairs
7
- # ./run.bash go:ts # Run go-server+ts-client and ts-server+go-client
8
- # ./run.bash go:ts go:rust # Run multiple pair filters
6
+ # ./run.bash # Run all pairs
7
+ # ./run.bash go:ts # Run go-server+ts-client and ts-server+go-client
8
+ # ./run.bash python:go # Run python-server+go-client and go-server+python-client
9
+ # ./run.bash --nested ts:python # Run nested TypeScript/Python pairs
10
+ # ./run.bash --nested go:ts go:python ts:python # Run nested Go/TypeScript/Python pairs
9
11
  set -eo pipefail
10
12
 
11
13
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -14,25 +16,79 @@ REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
14
16
  # Fixes errors with the generated esm using require()
15
17
  ESM_BANNER='import{fileURLToPath}from"node:url";import{dirname}from"node:path";import{createRequire as topLevelCreateRequire}from"node:module";const require=topLevelCreateRequire(import.meta.url);const __filename=fileURLToPath(import.meta.url);const __dirname=dirname(__filename);'
16
18
 
17
- FILTERS=("$@")
19
+ NESTED=false
20
+ FILTERS=()
21
+ for arg in "$@"; do
22
+ if [ "$arg" = "--nested" ]; then
23
+ NESTED=true
24
+ else
25
+ FILTERS+=("$arg")
26
+ fi
27
+ done
28
+
29
+ needs_language() {
30
+ local language="$1"
31
+ if [ ${#FILTERS[@]} -eq 0 ]; then
32
+ return 0
33
+ fi
34
+ for filter in "${FILTERS[@]}"; do
35
+ if [[ ":${filter}:" == *":${language}:"* ]]; then
36
+ return 0
37
+ fi
38
+ done
39
+ return 1
40
+ }
18
41
 
19
42
  PASSED=0
20
43
  FAILED=0
21
44
  ERRORS=""
22
45
 
46
+ SERVER_PID=""
23
47
  SERVER_LOG=""
48
+ SERVER_EXIT_STATUS=0
49
+ CLIENT_PID=""
50
+ CLIENT_OUT=""
24
51
 
25
- # should_run checks if a test name matches the active filters.
52
+ cleanup() {
53
+ if [ -n "${CLIENT_PID:-}" ]; then
54
+ kill "$CLIENT_PID" 2>/dev/null || true
55
+ wait "$CLIENT_PID" 2>/dev/null || true
56
+ CLIENT_PID=""
57
+ fi
58
+ if [ -n "${SERVER_PID:-}" ]; then
59
+ kill "$SERVER_PID" 2>/dev/null || true
60
+ wait "$SERVER_PID" 2>/dev/null || true
61
+ SERVER_PID=""
62
+ fi
63
+ if [ -n "${SERVER_LOG:-}" ]; then
64
+ rm -f "$SERVER_LOG"
65
+ SERVER_LOG=""
66
+ fi
67
+ if [ -n "${CLIENT_OUT:-}" ]; then
68
+ rm -f "$CLIENT_OUT"
69
+ CLIENT_OUT=""
70
+ fi
71
+ }
72
+
73
+ trap cleanup EXIT
74
+ trap 'exit 130' INT
75
+ trap 'exit 143' TERM
76
+
77
+ # should_run checks if a server/client pair matches the active filters.
26
78
  # Returns 0 (true) if the test should run, 1 (false) otherwise.
27
79
  should_run() {
28
80
  local test_name="$1"
29
81
  if [ ${#FILTERS[@]} -eq 0 ]; then
30
82
  return 0
31
83
  fi
84
+ local server_language="${test_name%%-server*}"
85
+ local client_language="${test_name#*+ }"
86
+ client_language="${client_language%%-client*}"
32
87
  for filter in "${FILTERS[@]}"; do
33
88
  local lang1="${filter%%:*}"
34
89
  local lang2="${filter##*:}"
35
- if [[ "$test_name" == *"${lang1}-"* && "$test_name" == *"${lang2}-"* ]]; then
90
+ if { [ "$server_language" = "$lang1" ] && [ "$client_language" = "$lang2" ]; } ||
91
+ { [ "$server_language" = "$lang2" ] && [ "$client_language" = "$lang1" ]; }; then
36
92
  return 0
37
93
  fi
38
94
  done
@@ -42,32 +98,39 @@ should_run() {
42
98
  # Build all binaries.
43
99
  echo "=== Building all integration binaries ==="
44
100
 
45
- echo "Building Go server/client..."
46
- go build -o "$SCRIPT_DIR/go-server/go-server" "$SCRIPT_DIR/go-server/"
47
- go build -o "$SCRIPT_DIR/go-client/go-client" "$SCRIPT_DIR/go-client/"
48
-
49
- echo "Building TypeScript server/client..."
50
- "$REPO_DIR/node_modules/.bin/esbuild" "$SCRIPT_DIR/ts-server.ts" \
51
- --bundle --sourcemap --platform=node --format=esm \
52
- --banner:js="$ESM_BANNER" \
53
- --outfile="$SCRIPT_DIR/ts-server.mjs"
54
- "$REPO_DIR/node_modules/.bin/esbuild" "$SCRIPT_DIR/ts-client.ts" \
55
- --bundle --sourcemap --platform=node --format=esm \
56
- --banner:js="$ESM_BANNER" \
57
- --outfile="$SCRIPT_DIR/ts-client.mjs"
58
-
59
- echo "Building Rust server/client..."
60
- cargo build --release -p echo-example --bin integration-server --bin integration-client
61
-
62
- echo "Vendoring Go dependencies (needed for C++ build)..."
63
- go mod vendor
64
-
65
- echo "Building C++ server/client..."
66
- mkdir -p "$REPO_DIR/build"
67
- pushd "$REPO_DIR/build" > /dev/null
68
- cmake "$REPO_DIR" -DCMAKE_BUILD_TYPE=Release > /dev/null 2>&1
69
- cmake --build . --target cpp-integration-server cpp-integration-client --parallel > /dev/null 2>&1
70
- popd > /dev/null
101
+ if needs_language go; then
102
+ echo "Building Go server/client..."
103
+ go build -o "$SCRIPT_DIR/go-server/go-server" "$SCRIPT_DIR/go-server/"
104
+ go build -o "$SCRIPT_DIR/go-client/go-client" "$SCRIPT_DIR/go-client/"
105
+ fi
106
+
107
+ if needs_language ts; then
108
+ echo "Building TypeScript server/client..."
109
+ "$REPO_DIR/node_modules/.bin/esbuild" "$SCRIPT_DIR/ts-server.ts" \
110
+ --bundle --sourcemap --platform=node --format=esm \
111
+ --banner:js="$ESM_BANNER" \
112
+ --outfile="$SCRIPT_DIR/ts-server.mjs"
113
+ "$REPO_DIR/node_modules/.bin/esbuild" "$SCRIPT_DIR/ts-client.ts" \
114
+ --bundle --sourcemap --platform=node --format=esm \
115
+ --banner:js="$ESM_BANNER" \
116
+ --outfile="$SCRIPT_DIR/ts-client.mjs"
117
+ fi
118
+
119
+ if needs_language rust; then
120
+ echo "Building Rust server/client..."
121
+ cargo build --release -p echo-example --bin integration-server --bin integration-client
122
+ fi
123
+
124
+ if needs_language cpp; then
125
+ echo "Vendoring Go dependencies (needed for C++ build)..."
126
+ go mod vendor
127
+ echo "Building C++ server/client..."
128
+ mkdir -p "$REPO_DIR/build"
129
+ pushd "$REPO_DIR/build" > /dev/null
130
+ cmake "$REPO_DIR" -DCMAKE_BUILD_TYPE=Release > /dev/null 2>&1
131
+ cmake --build . --target cpp-integration-server cpp-integration-client --parallel > /dev/null 2>&1
132
+ popd > /dev/null
133
+ fi
71
134
 
72
135
  # Binary paths.
73
136
  GO_SERVER="$SCRIPT_DIR/go-server/go-server"
@@ -78,6 +141,7 @@ RUST_SERVER="$REPO_DIR/target/release/integration-server"
78
141
  RUST_CLIENT="$REPO_DIR/target/release/integration-client"
79
142
  CPP_SERVER="$REPO_DIR/build/cpp-integration-server"
80
143
  CPP_CLIENT="$REPO_DIR/build/cpp-integration-client"
144
+ PYTHON=(uv run --project "$REPO_DIR" python)
81
145
 
82
146
  start_server() {
83
147
  SERVER_LOG=$(mktemp)
@@ -95,8 +159,9 @@ start_server() {
95
159
  SERVER_ADDR=$(grep 'LISTENING' "$SERVER_LOG" 2>/dev/null | awk '{print $2}')
96
160
  if [ -z "$SERVER_ADDR" ]; then
97
161
  echo "FAILED: server did not start"
98
- kill $SERVER_PID 2>/dev/null || true
99
- wait $SERVER_PID 2>/dev/null || true
162
+ kill "$SERVER_PID" 2>/dev/null || true
163
+ wait "$SERVER_PID" 2>/dev/null || true
164
+ SERVER_PID=""
100
165
  rm -f "$SERVER_LOG"
101
166
  SERVER_LOG=""
102
167
  return 1
@@ -105,8 +170,36 @@ start_server() {
105
170
  }
106
171
 
107
172
  stop_server() {
108
- kill $SERVER_PID 2>/dev/null || true
109
- wait $SERVER_PID 2>/dev/null || true
173
+ kill "$SERVER_PID" 2>/dev/null || true
174
+ if wait "$SERVER_PID" 2>/dev/null; then
175
+ SERVER_EXIT_STATUS=0
176
+ else
177
+ SERVER_EXIT_STATUS=$?
178
+ fi
179
+ SERVER_PID=""
180
+ }
181
+
182
+ python_server_passed() {
183
+ local status="$1"
184
+ local log="$2"
185
+ [ "$status" -eq 0 ] && grep -qx "NESTED_CLEAN" "$log"
186
+ }
187
+
188
+ verify_python_server_guard() {
189
+ local log
190
+ log="$(mktemp)"
191
+ printf '%s\n' "NESTED_CLEAN" >"$log"
192
+ if python_server_passed 1 "$log"; then
193
+ rm -f "$log"
194
+ echo "runner guard accepted a nonzero Python server" >&2
195
+ return 1
196
+ fi
197
+ if ! python_server_passed 0 "$log"; then
198
+ rm -f "$log"
199
+ echo "runner guard rejected a clean Python server" >&2
200
+ return 1
201
+ fi
202
+ rm -f "$log"
110
203
  }
111
204
 
112
205
  # run_pair <test_name> <server_args...> -- <client_args...>
@@ -142,13 +235,25 @@ run_pair() {
142
235
  return
143
236
  fi
144
237
 
145
- local client_out
146
- client_out=$(mktemp)
238
+ CLIENT_OUT=$(mktemp)
147
239
  local client_ok=false
148
- if "${cli_args[@]}" "$SERVER_ADDR" > "$client_out" 2>&1; then
240
+ timeout 60 "${cli_args[@]}" "$SERVER_ADDR" > "$CLIENT_OUT" 2>&1 &
241
+ CLIENT_PID=$!
242
+ if wait "$CLIENT_PID"; then
149
243
  client_ok=true
150
244
  fi
245
+ CLIENT_PID=""
151
246
  stop_server
247
+ if $NESTED && [[ "$test_name" == python-server* ]]; then
248
+ if ! verify_python_server_guard; then
249
+ client_ok=false
250
+ echo " Python server result guard regression failed"
251
+ elif ! python_server_passed "$SERVER_EXIT_STATUS" "$SERVER_LOG"; then
252
+ client_ok=false
253
+ echo " Python server failed nested shutdown (status $SERVER_EXIT_STATUS):"
254
+ sed 's/^/ /' "$SERVER_LOG"
255
+ fi
256
+ fi
152
257
 
153
258
  if $client_ok; then
154
259
  echo "PASSED"
@@ -158,9 +263,10 @@ run_pair() {
158
263
  FAILED=$((FAILED + 1))
159
264
  ERRORS="${ERRORS}\n ${test_name}"
160
265
  echo " client output:"
161
- sed 's/^/ /' "$client_out"
266
+ sed 's/^/ /' "$CLIENT_OUT"
162
267
  fi
163
- rm -f "$client_out" "$SERVER_LOG"
268
+ rm -f "$CLIENT_OUT" "$SERVER_LOG"
269
+ CLIENT_OUT=""
164
270
  SERVER_LOG=""
165
271
  }
166
272
 
@@ -168,29 +274,48 @@ echo ""
168
274
  echo "=== Running cross-language integration tests ==="
169
275
  echo ""
170
276
 
171
- # Go server combinations
172
- run_pair "go-server + go-client" "$GO_SERVER" -- "$GO_CLIENT"
173
- run_pair "go-server + rust-client" "$GO_SERVER" -- "$RUST_CLIENT"
174
- run_pair "go-server + ts-client" "$GO_SERVER" -- node "$TS_CLIENT"
175
- run_pair "go-server + cpp-client" "$GO_SERVER" -- "$CPP_CLIENT"
176
-
177
- # Rust server combinations
178
- run_pair "rust-server + go-client" "$RUST_SERVER" -- "$GO_CLIENT"
179
- run_pair "rust-server + rust-client" "$RUST_SERVER" -- "$RUST_CLIENT"
180
- run_pair "rust-server + ts-client" "$RUST_SERVER" -- node "$TS_CLIENT"
181
- run_pair "rust-server + cpp-client" "$RUST_SERVER" -- "$CPP_CLIENT"
182
-
183
- # TypeScript server combinations
184
- run_pair "ts-server + go-client" node "$TS_SERVER" -- "$GO_CLIENT"
185
- run_pair "ts-server + rust-client" node "$TS_SERVER" -- "$RUST_CLIENT"
186
- run_pair "ts-server + ts-client" node "$TS_SERVER" -- node "$TS_CLIENT"
187
- run_pair "ts-server + cpp-client" node "$TS_SERVER" -- "$CPP_CLIENT"
188
-
189
- # C++ server combinations
190
- run_pair "cpp-server + go-client" "$CPP_SERVER" -- "$GO_CLIENT"
191
- run_pair "cpp-server + rust-client" "$CPP_SERVER" -- "$RUST_CLIENT"
192
- run_pair "cpp-server + ts-client" "$CPP_SERVER" -- node "$TS_CLIENT"
193
- run_pair "cpp-server + cpp-client" "$CPP_SERVER" -- "$CPP_CLIENT"
277
+ if $NESTED; then
278
+ # Nested streams have maintained Go, TypeScript, and Python endpoints.
279
+ run_pair "go-server + ts-client" "$GO_SERVER" -- node "$TS_CLIENT" --nested
280
+ run_pair "go-server + python-client" "$GO_SERVER" -- "${PYTHON[@]}" "$SCRIPT_DIR/python-client.py" --nested
281
+
282
+ run_pair "ts-server + go-client" node "$TS_SERVER" -- "$GO_CLIENT" --nested
283
+ run_pair "ts-server + python-client" node "$TS_SERVER" -- "${PYTHON[@]}" "$SCRIPT_DIR/python-client.py" --nested
284
+
285
+ run_pair "python-server + go-client" "${PYTHON[@]}" "$SCRIPT_DIR/python-server.py" -- "$GO_CLIENT" --nested-release
286
+ run_pair "python-server + ts-client" "${PYTHON[@]}" "$SCRIPT_DIR/python-server.py" -- node "$TS_CLIENT" --nested-release
287
+ else
288
+ # Go server combinations
289
+ run_pair "go-server + go-client" "$GO_SERVER" -- "$GO_CLIENT"
290
+ run_pair "go-server + rust-client" "$GO_SERVER" -- "$RUST_CLIENT"
291
+ run_pair "go-server + ts-client" "$GO_SERVER" -- node "$TS_CLIENT"
292
+ run_pair "go-server + cpp-client" "$GO_SERVER" -- "$CPP_CLIENT"
293
+ run_pair "go-server + python-client" "$GO_SERVER" -- "${PYTHON[@]}" "$SCRIPT_DIR/python-client.py"
294
+
295
+ # Rust server combinations
296
+ run_pair "rust-server + go-client" "$RUST_SERVER" -- "$GO_CLIENT"
297
+ run_pair "rust-server + rust-client" "$RUST_SERVER" -- "$RUST_CLIENT"
298
+ run_pair "rust-server + ts-client" "$RUST_SERVER" -- node "$TS_CLIENT"
299
+ run_pair "rust-server + cpp-client" "$RUST_SERVER" -- "$CPP_CLIENT"
300
+
301
+ # TypeScript server combinations
302
+ run_pair "ts-server + go-client" node "$TS_SERVER" -- "$GO_CLIENT"
303
+ run_pair "ts-server + rust-client" node "$TS_SERVER" -- "$RUST_CLIENT"
304
+ run_pair "ts-server + ts-client" node "$TS_SERVER" -- node "$TS_CLIENT"
305
+ run_pair "ts-server + cpp-client" node "$TS_SERVER" -- "$CPP_CLIENT"
306
+ run_pair "ts-server + python-client" node "$TS_SERVER" -- "${PYTHON[@]}" "$SCRIPT_DIR/python-client.py"
307
+
308
+ # Python server combinations
309
+ run_pair "python-server + go-client" "${PYTHON[@]}" "$SCRIPT_DIR/python-server.py" -- "$GO_CLIENT"
310
+ run_pair "python-server + ts-client" "${PYTHON[@]}" "$SCRIPT_DIR/python-server.py" -- node "$TS_CLIENT" lifecycle
311
+ run_pair "python-server + python-client" "${PYTHON[@]}" "$SCRIPT_DIR/python-server.py" -- "${PYTHON[@]}" "$SCRIPT_DIR/python-client.py"
312
+
313
+ # C++ server combinations
314
+ run_pair "cpp-server + go-client" "$CPP_SERVER" -- "$GO_CLIENT"
315
+ run_pair "cpp-server + rust-client" "$CPP_SERVER" -- "$RUST_CLIENT"
316
+ run_pair "cpp-server + ts-client" "$CPP_SERVER" -- node "$TS_CLIENT"
317
+ run_pair "cpp-server + cpp-client" "$CPP_SERVER" -- "$CPP_CLIENT"
318
+ fi
194
319
 
195
320
  echo ""
196
321
  echo "=== Results: ${PASSED} passed, ${FAILED} failed ==="
@@ -0,0 +1,154 @@
1
+ import net from 'net'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { pushable } from 'it-pushable'
4
+
5
+ import { tcpSocketToPacketStream } from './tcp-packet-stream.js'
6
+
7
+ const sockets: net.Socket[] = []
8
+ const servers: net.Server[] = []
9
+
10
+ afterEach(() => {
11
+ for (const socket of sockets) socket.destroy()
12
+ for (const server of servers) server.close()
13
+ sockets.length = 0
14
+ servers.length = 0
15
+ })
16
+
17
+ describe('TCP PacketStream lifecycle', () => {
18
+ it('settles a blocked source when the peer ends cleanly', async () => {
19
+ const { local, peer } = await connectSockets()
20
+ const stream = tcpSocketToPacketStream(local)
21
+ const pending = stream.source.next()
22
+
23
+ peer.end()
24
+
25
+ await expect(settleBeforeTimeout(pending)).resolves.toEqual({
26
+ done: true,
27
+ value: undefined,
28
+ })
29
+ })
30
+
31
+ it('reports a truncated frame when the peer ends', async () => {
32
+ const { local, peer } = await connectSockets()
33
+ const stream = tcpSocketToPacketStream(local)
34
+ const pending = stream.source.next()
35
+
36
+ peer.end(Buffer.from([4, 0, 0, 0, 10, 1]))
37
+
38
+ await expect(settleBeforeTimeout(pending)).rejects.toThrow(
39
+ 'truncated packet frame',
40
+ )
41
+ })
42
+
43
+ it('rejects a blocked source with the abort error', async () => {
44
+ const { local } = await connectSockets()
45
+ const stream = tcpSocketToPacketStream(local)
46
+ const pending = stream.source.next()
47
+ const error = new Error('stopped')
48
+
49
+ stream.abort(error)
50
+
51
+ await expect(settleBeforeTimeout(pending)).rejects.toBe(error)
52
+ })
53
+
54
+ it('settles a blocked sink when closed', async () => {
55
+ const { local } = await connectSockets()
56
+ const stream = tcpSocketToPacketStream(local)
57
+ const input = pushable<Uint8Array>({ objectMode: true })
58
+ const pending = stream.sink(input)
59
+
60
+ await stream.close()
61
+
62
+ await expect(pending).resolves.toBeUndefined()
63
+ })
64
+
65
+ it('settles an in-flight socket write when closed', async () => {
66
+ const { local } = await connectSockets()
67
+ const stream = tcpSocketToPacketStream(local)
68
+ const input = pushable<Uint8Array>({ objectMode: true })
69
+ const writeStarted = Promise.withResolvers<void>()
70
+ vi.spyOn(local, 'write').mockImplementation(((
71
+ _data: Uint8Array,
72
+ _callback: (error?: Error) => void,
73
+ ) => {
74
+ writeStarted.resolve()
75
+ return true
76
+ }) as typeof local.write)
77
+ input.push(new Uint8Array([1]))
78
+ const pending = stream.sink(input)
79
+ await writeStarted.promise
80
+
81
+ await stream.close()
82
+
83
+ await expect(settleBeforeTimeout(pending)).resolves.toBeUndefined()
84
+ })
85
+
86
+ it('rejects a blocked sink with the abort error', async () => {
87
+ const { local } = await connectSockets()
88
+ const stream = tcpSocketToPacketStream(local)
89
+ const input = pushable<Uint8Array>({ objectMode: true })
90
+ const pending = stream.sink(input)
91
+ const error = new Error('stopped')
92
+
93
+ stream.abort(error)
94
+
95
+ await expect(pending).rejects.toBe(error)
96
+ })
97
+
98
+ it('does not write ready input after close', async () => {
99
+ const { local, peer } = await connectSockets()
100
+ const stream = tcpSocketToPacketStream(local)
101
+ const input = pushable<Uint8Array>({ objectMode: true })
102
+ const received: Buffer[] = []
103
+ peer.on('data', (data) => {
104
+ if (typeof data !== 'string') received.push(data)
105
+ })
106
+ input.push(new Uint8Array([1]))
107
+
108
+ const pending = stream.sink(input)
109
+ await stream.close()
110
+ await pending
111
+ await new Promise<void>((resolve) => peer.once('close', () => resolve()))
112
+
113
+ expect(received).toEqual([])
114
+ })
115
+ })
116
+
117
+ async function connectSockets(): Promise<{
118
+ local: net.Socket
119
+ peer: net.Socket
120
+ }> {
121
+ const accepted = Promise.withResolvers<net.Socket>()
122
+ const server = net.createServer((socket) => accepted.resolve(socket))
123
+ servers.push(server)
124
+ await new Promise<void>((resolve, reject) => {
125
+ server.once('error', reject)
126
+ server.listen(0, '127.0.0.1', resolve)
127
+ })
128
+ const address = server.address() as net.AddressInfo
129
+ const local = net.connect(address.port, address.address)
130
+ await new Promise<void>((resolve, reject) => {
131
+ local.once('connect', resolve)
132
+ local.once('error', reject)
133
+ })
134
+ const peer = await accepted.promise
135
+ sockets.push(local, peer)
136
+ return { local, peer }
137
+ }
138
+
139
+ async function settleBeforeTimeout<T>(promise: Promise<T>): Promise<T> {
140
+ let timeout: ReturnType<typeof setTimeout> | undefined
141
+ try {
142
+ return await Promise.race([
143
+ promise,
144
+ new Promise<never>((_resolve, reject) => {
145
+ timeout = setTimeout(
146
+ () => reject(new Error('source stayed blocked')),
147
+ 500,
148
+ )
149
+ }),
150
+ ])
151
+ } finally {
152
+ clearTimeout(timeout)
153
+ }
154
+ }
@@ -0,0 +1,121 @@
1
+ import net from 'net'
2
+ import { pipe } from 'it-pipe'
3
+ import { pushable } from 'it-pushable'
4
+ import type { Source } from 'it-stream-types'
5
+
6
+ import { combineUint8ArrayListTransform } from '../../srpc/array-list.js'
7
+ import {
8
+ parseLengthPrefixTransform,
9
+ prependLengthPrefixTransform,
10
+ } from '../../srpc/packet.js'
11
+ import type { PacketStream } from '../../srpc/stream.js'
12
+ import {
13
+ closeIterator,
14
+ sourceIterator,
15
+ TerminationGate,
16
+ } from '../../srpc/termination.js'
17
+
18
+ // tcpSocketToPacketStream wraps a Node.js TCP socket into a PacketStream.
19
+ export function tcpSocketToPacketStream(socket: net.Socket): PacketStream {
20
+ const sourceTermination = new TerminationGate()
21
+ const sinkTermination = new TerminationGate()
22
+ const bytes = pushable<Uint8Array>({ objectMode: true })
23
+ let bytesEnded = false
24
+
25
+ const endBytes = (error?: Error) => {
26
+ if (bytesEnded) return
27
+ bytesEnded = true
28
+ bytes.end(error)
29
+ }
30
+ const terminate = (error?: Error): boolean => {
31
+ const first = sourceTermination.terminate(error)
32
+ sinkTermination.terminate(error)
33
+ endBytes(error)
34
+ return first
35
+ }
36
+
37
+ socket.on('data', (data: Buffer) => {
38
+ if (!bytesEnded) bytes.push(new Uint8Array(data))
39
+ })
40
+ socket.on('end', () => endBytes())
41
+ socket.on('error', (error) => terminate(error))
42
+ socket.on('close', () => {
43
+ endBytes()
44
+ sinkTermination.terminate()
45
+ })
46
+
47
+ const close = async (): Promise<void> => {
48
+ if (terminate()) socket.destroy()
49
+ }
50
+ const abort = (error: Error): void => {
51
+ if (terminate(error)) socket.destroy(error)
52
+ }
53
+
54
+ return {
55
+ close,
56
+ abort,
57
+ source: (async function* () {
58
+ const packets = pipe(
59
+ bytes,
60
+ parseLengthPrefixTransform(),
61
+ combineUint8ArrayListTransform(),
62
+ )[Symbol.asyncIterator]()
63
+ try {
64
+ while (true) {
65
+ const next = await sourceTermination.next(packets)
66
+ if ('terminated' in next) {
67
+ if (next.error) throw next.error
68
+ return
69
+ }
70
+ if ('error' in next) throw next.error
71
+ if (next.result.done) return
72
+ yield next.result.value
73
+ }
74
+ } finally {
75
+ closeIterator(packets)
76
+ }
77
+ })(),
78
+ sink: async (source: Source<Uint8Array>): Promise<void> => {
79
+ const iterator = sourceIterator(
80
+ pipe(source, prependLengthPrefixTransform()),
81
+ )
82
+ try {
83
+ while (true) {
84
+ const next = await sinkTermination.next(iterator)
85
+ if ('terminated' in next) {
86
+ if (next.error) throw next.error
87
+ return
88
+ }
89
+ if ('error' in next) throw next.error
90
+ if (next.result.done) {
91
+ socket.end()
92
+ return
93
+ }
94
+ const data =
95
+ next.result.value instanceof Uint8Array
96
+ ? next.result.value
97
+ : next.result.value.subarray()
98
+ const written = await sinkTermination.wait(
99
+ new Promise<void>((resolve, reject) => {
100
+ socket.write(data, (error) => {
101
+ if (error) reject(error)
102
+ else resolve()
103
+ })
104
+ }),
105
+ )
106
+ if ('terminated' in written) {
107
+ if (written.error) throw written.error
108
+ return
109
+ }
110
+ if ('error' in written) throw written.error
111
+ }
112
+ } catch (err) {
113
+ const error = err instanceof Error ? err : new Error(String(err))
114
+ abort(error)
115
+ throw error
116
+ } finally {
117
+ closeIterator(iterator)
118
+ }
119
+ },
120
+ }
121
+ }