@emseepea/server 0.0.1 → 0.0.3
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 +299 -8
- package/dist/http.d.ts +15 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +284 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +286 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1612 -0
- package/dist/index.js.map +1 -0
- package/dist/telemetry.d.ts +3 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +93 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/ui.d.ts +185 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +263 -0
- package/dist/ui.js.map +1 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -5,8 +5,9 @@ Protocol (MCP) `2026-07-28` servers over Streamable HTTP.
|
|
|
5
5
|
|
|
6
6
|
It checks data when requests enter and leave the server. It supports public
|
|
7
7
|
tools, tools that require sign-in, public resources and prompts, request time
|
|
8
|
-
limits, and bounded progress updates
|
|
9
|
-
|
|
8
|
+
limits, and bounded progress updates. Direct tools,
|
|
9
|
+
resources, and prompts can ask capable clients for more information. Prompt
|
|
10
|
+
arguments and resource fields may also offer suggestions.
|
|
10
11
|
|
|
11
12
|
The package is pre-alpha. See the
|
|
12
13
|
[repository README](https://github.com/windyroad/emseepea#readme) for what the
|
|
@@ -50,6 +51,221 @@ const app = createEmseepea({ name: "coffee-guide", version: "1.0.0", tools: [get
|
|
|
50
51
|
await serveEmseepea(app);
|
|
51
52
|
```
|
|
52
53
|
|
|
54
|
+
## Measure Requests
|
|
55
|
+
|
|
56
|
+
Available in this checkout; not yet published to npm.
|
|
57
|
+
|
|
58
|
+
Set `telemetry: true` in `createEmseepea` to record request traces, counts, and
|
|
59
|
+
response times through OpenTelemetry. Leave it out to disable these measurements.
|
|
60
|
+
|
|
61
|
+
Configure your OpenTelemetry providers and context manager before creating the
|
|
62
|
+
app. Em See Pea does not choose an exporter or send data to a service.
|
|
63
|
+
|
|
64
|
+
### What Gets Measured
|
|
65
|
+
|
|
66
|
+
Each trace covers one `/mcp` request through response completion or
|
|
67
|
+
disconnection, including progress updates. Measurements report:
|
|
68
|
+
|
|
69
|
+
- the HTTP method and status
|
|
70
|
+
- the known MCP method, or `_OTHER` for unknown names
|
|
71
|
+
- whether the response finished or disconnected
|
|
72
|
+
|
|
73
|
+
An HTTP 200 response can still contain a tool error. These measurements describe
|
|
74
|
+
the response, not whether the tool achieved its task.
|
|
75
|
+
|
|
76
|
+
### What Stays Out
|
|
77
|
+
|
|
78
|
+
The framework does not attach tool names, request data, tokens, URLs, or error
|
|
79
|
+
messages. It preserves a valid active parent trace identity, but drops baggage
|
|
80
|
+
and other context data. It does not read trace headers itself.
|
|
81
|
+
|
|
82
|
+
### Current Limits
|
|
83
|
+
|
|
84
|
+
SDK host and origin rejections happen before this measurement starts.
|
|
85
|
+
Ordinary telemetry API and exporter failures do not change tool results.
|
|
86
|
+
An adopter exporter that blocks the process or throws outside the telemetry
|
|
87
|
+
call is not isolated.
|
|
88
|
+
|
|
89
|
+
Structured logs are not included yet.
|
|
90
|
+
|
|
91
|
+
## Report Dependency Readiness
|
|
92
|
+
|
|
93
|
+
Available in this checkout; not yet published to npm.
|
|
94
|
+
|
|
95
|
+
Pass a `readiness` callback to `createEmseepea` to check the dependencies your
|
|
96
|
+
server needs. The callback receives `{ signal }` and must return `true` only
|
|
97
|
+
when those dependencies are ready. It may return a promise.
|
|
98
|
+
|
|
99
|
+
`GET /readyz` returns:
|
|
100
|
+
|
|
101
|
+
- HTTP 200 and `ready` when the check succeeds
|
|
102
|
+
- HTTP 503 and `not ready` when it fails, throws, or takes too long
|
|
103
|
+
|
|
104
|
+
The reply never includes dependency details or error messages. It is not cached.
|
|
105
|
+
`GET /healthz` continues to report that the process is alive, not that its
|
|
106
|
+
dependencies work. Without a callback, readiness does not check dependencies.
|
|
107
|
+
|
|
108
|
+
Set `readinessTimeoutMs` to limit the check. It defaults to 1,000 milliseconds
|
|
109
|
+
and accepts whole numbers from 1 to 60,000. A timeout or shutdown signals
|
|
110
|
+
cancellation. Only one dependency check can remain unfinished at a time; if it
|
|
111
|
+
ignores cancellation, later probes return not-ready until it settles.
|
|
112
|
+
|
|
113
|
+
Readiness reports health for a load balancer or monitoring system. It does not
|
|
114
|
+
block MCP calls itself. Tool handlers must still handle an unavailable backend.
|
|
115
|
+
|
|
116
|
+
## Flush Measurements on Shutdown
|
|
117
|
+
|
|
118
|
+
Available in this checkout; not yet published to npm.
|
|
119
|
+
|
|
120
|
+
Pass `flushTelemetry` to `serveEmseepea` to flush your OpenTelemetry providers
|
|
121
|
+
when the returned server's `close()` method runs. The callback receives
|
|
122
|
+
`{ signal }`; it can call your providers' `forceFlush()` methods. Calling the
|
|
123
|
+
Fastify app's `close()` directly does not run this callback.
|
|
124
|
+
|
|
125
|
+
Shutdown uses two time limits:
|
|
126
|
+
|
|
127
|
+
- `shutdownTimeoutMs`: finish existing requests, then close remaining
|
|
128
|
+
connections. The default is 5,000 milliseconds.
|
|
129
|
+
- `telemetryFlushTimeoutMs`: wait for the final request measurements, then flush.
|
|
130
|
+
The default is 1,000 milliseconds; the allowed range is 1 to 60,000.
|
|
131
|
+
|
|
132
|
+
The total waiting budget is the sum of those limits. The second limit applies
|
|
133
|
+
only when you provide `flushTelemetry`. If final measurements are not ready in
|
|
134
|
+
time, flushing is skipped. Repeated `close()` calls share the same work.
|
|
135
|
+
|
|
136
|
+
Flushing is best-effort. Its errors do not change tool responses, and completion
|
|
137
|
+
of `close()` does not prove that an exporter delivered data. Callbacks that block
|
|
138
|
+
JavaScript or ignore cancellation cannot be forcibly stopped by the framework.
|
|
139
|
+
|
|
140
|
+
## Describe What Clients Can Show
|
|
141
|
+
|
|
142
|
+
Applications can give servers, tools, resources, reusable resource addresses,
|
|
143
|
+
and prompts a human-friendly title, description, and icons. A server can also
|
|
144
|
+
provide its website address. Tools can say whether they only read data and
|
|
145
|
+
whether they contact services outside the application:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const getBeanDetails = defineTool({
|
|
149
|
+
name: "get-bean-details",
|
|
150
|
+
title: "Get bean details",
|
|
151
|
+
access: "public",
|
|
152
|
+
description: "Get the recorded details for one coffee.",
|
|
153
|
+
icons: [{ src: "https://coffee.example/icons/bean.png", mimeType: "image/png" }],
|
|
154
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
155
|
+
inputSchema: z.object({ name: z.string() }),
|
|
156
|
+
outputSchema: beanDetails,
|
|
157
|
+
handler,
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Resources may also include their known byte size and hints about their intended
|
|
162
|
+
audience and importance. Use `_meta` for application-specific public details.
|
|
163
|
+
|
|
164
|
+
Em See Pea checks this metadata when the application starts and copies it, so
|
|
165
|
+
later changes to the original objects have no effect. Tool annotations are
|
|
166
|
+
hints for clients. They do not prove that a tool is safe, grant permission, or
|
|
167
|
+
replace sign-in and authorization checks.
|
|
168
|
+
|
|
169
|
+
## Route with a Tool Argument
|
|
170
|
+
|
|
171
|
+
A proxy can route a request using a checked tool argument without reading the
|
|
172
|
+
JSON body. Mark a string, integer, or boolean property with `x-mcp-header`:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
const inputSchema = z.object({
|
|
176
|
+
region: z.string().meta({ "x-mcp-header": "Region" }),
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Compatible clients send the same value as `Mcp-Param-Region`. The tool still
|
|
181
|
+
receives and checks `region` normally. The server rejects a missing or
|
|
182
|
+
different header before the tool runs.
|
|
183
|
+
|
|
184
|
+
This header is a copy of tool input. It does not identify a person, prove that
|
|
185
|
+
they signed in, grant permission, or protect a secret.
|
|
186
|
+
|
|
187
|
+
## Bound Large Catalogues
|
|
188
|
+
|
|
189
|
+
Split long tool, resource, resource-address, and prompt lists into pages by
|
|
190
|
+
adding one option to the server:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const app = createEmseepea({
|
|
194
|
+
name: "coffee-guide",
|
|
195
|
+
version: "1.0.0",
|
|
196
|
+
tools,
|
|
197
|
+
listPagination: { pageSize: 50, maxPageBytes: 256 * 1024 },
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`pageSize` must be between 1 and 100. `maxPageBytes` defaults to one mebibyte and
|
|
202
|
+
stops a page earlier when its public catalogue details would be too large. The
|
|
203
|
+
server stops at startup if one item cannot fit.
|
|
204
|
+
|
|
205
|
+
Clients receive an opaque cursor for the next page. Identical server instances
|
|
206
|
+
accept the same cursor. Changing the catalogue or either limit makes old
|
|
207
|
+
cursors invalid. Cursors select public catalogue pages; they do not identify a
|
|
208
|
+
person or grant permission. Omit `listPagination` to keep one-page lists.
|
|
209
|
+
|
|
210
|
+
## Ask the Client for More Information
|
|
211
|
+
|
|
212
|
+
Application authors can create a direct tool, resource, resource address
|
|
213
|
+
pattern, or prompt that pauses and asks a capable client for more information.
|
|
214
|
+
The client answers by making a fresh request, and the handler runs again with
|
|
215
|
+
those answers in its context.
|
|
216
|
+
|
|
217
|
+
This example asks the person for a coffee preference:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
import { acceptedContent, defineTool, inputRequired } from "@emseepea/server";
|
|
221
|
+
import { z } from "zod";
|
|
222
|
+
|
|
223
|
+
const preference = z.object({ roast: z.enum(["light", "medium", "dark"]) });
|
|
224
|
+
|
|
225
|
+
const chooseRoast = defineTool({
|
|
226
|
+
name: "choose-roast",
|
|
227
|
+
access: "public",
|
|
228
|
+
description: "Ask which roast a person prefers for one coffee.",
|
|
229
|
+
inputSchema: z.object({ coffee: z.string() }),
|
|
230
|
+
outputSchema: z.object({ coffee: z.string(), roast: z.string() }),
|
|
231
|
+
handler: ({ coffee }, context) => {
|
|
232
|
+
const answer = acceptedContent(context.inputResponses, "preference", preference);
|
|
233
|
+
if (!answer) {
|
|
234
|
+
return inputRequired({
|
|
235
|
+
inputRequests: {
|
|
236
|
+
preference: inputRequired.elicit({
|
|
237
|
+
message: `Which roast do you prefer for ${coffee}?`,
|
|
238
|
+
requestedSchema: preference,
|
|
239
|
+
}),
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
text: `${answer.roast} roast selected for ${coffee}`,
|
|
245
|
+
data: { coffee, roast: answer.roast },
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Use `acceptedContent` with the same schema that described the form. Client
|
|
252
|
+
answers are untrusted even when they have the expected wire shape. Use
|
|
253
|
+
`inputResponse` when the handler must distinguish acceptance, refusal, and
|
|
254
|
+
cancellation.
|
|
255
|
+
|
|
256
|
+
A client answer supplies information. It is not proof of identity or permission
|
|
257
|
+
to change data. Authorize any effect through the application's normal security
|
|
258
|
+
boundary.
|
|
259
|
+
|
|
260
|
+
The client must advertise elicitation for form input or URL-mode elicitation,
|
|
261
|
+
where the client opens a URL. Em See Pea applies the normal result-size limit,
|
|
262
|
+
time limit, cancellation, safe-error handling, and sign-in policy to every
|
|
263
|
+
round.
|
|
264
|
+
|
|
265
|
+
This release supports stateless requests only. It rejects opaque
|
|
266
|
+
`requestState`. Mapped tools and progress-reporting tools return through their
|
|
267
|
+
existing checked paths and cannot request more client input.
|
|
268
|
+
|
|
53
269
|
## Mapped Backend Tool
|
|
54
270
|
|
|
55
271
|
`defineMappedTool` validates the public input, mapped backend command, backend
|
|
@@ -166,7 +382,8 @@ progress, the official MCP library sends progress events over the same POST
|
|
|
166
382
|
request. Otherwise the call returns one JSON response.
|
|
167
383
|
|
|
168
384
|
```ts
|
|
169
|
-
import { defineStreamingTool } from "@emseepea/server";
|
|
385
|
+
import { createEmseepea, defineStreamingTool } from "@emseepea/server";
|
|
386
|
+
import { z } from "zod";
|
|
170
387
|
|
|
171
388
|
const roast = defineStreamingTool({
|
|
172
389
|
name: "roast-batch",
|
|
@@ -190,13 +407,64 @@ change those positive bounds. The reporter closes with the tool call.
|
|
|
190
407
|
Heartbeats are disabled. Invalid, oversized, late, or extra updates are
|
|
191
408
|
rejected.
|
|
192
409
|
|
|
193
|
-
|
|
410
|
+
### Use Progress Behind a Proxy
|
|
411
|
+
|
|
412
|
+
In the current source, public tools can also report progress behind a trusted
|
|
413
|
+
HTTPS proxy. This addition is not yet in a published npm release.
|
|
414
|
+
|
|
415
|
+
Using the `roast` tool above, configure the proxy's exact address and the public
|
|
416
|
+
host and origin your clients use:
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
const app = createEmseepea({
|
|
420
|
+
name: "coffee-guide",
|
|
421
|
+
version: "1.0.0",
|
|
422
|
+
tools: [roast],
|
|
423
|
+
deployment: {
|
|
424
|
+
mode: "production-behind-proxy",
|
|
425
|
+
trustedProxyAddresses: ["10.0.0.10"],
|
|
426
|
+
allowedAuthorities: ["mcp.example.com"],
|
|
427
|
+
allowedOrigins: ["https://mcp.example.com"],
|
|
428
|
+
rateLimit: { maxRequests: 100, windowMs: 60_000, maxClients: 1_000 },
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Replace these example addresses with yours. The server still listens locally
|
|
434
|
+
by default. Choose a private listening address explicitly when starting it,
|
|
435
|
+
and restrict network access so only your proxy can reach it.
|
|
436
|
+
|
|
437
|
+
Your proxy must replace caller-supplied forwarding headers and send:
|
|
438
|
+
|
|
439
|
+
- `X-Forwarded-Proto: https` for the original HTTPS connection
|
|
440
|
+
- `X-Forwarded-For` containing one client IP address, not a comma-separated chain
|
|
441
|
+
- `Host` matching an entry in `allowedAuthorities`
|
|
442
|
+
|
|
443
|
+
It must also forward streamed responses without buffering them.
|
|
444
|
+
`X-Accel-Buffering: no` asks compatible proxies not to buffer; it does not
|
|
445
|
+
configure your proxy for you.
|
|
446
|
+
|
|
447
|
+
Each call sends progress on its already-open HTTP response. A later call can
|
|
448
|
+
go to another server without pinning that client to the first server. This
|
|
449
|
+
does not share application state or rate limits: the configured limit applies
|
|
450
|
+
to each server separately.
|
|
451
|
+
|
|
452
|
+
### Limits
|
|
453
|
+
|
|
454
|
+
Progress remains limited by the event count, event size, result size, and
|
|
455
|
+
request deadline. Disconnecting cancels work that observes the cancellation
|
|
456
|
+
signal. These checks do not slow the producer to match a slow reader.
|
|
457
|
+
|
|
458
|
+
Not supported yet:
|
|
194
459
|
|
|
195
460
|
- long-running streams opened with GET
|
|
196
461
|
- saved sessions, replay, or subscriptions
|
|
197
|
-
-
|
|
462
|
+
- recovery after reconnecting
|
|
198
463
|
- slowing the producer when a client cannot keep up
|
|
199
|
-
-
|
|
464
|
+
- progress from tools requiring sign-in outside local development
|
|
465
|
+
|
|
466
|
+
See the [progress coverage and tested proxy setup](../../docs/protocol-coverage.md#progress-updates)
|
|
467
|
+
for the current checks and their limits.
|
|
200
468
|
|
|
201
469
|
## Public Resources and Prompts
|
|
202
470
|
|
|
@@ -226,6 +494,7 @@ const guide = defineResource({
|
|
|
226
494
|
name: "getting-started",
|
|
227
495
|
uri: guideUri,
|
|
228
496
|
mimeType: "text/markdown",
|
|
497
|
+
cacheHint: { ttlMs: 30_000 },
|
|
229
498
|
handler: () => ({ contents: [{ uri: guideUri, text: "# Brew safely" }] }),
|
|
230
499
|
});
|
|
231
500
|
|
|
@@ -257,9 +526,31 @@ const app = createEmseepea({
|
|
|
257
526
|
version: "1.0.0",
|
|
258
527
|
resources: [guide, methodGuide],
|
|
259
528
|
prompts: [brew],
|
|
529
|
+
cacheHints: {
|
|
530
|
+
"resources/list": { ttlMs: 60_000, cacheScope: "public" },
|
|
531
|
+
"resources/read": { ttlMs: 5_000, cacheScope: "private" },
|
|
532
|
+
},
|
|
260
533
|
});
|
|
261
534
|
```
|
|
262
535
|
|
|
536
|
+
## Tell Clients When They May Reuse Results
|
|
537
|
+
|
|
538
|
+
By default, clients are told not to reuse lists, discovery details, or resource
|
|
539
|
+
content. `cacheHints` sets a time limit in milliseconds and says whether a
|
|
540
|
+
shared cache may store the result.
|
|
541
|
+
|
|
542
|
+
Use `public` only when every caller receives the same non-sensitive result.
|
|
543
|
+
Use `private` for results that only the requesting client may keep. These
|
|
544
|
+
instructions do not grant access and do not make changing or personal data safe
|
|
545
|
+
to share.
|
|
546
|
+
|
|
547
|
+
An individual resource or reusable resource address can override either field.
|
|
548
|
+
In the example, `guide` overrides the time limit while its missing
|
|
549
|
+
`cacheScope` still comes from `cacheHints["resources/read"]`.
|
|
550
|
+
|
|
551
|
+
Cache instructions are supported for discovery, tool lists, resource lists,
|
|
552
|
+
reusable resource-address lists, prompt lists, and resource reads.
|
|
553
|
+
|
|
263
554
|
Resource templates advertise an address pattern rather than listing every
|
|
264
555
|
possible address. Clients list the patterns, then read an address that matches
|
|
265
556
|
one. Each `{variable}` fills one complete path segment. The scheme and host
|
|
@@ -272,8 +563,8 @@ Pea checks the returned suggestions and keeps at most the first 100.
|
|
|
272
563
|
Suggestions are public even when some tools require sign-in. Return only text
|
|
273
564
|
that is safe for anyone to discover.
|
|
274
565
|
|
|
275
|
-
Listing every matching address,
|
|
276
|
-
|
|
566
|
+
Listing every matching address, catalogue pages without the bounds above, and
|
|
567
|
+
sign-in for resources are not included yet.
|
|
277
568
|
|
|
278
569
|
## Tool That Requires Sign-In
|
|
279
570
|
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface JsonHttpClientOptions {
|
|
2
|
+
readonly origin: string | URL;
|
|
3
|
+
readonly maxResponseBytes?: number;
|
|
4
|
+
}
|
|
5
|
+
export interface JsonHttpGetOptions {
|
|
6
|
+
readonly pathname: string;
|
|
7
|
+
readonly searchParams?: Readonly<Record<string, string | readonly string[]>>;
|
|
8
|
+
readonly signal: AbortSignal;
|
|
9
|
+
readonly deadlineMs: number;
|
|
10
|
+
}
|
|
11
|
+
export interface JsonHttpClient {
|
|
12
|
+
readonly get: (options: JsonHttpGetOptions) => Promise<unknown>;
|
|
13
|
+
}
|
|
14
|
+
export declare function createJsonHttpClient(candidate: JsonHttpClientOptions): JsonHttpClient;
|
|
15
|
+
//# sourceMappingURL=http.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAiDA,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,CAAC;IAC9B,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;IAC7E,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,kBAAkB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACjE;AAED,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,qBAAqB,GAAG,cAAc,CAiCrF"}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { request } from "node:https";
|
|
3
|
+
import { BlockList, isIP } from "node:net";
|
|
4
|
+
import { TextDecoder } from "node:util";
|
|
5
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
6
|
+
const MAX_RESPONSE_HEADER_BYTES = 16 * 1024;
|
|
7
|
+
const MAX_REQUEST_TARGET_BYTES = 8 * 1024;
|
|
8
|
+
const MAX_DEADLINE_DELAY_MS = 2_147_483_647;
|
|
9
|
+
const FAILURE_MESSAGE = "Outbound JSON request failed";
|
|
10
|
+
const prohibitedIpv4Addresses = new BlockList();
|
|
11
|
+
for (const [network, prefix] of [
|
|
12
|
+
["0.0.0.0", 8],
|
|
13
|
+
["10.0.0.0", 8],
|
|
14
|
+
["100.64.0.0", 10],
|
|
15
|
+
["127.0.0.0", 8],
|
|
16
|
+
["169.254.0.0", 16],
|
|
17
|
+
["172.16.0.0", 12],
|
|
18
|
+
["192.0.0.0", 24],
|
|
19
|
+
["192.0.2.0", 24],
|
|
20
|
+
["192.88.99.0", 24],
|
|
21
|
+
["192.168.0.0", 16],
|
|
22
|
+
["198.18.0.0", 15],
|
|
23
|
+
["198.51.100.0", 24],
|
|
24
|
+
["203.0.113.0", 24],
|
|
25
|
+
["224.0.0.0", 4],
|
|
26
|
+
["240.0.0.0", 4],
|
|
27
|
+
])
|
|
28
|
+
prohibitedIpv4Addresses.addSubnet(network, prefix, "ipv4");
|
|
29
|
+
const prohibitedIpv6Addresses = new BlockList();
|
|
30
|
+
const globallyRoutableIpv6Addresses = new BlockList();
|
|
31
|
+
globallyRoutableIpv6Addresses.addSubnet("2000::", 3, "ipv6");
|
|
32
|
+
for (const [network, prefix] of [
|
|
33
|
+
["::", 128],
|
|
34
|
+
["::1", 128],
|
|
35
|
+
["::ffff:0:0", 96],
|
|
36
|
+
["64:ff9b::", 96],
|
|
37
|
+
["64:ff9b:1::", 48],
|
|
38
|
+
["100::", 64],
|
|
39
|
+
["2001::", 23],
|
|
40
|
+
["2001:db8::", 32],
|
|
41
|
+
["2002::", 16],
|
|
42
|
+
["3fff::", 20],
|
|
43
|
+
["fc00::", 7],
|
|
44
|
+
["fe80::", 10],
|
|
45
|
+
["ff00::", 8],
|
|
46
|
+
])
|
|
47
|
+
prohibitedIpv6Addresses.addSubnet(network, prefix, "ipv6");
|
|
48
|
+
export function createJsonHttpClient(candidate) {
|
|
49
|
+
assertKeys(candidate, ["origin", "maxResponseBytes"], "HTTP client options");
|
|
50
|
+
const origin = normalizeOrigin(candidate.origin);
|
|
51
|
+
const maxResponseBytes = boundedPositiveInteger(candidate.maxResponseBytes ?? MAX_RESPONSE_BYTES, "maxResponseBytes", MAX_RESPONSE_BYTES);
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
async get(options) {
|
|
54
|
+
try {
|
|
55
|
+
assertKeys(options, ["pathname", "searchParams", "signal", "deadlineMs"], "HTTP GET options");
|
|
56
|
+
const url = requestUrl(origin, options.pathname, options.searchParams);
|
|
57
|
+
const operation = operationSignal(options.signal, options.deadlineMs);
|
|
58
|
+
try {
|
|
59
|
+
operation.signal.throwIfAborted();
|
|
60
|
+
const addresses = await waitFor(lookup(url.hostname, { all: true, order: "verbatim" }), operation.signal);
|
|
61
|
+
operation.signal.throwIfAborted();
|
|
62
|
+
if (addresses.length === 0 || addresses.some(({ address, family }) => !isAllowedAddress(address, family))) {
|
|
63
|
+
throw new Error(FAILURE_MESSAGE);
|
|
64
|
+
}
|
|
65
|
+
const selected = addresses[0];
|
|
66
|
+
if (!selected)
|
|
67
|
+
throw new Error(FAILURE_MESSAGE);
|
|
68
|
+
return await waitFor(readJson(url, selected, maxResponseBytes, operation.signal), operation.signal);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
operation.dispose();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new Error(FAILURE_MESSAGE);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function normalizeOrigin(candidate) {
|
|
81
|
+
let url;
|
|
82
|
+
try {
|
|
83
|
+
url = new URL(candidate instanceof URL ? candidate.href : candidate);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new TypeError("origin must be a valid URL");
|
|
87
|
+
}
|
|
88
|
+
const hostname = url.hostname.startsWith("[") && url.hostname.endsWith("]")
|
|
89
|
+
? url.hostname.slice(1, -1)
|
|
90
|
+
: url.hostname;
|
|
91
|
+
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" ||
|
|
92
|
+
url.search || url.hash || isIP(hostname) !== 0) {
|
|
93
|
+
throw new TypeError("origin must be an HTTPS origin without credentials, path, query, fragment, or IP literal");
|
|
94
|
+
}
|
|
95
|
+
return new URL(url.origin);
|
|
96
|
+
}
|
|
97
|
+
function requestUrl(origin, pathname, searchParams) {
|
|
98
|
+
if (typeof pathname !== "string" || !pathname.startsWith("/") || pathname.startsWith("//") ||
|
|
99
|
+
pathname.includes("\\") || pathname.includes("?") || pathname.includes("#")) {
|
|
100
|
+
throw new TypeError("pathname must be one root-relative URL path");
|
|
101
|
+
}
|
|
102
|
+
const url = new URL(pathname, origin);
|
|
103
|
+
if (url.origin !== origin.origin || url.protocol !== "https:" || url.username || url.password || url.hash) {
|
|
104
|
+
throw new TypeError("pathname escaped the configured origin");
|
|
105
|
+
}
|
|
106
|
+
if (searchParams !== undefined) {
|
|
107
|
+
assertPlainRecord(searchParams, "searchParams");
|
|
108
|
+
for (const [name, value] of Object.entries(searchParams).sort(([left], [right]) => left.localeCompare(right))) {
|
|
109
|
+
if (typeof value === "string") {
|
|
110
|
+
url.searchParams.append(name, value);
|
|
111
|
+
}
|
|
112
|
+
else if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
|
|
113
|
+
for (const entry of value)
|
|
114
|
+
url.searchParams.append(name, entry);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
throw new TypeError("searchParams values must be strings or string arrays");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (Buffer.byteLength(`${url.pathname}${url.search}`, "utf8") > MAX_REQUEST_TARGET_BYTES) {
|
|
122
|
+
throw new TypeError("request target exceeds the configured size limit");
|
|
123
|
+
}
|
|
124
|
+
return url;
|
|
125
|
+
}
|
|
126
|
+
function isAllowedAddress(address, family) {
|
|
127
|
+
if (isIP(address) !== family || (family !== 4 && family !== 6))
|
|
128
|
+
return false;
|
|
129
|
+
return family === 4
|
|
130
|
+
? !prohibitedIpv4Addresses.check(address, "ipv4")
|
|
131
|
+
: globallyRoutableIpv6Addresses.check(address, "ipv6")
|
|
132
|
+
&& !prohibitedIpv6Addresses.check(address, "ipv6");
|
|
133
|
+
}
|
|
134
|
+
function operationSignal(parent, deadlineMs) {
|
|
135
|
+
if (!(parent instanceof AbortSignal) || !Number.isSafeInteger(deadlineMs) ||
|
|
136
|
+
deadlineMs - Date.now() > MAX_DEADLINE_DELAY_MS) {
|
|
137
|
+
throw new TypeError("signal and a safe-integer absolute deadlineMs are required");
|
|
138
|
+
}
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const abort = () => controller.abort();
|
|
141
|
+
if (parent.aborted || deadlineMs <= Date.now())
|
|
142
|
+
controller.abort();
|
|
143
|
+
else
|
|
144
|
+
parent.addEventListener("abort", abort, { once: true });
|
|
145
|
+
const timer = setTimeout(abort, Math.max(0, deadlineMs - Date.now()));
|
|
146
|
+
timer.unref();
|
|
147
|
+
return {
|
|
148
|
+
signal: controller.signal,
|
|
149
|
+
dispose() {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
parent.removeEventListener("abort", abort);
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
async function waitFor(promise, signal) {
|
|
156
|
+
if (signal.aborted)
|
|
157
|
+
throw new Error(FAILURE_MESSAGE);
|
|
158
|
+
return await new Promise((resolve, reject) => {
|
|
159
|
+
let settled = false;
|
|
160
|
+
const finish = (callback) => {
|
|
161
|
+
if (settled)
|
|
162
|
+
return;
|
|
163
|
+
settled = true;
|
|
164
|
+
signal.removeEventListener("abort", abort);
|
|
165
|
+
callback();
|
|
166
|
+
};
|
|
167
|
+
const abort = () => finish(() => reject(new Error(FAILURE_MESSAGE)));
|
|
168
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
169
|
+
void promise.then((value) => finish(() => resolve(value)), () => finish(() => reject(new Error(FAILURE_MESSAGE))));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function readJson(url, selected, maxResponseBytes, signal) {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
let settled = false;
|
|
175
|
+
const fail = () => {
|
|
176
|
+
if (settled)
|
|
177
|
+
return;
|
|
178
|
+
settled = true;
|
|
179
|
+
reject(new Error(FAILURE_MESSAGE));
|
|
180
|
+
};
|
|
181
|
+
const requestOptions = {
|
|
182
|
+
agent: false,
|
|
183
|
+
autoSelectFamily: false,
|
|
184
|
+
headers: {
|
|
185
|
+
Accept: "application/json",
|
|
186
|
+
"Accept-Encoding": "identity",
|
|
187
|
+
Host: url.host,
|
|
188
|
+
},
|
|
189
|
+
family: selected.family,
|
|
190
|
+
lookup: (_hostname, _options, callback) => callback(null, selected.address, selected.family),
|
|
191
|
+
maxHeaderSize: MAX_RESPONSE_HEADER_BYTES,
|
|
192
|
+
method: "GET",
|
|
193
|
+
servername: url.hostname,
|
|
194
|
+
signal,
|
|
195
|
+
};
|
|
196
|
+
const outgoing = request(url, requestOptions, (incoming) => {
|
|
197
|
+
if (signal.aborted || settled) {
|
|
198
|
+
incoming.destroy();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const status = incoming.statusCode ?? 0;
|
|
202
|
+
const contentType = oneHeader(incoming.headersDistinct?.["content-type"]);
|
|
203
|
+
const contentEncoding = oneHeader(incoming.headersDistinct?.["content-encoding"]);
|
|
204
|
+
const contentLength = oneHeader(incoming.headersDistinct?.["content-length"]);
|
|
205
|
+
if (status < 200 || status > 299 || !isJsonContentType(contentType ?? undefined) ||
|
|
206
|
+
contentEncoding === null ||
|
|
207
|
+
(contentEncoding !== undefined && contentEncoding.toLowerCase() !== "identity") ||
|
|
208
|
+
contentLength === null ||
|
|
209
|
+
(contentLength !== undefined && !validContentLength(contentLength, maxResponseBytes))) {
|
|
210
|
+
incoming.destroy();
|
|
211
|
+
fail();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const chunks = [];
|
|
215
|
+
let bytes = 0;
|
|
216
|
+
incoming.on("data", (chunk) => {
|
|
217
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
218
|
+
bytes += buffer.length;
|
|
219
|
+
if (bytes > maxResponseBytes) {
|
|
220
|
+
incoming.destroy();
|
|
221
|
+
fail();
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
chunks.push(buffer);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
incoming.once("aborted", fail);
|
|
228
|
+
incoming.once("error", fail);
|
|
229
|
+
incoming.once("close", () => {
|
|
230
|
+
if (!incoming.complete)
|
|
231
|
+
fail();
|
|
232
|
+
});
|
|
233
|
+
incoming.once("end", () => {
|
|
234
|
+
if (settled)
|
|
235
|
+
return;
|
|
236
|
+
try {
|
|
237
|
+
signal.throwIfAborted();
|
|
238
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
|
239
|
+
const value = JSON.parse(text);
|
|
240
|
+
signal.throwIfAborted();
|
|
241
|
+
settled = true;
|
|
242
|
+
resolve(value);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
fail();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
outgoing.once("error", fail);
|
|
250
|
+
outgoing.end();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
function oneHeader(values) {
|
|
254
|
+
if (values === undefined || values.length === 0)
|
|
255
|
+
return undefined;
|
|
256
|
+
return values.length === 1 ? values[0] ?? null : null;
|
|
257
|
+
}
|
|
258
|
+
function isJsonContentType(value) {
|
|
259
|
+
return value !== undefined && /^application\/(?:json|[a-z0-9!#$&^_.+-]+\+json)(?:\s*;.*)?$/i.test(value);
|
|
260
|
+
}
|
|
261
|
+
function validContentLength(value, maximum) {
|
|
262
|
+
const length = Number(value);
|
|
263
|
+
return /^(?:0|[1-9]\d*)$/.test(value) && Number.isSafeInteger(length) && length <= maximum;
|
|
264
|
+
}
|
|
265
|
+
function assertKeys(value, allowed, label) {
|
|
266
|
+
assertPlainRecord(value, label);
|
|
267
|
+
const accepted = new Set(allowed);
|
|
268
|
+
if (Reflect.ownKeys(value).some((key) => typeof key !== "string" || !accepted.has(key))) {
|
|
269
|
+
throw new TypeError(`${label} contains an unsupported field`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function assertPlainRecord(value, label) {
|
|
273
|
+
if (typeof value !== "object" || value === null || Array.isArray(value) ||
|
|
274
|
+
(Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) {
|
|
275
|
+
throw new TypeError(`${label} must be a plain object`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function boundedPositiveInteger(value, label, maximum) {
|
|
279
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) {
|
|
280
|
+
throw new TypeError(`${label} must be a positive integer no greater than ${maximum}`);
|
|
281
|
+
}
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
//# sourceMappingURL=http.js.map
|