@grest-ts/http 0.0.66 → 0.0.68

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 (43) hide show
  1. package/README.md +23 -0
  2. package/dist/src/_dedupCheck.js +1 -1
  3. package/dist/src/client/GGHttpSchema.createClient.d.ts +10 -0
  4. package/dist/src/client/GGHttpSchema.createClient.d.ts.map +1 -1
  5. package/dist/src/client/GGHttpSchema.createClient.js +19 -2
  6. package/dist/src/client/GGHttpSchema.createClient.js.map +1 -1
  7. package/dist/src/client/GGHttpSchema.createClient.node.d.ts.map +1 -1
  8. package/dist/src/client/GGHttpSchema.createClient.node.js +3 -1
  9. package/dist/src/client/GGHttpSchema.createClient.node.js.map +1 -1
  10. package/dist/src/client/nodeConnectionTransport.node.d.ts +5 -0
  11. package/dist/src/client/nodeConnectionTransport.node.d.ts.map +1 -0
  12. package/dist/src/client/nodeConnectionTransport.node.js +168 -0
  13. package/dist/src/client/nodeConnectionTransport.node.js.map +1 -0
  14. package/dist/src/index-browser.d.ts +1 -0
  15. package/dist/src/index-browser.d.ts.map +1 -1
  16. package/dist/src/index-browser.js +1 -0
  17. package/dist/src/index-browser.js.map +1 -1
  18. package/dist/src/index-node.d.ts +2 -0
  19. package/dist/src/index-node.d.ts.map +1 -1
  20. package/dist/src/index-node.js +2 -0
  21. package/dist/src/index-node.js.map +1 -1
  22. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.d.ts.map +1 -1
  23. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js +10 -1
  24. package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js.map +1 -1
  25. package/dist/src/schema/GGConnectionSettingsKey.d.ts +21 -0
  26. package/dist/src/schema/GGConnectionSettingsKey.d.ts.map +1 -0
  27. package/dist/src/schema/GGConnectionSettingsKey.js +27 -0
  28. package/dist/src/schema/GGConnectionSettingsKey.js.map +1 -0
  29. package/dist/src/schema/GGHttpSchema.d.ts +2 -1
  30. package/dist/src/schema/GGHttpSchema.d.ts.map +1 -1
  31. package/dist/tsconfig.publish.tsbuildinfo +1 -1
  32. package/package.json +10 -10
  33. package/src/_dedupCheck.ts +1 -1
  34. package/src/client/GGHttpSchema.createClient.node.ts +3 -1
  35. package/src/client/GGHttpSchema.createClient.ts +32 -2
  36. package/src/client/nodeConnectionTransport.node.test.ts +320 -0
  37. package/src/client/nodeConnectionTransport.node.ts +181 -0
  38. package/src/index-browser.bundle.node.test.ts +51 -0
  39. package/src/index-browser.ts +1 -0
  40. package/src/index-node.ts +2 -0
  41. package/src/rpc/RpcRequest/GGRpcRequestBuilder.ts +10 -2
  42. package/src/schema/GGConnectionSettingsKey.ts +28 -0
  43. package/src/schema/GGHttpSchema.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grest-ts/http",
3
- "version": "0.0.66",
3
+ "version": "0.0.68",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "HTTP server and client library for Node.js and browser",
@@ -61,16 +61,16 @@
61
61
  "find-my-way": "^9.4.0"
62
62
  },
63
63
  "peerDependencies": {
64
- "@grest-ts/common": "0.0.66",
65
- "@grest-ts/context": "0.0.66",
66
- "@grest-ts/discovery": "0.0.66",
67
- "@grest-ts/locator": "0.0.66",
68
- "@grest-ts/logger": "0.0.66",
69
- "@grest-ts/metrics": "0.0.66",
70
- "@grest-ts/schema": "0.0.66",
71
- "@grest-ts/trace": "0.0.66"
64
+ "@grest-ts/common": "0.0.68",
65
+ "@grest-ts/context": "0.0.68",
66
+ "@grest-ts/discovery": "0.0.68",
67
+ "@grest-ts/locator": "0.0.68",
68
+ "@grest-ts/logger": "0.0.68",
69
+ "@grest-ts/metrics": "0.0.68",
70
+ "@grest-ts/schema": "0.0.68",
71
+ "@grest-ts/trace": "0.0.68"
72
72
  },
73
73
  "devDependencies": {
74
- "@grest-ts/testkit": "0.0.66"
74
+ "@grest-ts/testkit": "0.0.68"
75
75
  }
76
76
  }
@@ -12,7 +12,7 @@
12
12
  const __KEY = Symbol.for("grest-ts:dedup:@grest-ts/http");
13
13
  const __slot = globalThis as Record<symbol, unknown>;
14
14
  const __existing = __slot[__KEY] as { path: string; version: string } | undefined;
15
- const __here = { path: import.meta.url, version: "0.0.66" };
15
+ const __here = { path: import.meta.url, version: "0.0.68" };
16
16
  // Same path + same version means the same physical package is being
17
17
  // re-evaluated by a different ESM runner (e.g. vitest projects share one
18
18
  // process but each project has its own module cache). That isn't a true
@@ -4,7 +4,8 @@
4
4
  * The import stays dynamic so discovery remains an optional integration, not a
5
5
  * hard dependency of @grest-ts/http.
6
6
  */
7
- import {_registerDiscoveryUrlResolver} from "./GGHttpSchema.createClient"
7
+ import {_registerDiscoveryUrlResolver, _registerNodeDefaultTransport} from "./GGHttpSchema.createClient"
8
+ import {nodeDefaultTransport} from "./nodeConnectionTransport.node"
8
9
 
9
10
  export const discoveryUrlResolver = async (apiName: string): Promise<string> => {
10
11
  const {GG_DISCOVERY} = await import('@grest-ts/discovery')
@@ -12,3 +13,4 @@ export const discoveryUrlResolver = async (apiName: string): Promise<string> =>
12
13
  }
13
14
 
14
15
  _registerDiscoveryUrlResolver(discoveryUrlResolver)
16
+ _registerNodeDefaultTransport(nodeDefaultTransport)
@@ -1,6 +1,7 @@
1
1
  import {ANY_ERROR, ERROR, GGContractApiDefinition, GGContractClient, GGContractExecutor, GGContractImplementation, GGPromise, OK, SERVER_ERROR} from "@grest-ts/schema"
2
2
  import {ClientHttpRouteToRpcTransformClientCodec, GGHttpCodec, GGHttpSchema} from "../schema/GGHttpSchema";
3
3
  import {isBrowser} from "@grest-ts/common";
4
+ import type {GGConnectionSettings} from "@grest-ts/context";
4
5
 
5
6
 
6
7
  declare module "../schema/GGHttpSchema" {
@@ -30,6 +31,8 @@ export type GGHttpTransport = (
30
31
  body: string | FormData | undefined
31
32
  signal: AbortSignal
32
33
  credentials?: "omit" | "same-origin" | "include"
34
+ /** Transport-level dial settings (e.g. TLS pin) contributed by middleware and/or client config. */
35
+ connectionSettings?: GGConnectionSettings
33
36
  }
34
37
  ) => Promise<Response>
35
38
 
@@ -51,6 +54,12 @@ export interface GGHttpClientConfig {
51
54
  * just the schema-built path.
52
55
  */
53
56
  transport?: GGHttpTransport
57
+ /**
58
+ * Connection settings (e.g. TLS pin) fixed for this client's lifetime — the direct
59
+ * alternative to a `GGConnectionSettingsKey` middleware. Node-only: setting this on a
60
+ * browser client throws when a request is issued (the browser can't access the TLS layer).
61
+ */
62
+ connectionSettings?: GGConnectionSettings
54
63
  }
55
64
 
56
65
  /**
@@ -65,6 +74,18 @@ export function _registerDiscoveryUrlResolver(resolver: (apiName: string) => Pro
65
74
  discoveryUrlResolver = resolver
66
75
  }
67
76
 
77
+ /**
78
+ * Node-only default transport (pinned-TLS dialing). Attached by the node entry via
79
+ * ./GGHttpSchema.createClient.node; node-only because it imports `node:https`/`node:tls`.
80
+ * When registered it replaces `defaultFetchTransport` as the default (an explicit
81
+ * `config.transport` still wins). It falls back to `fetch` when no TLS pin is set.
82
+ */
83
+ let nodeDefaultTransport: GGHttpTransport | undefined
84
+
85
+ export function _registerNodeDefaultTransport(transport: GGHttpTransport): void {
86
+ nodeDefaultTransport = transport
87
+ }
88
+
68
89
  GGHttpSchema.prototype.createClient = function <TContract extends GGContractApiDefinition>(
69
90
  this: GGHttpSchema<TContract>,
70
91
  config?: GGHttpClientConfig
@@ -84,7 +105,7 @@ export function createClient<TContract extends GGContractApiDefinition>(
84
105
  // A custom transport implies "I take over the wire layer" — discovery is
85
106
  // off the table (the transport knows how to find the target), and the
86
107
  // default base URL becomes "" so the transport sees just the request path.
87
- const transport: GGHttpTransport = config.transport ?? defaultFetchTransport;
108
+ const transport: GGHttpTransport = config.transport ?? nodeDefaultTransport ?? defaultFetchTransport;
88
109
  if (config.transport && config.url === undefined) {
89
110
  config.url = "";
90
111
  }
@@ -129,6 +150,14 @@ export function createClient<TContract extends GGContractApiDefinition>(
129
150
  // ---------------------------------------------
130
151
  // Execution
131
152
  const fetchRequest = await wireFormat.createRequest(validatedInput);
153
+
154
+ const connectionSettings = config.connectionSettings || fetchRequest.connectionSettings
155
+ ? {...config.connectionSettings, ...fetchRequest.connectionSettings}
156
+ : undefined;
157
+ if (connectionSettings && isBrowser() && Object.keys(connectionSettings).length > 0) {
158
+ throw new SERVER_ERROR({displayMessage: "connectionSettings (e.g. TLS pinning) is node-only — the browser can't access the TLS layer, so it can't honor them."});
159
+ }
160
+
132
161
  const controller = new AbortController();
133
162
  const timeoutId = setTimeout(() => controller.abort(), config.timeout);
134
163
  const wireResponse = await transport(baseUrl + fetchRequest.url, {
@@ -136,7 +165,8 @@ export function createClient<TContract extends GGContractApiDefinition>(
136
165
  signal: controller.signal,
137
166
  headers: fetchRequest.headers,
138
167
  body: fetchRequest.body,
139
- credentials: config.credentials
168
+ credentials: config.credentials,
169
+ connectionSettings
140
170
  }).finally(() => clearTimeout(timeoutId));
141
171
  const resData = await wireFormat.parseResponse(wireResponse);
142
172
 
@@ -0,0 +1,320 @@
1
+ import {describe, it, expect, beforeAll, afterAll, afterEach, vi} from 'vitest'
2
+ import https from 'node:https'
3
+ import {execFileSync} from 'node:child_process'
4
+ import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
5
+ import {tmpdir} from 'node:os'
6
+ import {join} from 'node:path'
7
+ import {X509Certificate} from 'node:crypto'
8
+ import {GGContractClass, IsObject, IsString, SERVER_ERROR} from '@grest-ts/schema'
9
+ import {GGContext, type GGConnectionSettings} from '@grest-ts/context'
10
+ import {GGHttpSchema} from '../schema/GGHttpSchema'
11
+ import {GGRpc} from '../rpc/GGHttpRouteRPC'
12
+ import {createClient} from './GGHttpSchema.createClient'
13
+ import {GGConnectionSettingsKey} from '../schema/GGConnectionSettingsKey'
14
+ import {invalidateTlsPinAgent} from './nodeConnectionTransport.node'
15
+ // Side-effect import: registers nodeDefaultTransport as the default for url-less node clients.
16
+ import './GGHttpSchema.createClient.node'
17
+
18
+ const PingContract = new GGContractClass("PinnedTlsTestApi", {
19
+ ping: {
20
+ input: IsObject({msg: IsString}),
21
+ success: IsObject({msg: IsString}),
22
+ errors: [SERVER_ERROR],
23
+ },
24
+ })
25
+
26
+ const CONN = new GGConnectionSettingsKey("pinnedTlsTestConn")
27
+
28
+ // Mirror the request boundary: the runtime establishes one context per request, the app sets
29
+ // the key inside it. We do the same here — one scope per simulated operation, never per call.
30
+ function inScope<R>(settings: GGConnectionSettings, fn: () => R): R {
31
+ const ctx = new GGContext("test-req")
32
+ ctx.set(CONN, settings)
33
+ return ctx.run(fn)
34
+ }
35
+
36
+ const PingApi = new GGHttpSchema({
37
+ contract: PingContract,
38
+ pathPrefix: "api/ping",
39
+ routes: {ping: GGRpc.POST("ping")},
40
+ use: [CONN],
41
+ })
42
+
43
+ interface TestServer {
44
+ port: number
45
+ fingerprint256: string
46
+ reqCount: () => number
47
+ connCount: () => number
48
+ close: () => Promise<void>
49
+ }
50
+
51
+ function openssl(...args: string[]): void {
52
+ execFileSync("openssl", args, {stdio: "ignore"})
53
+ }
54
+
55
+ function genCert(dir: string, name: string): {cert: string; key: string} {
56
+ const certPath = join(dir, `${name}.cert.pem`)
57
+ const keyPath = join(dir, `${name}.key.pem`)
58
+ openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-keyout", keyPath, "-out", certPath, "-subj", `/CN=${name}`)
59
+ return {cert: readFileSync(certPath, "utf8"), key: readFileSync(keyPath, "utf8")}
60
+ }
61
+
62
+ interface TestCa {keyPath: string; crtPath: string; crt: string}
63
+
64
+ function genCa(dir: string, name: string): TestCa {
65
+ const keyPath = join(dir, `${name}.ca.key`)
66
+ const crtPath = join(dir, `${name}.ca.crt`)
67
+ openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-keyout", keyPath, "-out", crtPath, "-subj", `/CN=${name}-ca`)
68
+ return {keyPath, crtPath, crt: readFileSync(crtPath, "utf8")}
69
+ }
70
+
71
+ function genSignedCert(dir: string, name: string, ca: TestCa, sanIp?: string): {cert: string; key: string} {
72
+ const keyPath = join(dir, `${name}.key`)
73
+ const csrPath = join(dir, `${name}.csr`)
74
+ const crtPath = join(dir, `${name}.crt`)
75
+ openssl("req", "-newkey", "rsa:2048", "-nodes", "-keyout", keyPath, "-out", csrPath, "-subj", `/CN=${name}`)
76
+ const args = ["x509", "-req", "-in", csrPath, "-CA", ca.crtPath, "-CAkey", ca.keyPath, "-CAcreateserial", "-days", "1", "-out", crtPath]
77
+ if (sanIp) {
78
+ const extPath = join(dir, `${name}.ext`)
79
+ writeFileSync(extPath, `subjectAltName=IP:${sanIp}\n`)
80
+ args.push("-extfile", extPath)
81
+ }
82
+ openssl(...args)
83
+ return {cert: readFileSync(crtPath, "utf8"), key: readFileSync(keyPath, "utf8")}
84
+ }
85
+
86
+ async function startServer(cert: string, key: string, replyMsg: string, extra: https.ServerOptions = {}): Promise<TestServer> {
87
+ let reqCount = 0
88
+ let connCount = 0
89
+ const server = https.createServer({cert, key, ...extra}, (_req, res) => {
90
+ reqCount++
91
+ res.writeHead(200, {"content-type": "application/json"})
92
+ res.end(JSON.stringify({success: true, type: "OK", data: {msg: replyMsg}}))
93
+ })
94
+ server.on("connection", () => { connCount++ })
95
+ await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
96
+ const port = (server.address() as {port: number}).port
97
+ return {
98
+ port,
99
+ fingerprint256: new X509Certificate(cert).fingerprint256,
100
+ reqCount: () => reqCount,
101
+ connCount: () => connCount,
102
+ close: () => new Promise<void>(resolve => server.close(() => resolve())),
103
+ }
104
+ }
105
+
106
+ describe('pinned-TLS node transport', () => {
107
+
108
+ let dir: string
109
+ let serverA: TestServer
110
+ let serverB: TestServer
111
+
112
+ beforeAll(async () => {
113
+ dir = mkdtempSync(join(tmpdir(), "gg-pin-"))
114
+ const a = genCert(dir, "serverA")
115
+ const b = genCert(dir, "serverB")
116
+ serverA = await startServer(a.cert, a.key, "from-A")
117
+ serverB = await startServer(b.cert, b.key, "from-B")
118
+ })
119
+
120
+ afterAll(async () => {
121
+ await serverA?.close()
122
+ await serverB?.close()
123
+ if (dir) rmSync(dir, {recursive: true, force: true})
124
+ })
125
+
126
+ afterEach(() => {
127
+ vi.unstubAllGlobals()
128
+ })
129
+
130
+ it('accepts a server whose cert fingerprint matches the pin', async () => {
131
+ const client = createClient(PingApi, {url: ""})
132
+ const res = await inScope(
133
+ {host: "127.0.0.1", port: serverA.port, tlsPin: {fingerprint256: serverA.fingerprint256}},
134
+ () => client.ping({msg: "hi"}),
135
+ )
136
+ expect(res.msg).toBe("from-A")
137
+ })
138
+
139
+ it('rejects a fingerprint mismatch before any request bytes flow', async () => {
140
+ const before = serverA.reqCount()
141
+ const client = createClient(PingApi, {url: ""})
142
+ const bogus = "a".repeat(64)
143
+ const res = await inScope(
144
+ {host: "127.0.0.1", port: serverA.port, tlsPin: {fingerprint256: bogus}},
145
+ () => client.ping({msg: "hi"}).asResult(),
146
+ )
147
+ expect(res.success).toBe(false)
148
+ expect(res).toBeInstanceOf(SERVER_ERROR)
149
+ if (res instanceof SERVER_ERROR) {
150
+ // fetch surfaces a connector failure as `TypeError: fetch failed` with the real cause attached.
151
+ const original = res.getDebugContext()?.originalError as (Error & {cause?: Error}) | undefined
152
+ const chain = `${original?.message ?? ""} ${original?.cause?.message ?? ""}`
153
+ expect(chain).toMatch(/fingerprint mismatch/i)
154
+ }
155
+ expect(serverA.reqCount()).toBe(before)
156
+ })
157
+
158
+ it('isolates concurrent ops in separate context scopes — no cross-context bleed', async () => {
159
+ const client = createClient(PingApi, {url: ""})
160
+ const callA = inScope(
161
+ {host: "127.0.0.1", port: serverA.port, tlsPin: {fingerprint256: serverA.fingerprint256}},
162
+ () => client.ping({msg: "a"}),
163
+ )
164
+ const callB = inScope(
165
+ {host: "127.0.0.1", port: serverB.port, tlsPin: {fingerprint256: serverB.fingerprint256}},
166
+ () => client.ping({msg: "b"}),
167
+ )
168
+ const [resA, resB] = await Promise.all([callA, callB])
169
+ expect(resA.msg).toBe("from-A")
170
+ expect(resB.msg).toBe("from-B")
171
+ })
172
+
173
+ it('reuses a pooled connection per fingerprint; invalidate forces a fresh one', async () => {
174
+ // Dedicated server with a fresh fingerprint — the agent cache is module-global, so a
175
+ // fingerprint dialed by another test would already have a pooled socket.
176
+ const c = genCert(dir, "serverC")
177
+ const serverC = await startServer(c.cert, c.key, "from-C")
178
+ try {
179
+ const client = createClient(PingApi, {url: ""})
180
+ const pin = {host: "127.0.0.1", port: serverC.port, tlsPin: {fingerprint256: serverC.fingerprint256}}
181
+ await inScope(pin, () => client.ping({msg: "1"}))
182
+ await new Promise(r => setTimeout(r, 150)) // let undici return the socket to the pool
183
+ const afterFirst = serverC.connCount()
184
+ await inScope(pin, () => client.ping({msg: "2"}))
185
+ expect(serverC.connCount()).toBe(afterFirst) // same fingerprint → pooled socket reused
186
+
187
+ invalidateTlsPinAgent(serverC.fingerprint256)
188
+ await inScope(pin, () => client.ping({msg: "3"}))
189
+ expect(serverC.connCount()).toBe(afterFirst + 1) // fresh agent → new connection
190
+ } finally {
191
+ await serverC.close()
192
+ }
193
+ })
194
+
195
+ it('pins via client config (connectionSettings) — no context key needed', async () => {
196
+ const PublicApi = new GGHttpSchema({
197
+ contract: PingContract,
198
+ pathPrefix: "api/ping",
199
+ routes: {ping: GGRpc.POST("ping")},
200
+ })
201
+ const client = createClient(PublicApi, {
202
+ url: "",
203
+ connectionSettings: {host: "127.0.0.1", port: serverA.port, tlsPin: {fingerprint256: serverA.fingerprint256}},
204
+ })
205
+ const res = await client.ping({msg: "hi"})
206
+ expect(res.msg).toBe("from-A")
207
+ })
208
+
209
+ it('middleware overlays client config — the .set() pin wins', async () => {
210
+ const client = createClient(PingApi, {
211
+ url: "",
212
+ // config defaults to server A, but the in-scope pin targets server B and must win.
213
+ connectionSettings: {host: "127.0.0.1", port: serverA.port, tlsPin: {fingerprint256: serverA.fingerprint256}},
214
+ })
215
+ const res = await inScope(
216
+ {host: "127.0.0.1", port: serverB.port, tlsPin: {fingerprint256: serverB.fingerprint256}},
217
+ () => client.ping({msg: "hi"}),
218
+ )
219
+ expect(res.msg).toBe("from-B")
220
+ })
221
+
222
+ it('falls back to fetch when no pin is set', async () => {
223
+ const fetchSpy = vi.fn(async () => new Response(
224
+ JSON.stringify({success: true, type: "OK", data: {msg: "fetched"}}),
225
+ {status: 200, headers: {"content-type": "application/json"}},
226
+ ))
227
+ vi.stubGlobal("fetch", fetchSpy)
228
+ const client = createClient(PingApi, {url: "http://example.test"})
229
+ const res = await client.ping({msg: "hi"})
230
+ expect(res.msg).toBe("fetched")
231
+ expect(fetchSpy).toHaveBeenCalledOnce()
232
+ })
233
+
234
+ it('throws in the browser when settings are present, but not when absent', async () => {
235
+ vi.stubGlobal("window", {document: {}})
236
+
237
+ const pinnedClient = createClient(PingApi, {
238
+ url: "",
239
+ connectionSettings: {host: "127.0.0.1", port: serverA.port, tlsPin: {fingerprint256: serverA.fingerprint256}},
240
+ })
241
+ const res = await pinnedClient.ping({msg: "hi"}).asResult()
242
+ expect(res.success).toBe(false)
243
+ if (res instanceof SERVER_ERROR) {
244
+ expect(res.context?.displayMessage ?? "").toMatch(/node-only/i)
245
+ }
246
+
247
+ // No settings set → behaves like a normal fetch client even in the browser.
248
+ const fetchSpy = vi.fn(async () => new Response(
249
+ JSON.stringify({success: true, type: "OK", data: {msg: "fetched"}}),
250
+ {status: 200, headers: {"content-type": "application/json"}},
251
+ ))
252
+ vi.stubGlobal("fetch", fetchSpy)
253
+ const plainClient = createClient(PingApi, {url: "http://example.test"})
254
+ const ok = await plainClient.ping({msg: "hi"})
255
+ expect(ok.msg).toBe("fetched")
256
+ })
257
+ })
258
+
259
+ describe('connection settings — custom CA & mTLS', () => {
260
+
261
+ let dir: string
262
+ let srvCa: TestCa
263
+ let server: {cert: string; key: string}
264
+ let clientPem: {cert: string; key: string}
265
+ let caServer: TestServer
266
+ let mtlsServer: TestServer
267
+
268
+ beforeAll(async () => {
269
+ dir = mkdtempSync(join(tmpdir(), "gg-tls-"))
270
+ srvCa = genCa(dir, "srv")
271
+ server = genSignedCert(dir, "server", srvCa, "127.0.0.1") // SAN so hostname verification passes
272
+ const clientCa = genCa(dir, "client")
273
+ clientPem = genSignedCert(dir, "clientcert", clientCa)
274
+ caServer = await startServer(server.cert, server.key, "from-ca-server")
275
+ mtlsServer = await startServer(server.cert, server.key, "from-mtls-server", {
276
+ requestCert: true,
277
+ rejectUnauthorized: true,
278
+ ca: clientCa.crt, // verify the client cert against this CA
279
+ })
280
+ })
281
+
282
+ afterAll(async () => {
283
+ await caServer?.close()
284
+ await mtlsServer?.close()
285
+ if (dir) rmSync(dir, {recursive: true, force: true})
286
+ })
287
+
288
+ it('verifies the server against a custom ca', async () => {
289
+ const client = createClient(PingApi, {
290
+ url: "",
291
+ connectionSettings: {host: "127.0.0.1", port: caServer.port, ca: srvCa.crt},
292
+ })
293
+ const res = await client.ping({msg: "hi"})
294
+ expect(res.msg).toBe("from-ca-server")
295
+ })
296
+
297
+ it('rejects a server signed by an untrusted ca (no ca provided)', async () => {
298
+ const client = createClient(PingApi, {url: `https://127.0.0.1:${caServer.port}`})
299
+ const res = await client.ping({msg: "hi"}).asResult()
300
+ expect(res.success).toBe(false)
301
+ })
302
+
303
+ it('presents a client certificate for mTLS', async () => {
304
+ const client = createClient(PingApi, {
305
+ url: "",
306
+ connectionSettings: {host: "127.0.0.1", port: mtlsServer.port, ca: srvCa.crt, clientCert: clientPem},
307
+ })
308
+ const res = await client.ping({msg: "hi"})
309
+ expect(res.msg).toBe("from-mtls-server")
310
+ })
311
+
312
+ it('mTLS server rejects a client with no certificate', async () => {
313
+ const client = createClient(PingApi, {
314
+ url: "",
315
+ connectionSettings: {host: "127.0.0.1", port: mtlsServer.port, ca: srvCa.crt},
316
+ })
317
+ const res = await client.ping({msg: "hi"}).asResult()
318
+ expect(res.success).toBe(false)
319
+ })
320
+ })
@@ -0,0 +1,181 @@
1
+ import {createHash} from "node:crypto"
2
+ import type {TLSSocket} from "node:tls"
3
+ import {Agent, buildConnector, type Dispatcher} from "undici"
4
+ import type {GGConnectionSettings} from "@grest-ts/context"
5
+ import type {GGHttpTransport} from "./GGHttpSchema.createClient"
6
+
7
+ /**
8
+ * Node default transport: ordinary `fetch`, dialing through an undici dispatcher built from the
9
+ * request's connection settings. `fetch` keeps the wire correct (real Response/headers/streaming,
10
+ * FormData, abort, redirects); the dispatcher is the one node-only seam. When no settings call for
11
+ * a custom dispatcher it's plain `fetch` — identical to the browser path.
12
+ */
13
+
14
+ const DEFAULT_MAX_AGENTS = 2048
15
+ const DEFAULT_IDLE_TTL_MS = 5 * 60_000
16
+ const DEFAULT_SWEEP_INTERVAL_MS = 60_000
17
+
18
+ function normalizeFingerprint(input: string): string {
19
+ return input.replace(/:/g, "").toLowerCase()
20
+ }
21
+
22
+ /** Which settings shape the TLS connection (and therefore the dispatcher). */
23
+ function needsDispatcher(s: GGConnectionSettings): boolean {
24
+ return !!(s.tlsPin || s.ca || s.clientCert)
25
+ }
26
+
27
+ /** tls.connect options shared by both the pinned and the standard connector. */
28
+ function connectOptions(s: GGConnectionSettings) {
29
+ return {
30
+ ca: s.ca,
31
+ cert: s.clientCert?.cert,
32
+ key: s.clientCert?.key,
33
+ passphrase: s.clientCert?.passphrase,
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Build the undici Agent for a set of TLS settings.
39
+ * - tlsPin → self-signed pinning: `rejectUnauthorized: false` (skip the CA chain — the fingerprint
40
+ * replaces it) + verify the peer cert's fingerprint once connected, destroying the socket on
41
+ * mismatch before any request bytes flow. A resumed TLS session doesn't re-send the cert and
42
+ * proves knowledge of the original master secret, so the re-check is safely skipped.
43
+ * `rejectUnauthorized: false` WITHOUT the fingerprint check is a full MITM hole.
44
+ * - otherwise → standard verification, optionally against a custom `ca` and presenting a
45
+ * `clientCert` for mTLS.
46
+ */
47
+ function buildAgent(s: GGConnectionSettings): Agent {
48
+ const connect = connectOptions(s)
49
+ const pin = s.tlsPin
50
+ if (!pin) {
51
+ return new Agent({connect})
52
+ }
53
+ const expected = normalizeFingerprint(pin.fingerprint256)
54
+ if (!/^[0-9a-f]{64}$/.test(expected)) {
55
+ throw new Error(`pinned TLS: fingerprint must be 64 hex chars (got "${pin.fingerprint256}")`)
56
+ }
57
+ const baseConnector = buildConnector({...connect, rejectUnauthorized: false, servername: pin.servername})
58
+ return new Agent({
59
+ connect(opts, cb) {
60
+ baseConnector(opts, (err, socket) => {
61
+ if (err) return cb(err, null)
62
+ const tls = socket as TLSSocket
63
+ if (typeof tls.getPeerX509Certificate !== "function") {
64
+ socket.destroy()
65
+ return cb(new Error("pinned TLS requires an https target"), null)
66
+ }
67
+ if (!tls.isSessionReused()) {
68
+ const raw = tls.getPeerX509Certificate()?.fingerprint256
69
+ const actual = raw ? normalizeFingerprint(raw) : undefined
70
+ if (actual !== expected) {
71
+ tls.destroy()
72
+ return cb(new Error(`pinned TLS: cert fingerprint mismatch — expected ${expected}, got ${actual ?? "<none>"}`), null)
73
+ }
74
+ }
75
+ cb(null, socket)
76
+ })
77
+ },
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Stable key per distinct TLS config, so each pools its own connections. Pin-only (the common,
83
+ * high-frequency case) keys off the cheap fingerprint; CA/mTLS hash the (larger) PEM material.
84
+ */
85
+ function agentKey(s: GGConnectionSettings): string {
86
+ const pin = s.tlsPin ? `${normalizeFingerprint(s.tlsPin.fingerprint256)}|${s.tlsPin.servername ?? ""}` : ""
87
+ if (!s.ca && !s.clientCert) return `pin:${pin}`
88
+ return "tls:" + createHash("sha256").update(JSON.stringify([
89
+ pin,
90
+ s.ca ?? null,
91
+ s.clientCert?.cert ?? null,
92
+ s.clientCert?.key ?? null,
93
+ s.clientCert?.passphrase ?? null,
94
+ ])).digest("hex")
95
+ }
96
+
97
+ // One Agent per distinct TLS config. Pinned fingerprints rotate per server start, so those entries
98
+ // are per-server-run; invalidate() drops them after a restart, and the LRU cap + idle TTL bound the
99
+ // map for long-lived multi-target processes that never invalidate.
100
+ class TlsAgentCache {
101
+
102
+ private readonly agents = new Map<string, {agent: Agent; fingerprint?: string; lastUsed: number}>()
103
+ private readonly sweepTimer: NodeJS.Timeout
104
+
105
+ constructor() {
106
+ this.sweepTimer = setInterval(() => this.sweepIdle(), DEFAULT_SWEEP_INTERVAL_MS)
107
+ this.sweepTimer.unref()
108
+ }
109
+
110
+ get(settings: GGConnectionSettings): Agent {
111
+ const key = agentKey(settings)
112
+ const existing = this.agents.get(key)
113
+ if (existing) {
114
+ existing.lastUsed = Date.now()
115
+ this.agents.delete(key)
116
+ this.agents.set(key, existing)
117
+ return existing.agent
118
+ }
119
+ const agent = buildAgent(settings)
120
+ const fingerprint = settings.tlsPin ? normalizeFingerprint(settings.tlsPin.fingerprint256) : undefined
121
+ this.agents.set(key, {agent, fingerprint, lastUsed: Date.now()})
122
+ while (this.agents.size > DEFAULT_MAX_AGENTS) {
123
+ const lru = this.agents.keys().next().value
124
+ if (lru === undefined) break
125
+ this.drop(lru)
126
+ }
127
+ return agent
128
+ }
129
+
130
+ invalidateFingerprint(fingerprint: string): void {
131
+ const fp = normalizeFingerprint(fingerprint)
132
+ for (const [key, entry] of this.agents) {
133
+ if (entry.fingerprint === fp) this.drop(key)
134
+ }
135
+ }
136
+
137
+ private sweepIdle(): void {
138
+ const cutoff = Date.now() - DEFAULT_IDLE_TTL_MS
139
+ for (const [key, entry] of this.agents) {
140
+ if (entry.lastUsed < cutoff) this.drop(key)
141
+ }
142
+ }
143
+
144
+ private drop(key: string): void {
145
+ const existing = this.agents.get(key)
146
+ if (!existing) return
147
+ // close() drains in-flight requests then closes idle sockets; destroy() is the hard fallback.
148
+ existing.agent.close().catch(() => existing.agent.destroy())
149
+ this.agents.delete(key)
150
+ }
151
+ }
152
+
153
+ const AGENT_CACHE = new TlsAgentCache()
154
+
155
+ /** Drop the pooled dispatcher(s) for a pinned fingerprint so stale sockets don't linger after a restart. */
156
+ export function invalidateTlsPinAgent(fingerprint256: string): void {
157
+ AGENT_CACHE.invalidateFingerprint(fingerprint256)
158
+ }
159
+
160
+ /**
161
+ * Map connection settings onto an undici dispatcher — the single extensibility seam. Future
162
+ * settings (proxy → ProxyAgent, http2 → allowH2, …) add a branch here, with no change to the
163
+ * transport or the middleware hook.
164
+ */
165
+ function dispatcherFor(settings: GGConnectionSettings): Dispatcher | undefined {
166
+ if (needsDispatcher(settings)) return AGENT_CACHE.get(settings)
167
+ return undefined
168
+ }
169
+
170
+ /** url is path-only for a url-less client; settings.host (when set) supplies the origin. */
171
+ function dialUrl(url: string, settings: GGConnectionSettings): string {
172
+ if (!settings.host) return url
173
+ const port = settings.port ? `:${settings.port}` : ""
174
+ return `https://${settings.host}${port}${url}`
175
+ }
176
+
177
+ export const nodeDefaultTransport: GGHttpTransport = (url, init) => {
178
+ const {connectionSettings = {}, ...rest} = init
179
+ const dispatcher = dispatcherFor(connectionSettings)
180
+ return fetch(dialUrl(url, connectionSettings), {...rest, dispatcher} as RequestInit & {dispatcher?: Dispatcher})
181
+ }