@grest-ts/http 0.0.67 → 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.
- package/README.md +23 -0
- package/dist/src/_dedupCheck.js +1 -1
- package/dist/src/client/GGHttpSchema.createClient.d.ts +10 -0
- package/dist/src/client/GGHttpSchema.createClient.d.ts.map +1 -1
- package/dist/src/client/GGHttpSchema.createClient.js +19 -2
- package/dist/src/client/GGHttpSchema.createClient.js.map +1 -1
- package/dist/src/client/GGHttpSchema.createClient.node.d.ts.map +1 -1
- package/dist/src/client/GGHttpSchema.createClient.node.js +3 -1
- package/dist/src/client/GGHttpSchema.createClient.node.js.map +1 -1
- package/dist/src/client/nodeConnectionTransport.node.d.ts +5 -0
- package/dist/src/client/nodeConnectionTransport.node.d.ts.map +1 -0
- package/dist/src/client/nodeConnectionTransport.node.js +168 -0
- package/dist/src/client/nodeConnectionTransport.node.js.map +1 -0
- package/dist/src/index-browser.d.ts +1 -0
- package/dist/src/index-browser.d.ts.map +1 -1
- package/dist/src/index-browser.js +1 -0
- package/dist/src/index-browser.js.map +1 -1
- package/dist/src/index-node.d.ts +2 -0
- package/dist/src/index-node.d.ts.map +1 -1
- package/dist/src/index-node.js +2 -0
- package/dist/src/index-node.js.map +1 -1
- package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.d.ts.map +1 -1
- package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js +10 -1
- package/dist/src/rpc/RpcRequest/GGRpcRequestBuilder.js.map +1 -1
- package/dist/src/schema/GGConnectionSettingsKey.d.ts +21 -0
- package/dist/src/schema/GGConnectionSettingsKey.d.ts.map +1 -0
- package/dist/src/schema/GGConnectionSettingsKey.js +27 -0
- package/dist/src/schema/GGConnectionSettingsKey.js.map +1 -0
- package/dist/src/schema/GGHttpSchema.d.ts +2 -1
- package/dist/src/schema/GGHttpSchema.d.ts.map +1 -1
- package/dist/tsconfig.publish.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/_dedupCheck.ts +1 -1
- package/src/client/GGHttpSchema.createClient.node.ts +3 -1
- package/src/client/GGHttpSchema.createClient.ts +32 -2
- package/src/client/nodeConnectionTransport.node.test.ts +320 -0
- package/src/client/nodeConnectionTransport.node.ts +181 -0
- package/src/index-browser.bundle.node.test.ts +51 -0
- package/src/index-browser.ts +1 -0
- package/src/index-node.ts +2 -0
- package/src/rpc/RpcRequest/GGRpcRequestBuilder.ts +10 -2
- package/src/schema/GGConnectionSettingsKey.ts +28 -0
- 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.
|
|
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.
|
|
65
|
-
"@grest-ts/context": "0.0.
|
|
66
|
-
"@grest-ts/discovery": "0.0.
|
|
67
|
-
"@grest-ts/locator": "0.0.
|
|
68
|
-
"@grest-ts/logger": "0.0.
|
|
69
|
-
"@grest-ts/metrics": "0.0.
|
|
70
|
-
"@grest-ts/schema": "0.0.
|
|
71
|
-
"@grest-ts/trace": "0.0.
|
|
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.
|
|
74
|
+
"@grest-ts/testkit": "0.0.68"
|
|
75
75
|
}
|
|
76
76
|
}
|
package/src/_dedupCheck.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|