slotlock 0.6.2 → 0.7.0
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/CHANGELOG.md +492 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1544
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +560 -49
- package/agent-store-backend.d.ts +39 -7
- package/agent-store-backend.js +1439 -104
- package/api-keys.d.ts +69 -3
- package/api-keys.js +218 -29
- package/application-api.d.ts +59 -6
- package/application-api.js +842 -39
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +58 -27
- package/assistant.js +151 -53
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +42 -6
- package/config.js +114 -7
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1558 -64
- package/ddl.d.ts +67 -35
- package/ddl.js +2732 -162
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +53 -35
- package/event-feed.js +201 -64
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/interop.js +1 -4
- package/maintenance.d.ts +24 -10
- package/maintenance.js +59 -28
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +27 -2
- package/rate-limit.js +106 -23
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +15 -14
- package/rest-api.js +79 -15
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +46 -4
- package/self-host.js +990 -41
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +482 -165
- package/store.js +1460 -484
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.d.ts +25 -33
- package/timezone.js +50 -53
- package/types.d.ts +130 -57
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/mcp-modern.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
/*
|
|
3
|
-
* MCP 2026-07-28 wire mechanics
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* agent-server.ts, the orchestration owner.
|
|
3
|
+
* MCP 2026-07-28 wire mechanics: the per-request `_meta` envelope, Streamable HTTP headers, sealed
|
|
4
|
+
* `requestState`, SSE framing and W3C trace context. Authorization, dispatch and results stay in
|
|
5
|
+
* agent-server.ts.
|
|
7
6
|
*/
|
|
8
7
|
export const MCP_META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion';
|
|
9
8
|
const MCP_META_CLIENT_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities';
|
|
@@ -23,16 +22,15 @@ const MCP_NAMED_METHODS = new Map([
|
|
|
23
22
|
export function isRecord(value) {
|
|
24
23
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
25
24
|
}
|
|
26
|
-
// RFC 9110 field value: visible ASCII, space and
|
|
27
|
-
//
|
|
25
|
+
// RFC 9110 field value: visible ASCII, space and tab; anything else must use the `=?base64?…?=`
|
|
26
|
+
// sentinel (Streamable HTTP §Value Encoding).
|
|
28
27
|
const PLAIN_HEADER_VALUE = /^[\x20-\x7E\t]*$/;
|
|
29
28
|
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
30
29
|
const BASE64_SENTINEL_PREFIX = '=?base64?';
|
|
31
30
|
const BASE64_SENTINEL_SUFFIX = '?=';
|
|
32
31
|
/**
|
|
33
|
-
* The value an `Mcp-Name`
|
|
34
|
-
*
|
|
35
|
-
* header-safe set.
|
|
32
|
+
* The value an `Mcp-Name` or `Mcp-Param-*` header stands for, Base64-sentinel decoded when used;
|
|
33
|
+
* `null` when malformed.
|
|
36
34
|
*/
|
|
37
35
|
function decodeMcpHeaderValue(value) {
|
|
38
36
|
if (value.length >= BASE64_SENTINEL_PREFIX.length + BASE64_SENTINEL_SUFFIX.length &&
|
|
@@ -51,11 +49,10 @@ function decodeMcpHeaderValue(value) {
|
|
|
51
49
|
return PLAIN_HEADER_VALUE.test(value) ? value : null;
|
|
52
50
|
}
|
|
53
51
|
/**
|
|
54
|
-
* Validate a 2026-07-28 request's `_meta` envelope and the
|
|
55
|
-
* specification's order:
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* streamable-http §Server Validation). Every refusal is HTTP 400.
|
|
52
|
+
* Validate a 2026-07-28 request's `_meta` envelope and the headers mirroring its body, in the
|
|
53
|
+
* specification's order: required `_meta` fields (-32602), `MCP-Protocol-Version` against the body
|
|
54
|
+
* (-32020), a supported revision (-32022), then `Mcp-Method` and `Mcp-Name` against the body
|
|
55
|
+
* (-32020). Every refusal is HTTP 400.
|
|
59
56
|
*/
|
|
60
57
|
export function checkModernMcpRequest(args) {
|
|
61
58
|
const invalid = (message) => ({ ok: false, status: 400, code: -32602, message });
|
|
@@ -139,8 +136,8 @@ export function stateSecrets(secrets) {
|
|
|
139
136
|
});
|
|
140
137
|
}
|
|
141
138
|
/**
|
|
142
|
-
* Seal a JSON payload for a client to
|
|
143
|
-
*
|
|
139
|
+
* Seal a JSON payload for a client to echo: `v1.<payload>.<HMAC-SHA256>`, base64url.
|
|
140
|
+
* Integrity-protected, not encrypted: never put anything the client may not see in it.
|
|
144
141
|
*/
|
|
145
142
|
export function sealRequestState(key, payload) {
|
|
146
143
|
const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
@@ -148,8 +145,8 @@ export function sealRequestState(key, payload) {
|
|
|
148
145
|
return `${STATE_VERSION}.${body}.${mac}`;
|
|
149
146
|
}
|
|
150
147
|
/**
|
|
151
|
-
* The payload of a state this server sealed with any of `keys`, and the key
|
|
152
|
-
*
|
|
148
|
+
* The payload of a state this server sealed with any of `keys`, and the verifying key; `null`
|
|
149
|
+
* otherwise. The MAC is checked in constant time before parsing.
|
|
153
150
|
*/
|
|
154
151
|
export function openRequestState(keys, state) {
|
|
155
152
|
if (typeof state !== 'string' || state.length > MAX_REQUEST_STATE_LENGTH)
|
|
@@ -163,8 +160,8 @@ export function openRequestState(keys, state) {
|
|
|
163
160
|
!BASE64URL.test(mac)) {
|
|
164
161
|
return null;
|
|
165
162
|
}
|
|
166
|
-
// Compared as text: decoding drops the
|
|
167
|
-
//
|
|
163
|
+
// Compared as text: decoding drops the last character's spare bits, so other spellings of the
|
|
164
|
+
// same MAC would pass.
|
|
168
165
|
const given = Buffer.from(mac);
|
|
169
166
|
for (const key of keys) {
|
|
170
167
|
const expected = Buffer.from(createHmac('sha256', key).update(`${STATE_MAC_CONTEXT}.${body}`).digest('base64url'));
|
|
@@ -196,9 +193,9 @@ const SSE_KEEP_ALIVE = encoder.encode(':\n\n');
|
|
|
196
193
|
/** Messages a reader may leave unread before the stream counts it as gone. */
|
|
197
194
|
const MAX_UNREAD_EVENTS = 256;
|
|
198
195
|
/**
|
|
199
|
-
* An SSE response (streamable-http §Receiving Messages):
|
|
200
|
-
*
|
|
201
|
-
*
|
|
196
|
+
* An SSE response (streamable-http §Receiving Messages): uncached and unbuffered by proxies
|
|
197
|
+
* (`X-Accel-Buffering: no`). `onCancel` runs once if the reader leaves first, which is how an HTTP
|
|
198
|
+
* client cancels.
|
|
202
199
|
*/
|
|
203
200
|
export function openMcpEventStream(onCancel) {
|
|
204
201
|
let open = true;
|
|
@@ -266,10 +263,9 @@ function validTraceparent(value) {
|
|
|
266
263
|
parentId !== '0'.repeat(16));
|
|
267
264
|
}
|
|
268
265
|
/**
|
|
269
|
-
* Trace context from
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* dropped on its own (W3C Trace Context §3.3, Baggage §3.3).
|
|
266
|
+
* Trace context from `_meta` (MCP reserves `traceparent`, `tracestate` and `baggage` there) or, if
|
|
267
|
+
* absent, the HTTP headers; never a mix. An invalid `traceparent` discards it all; an invalid
|
|
268
|
+
* `tracestate` or `baggage` is dropped alone (W3C Trace Context §3.3, Baggage §3.3).
|
|
273
269
|
*/
|
|
274
270
|
export function traceContextFrom(meta, headers) {
|
|
275
271
|
const fromMeta = typeof meta?.traceparent === 'string';
|
package/metrics.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { SlotlockAgentServerEvent } from './agent-server.js';
|
|
|
2
2
|
import type { SlotlockMaintenanceResult } from './maintenance.js';
|
|
3
3
|
import type { SlotlockRefusalCounts } from './rate-limit.js';
|
|
4
4
|
import type { SlotlockWebhookPassResult } from './webhooks.js';
|
|
5
|
+
/** A metric's labels, by name. */
|
|
6
|
+
export type Labels = Readonly<Record<string, string>>;
|
|
5
7
|
export interface SlotlockMetrics {
|
|
6
8
|
/** Count what the agent server reports (its `onEvent`). */
|
|
7
9
|
event(event: SlotlockAgentServerEvent): void;
|
|
@@ -13,8 +15,47 @@ export interface SlotlockMetrics {
|
|
|
13
15
|
webhooks(result: SlotlockWebhookPassResult): void;
|
|
14
16
|
/** Count a scrape refused for its credential. */
|
|
15
17
|
refusedScrape(): void;
|
|
18
|
+
/**
|
|
19
|
+
* Count an authenticated request by the integrator its `Slotlock-Client` field names (RFC 0003
|
|
20
|
+
* P5): a name from the deployment's known list, `other` or `none` (slotlockClientLabel).
|
|
21
|
+
*/
|
|
22
|
+
client(name: string): void;
|
|
16
23
|
/** Everything counted so far, as a scrape answers it. */
|
|
17
24
|
render(): string;
|
|
25
|
+
/** Everything counted so far, as data: for an OpenTelemetry metrics export (otel.ts). */
|
|
26
|
+
collect(): SlotlockMetricsSnapshot;
|
|
27
|
+
}
|
|
28
|
+
/** The counters and histograms of one server since it started (cumulative). */
|
|
29
|
+
export interface SlotlockMetricsSnapshot {
|
|
30
|
+
startedAtMs: number;
|
|
31
|
+
counters: {
|
|
32
|
+
name: string;
|
|
33
|
+
help: string;
|
|
34
|
+
points: {
|
|
35
|
+
labels: Labels;
|
|
36
|
+
value: number;
|
|
37
|
+
}[];
|
|
38
|
+
}[];
|
|
39
|
+
histograms: {
|
|
40
|
+
name: string;
|
|
41
|
+
help: string;
|
|
42
|
+
/** Upper bounds, in seconds; each point's buckets count what fell at or under each one. */
|
|
43
|
+
bounds: readonly number[];
|
|
44
|
+
points: {
|
|
45
|
+
labels: Labels;
|
|
46
|
+
cumulative: number[];
|
|
47
|
+
sum: number;
|
|
48
|
+
count: number;
|
|
49
|
+
}[];
|
|
50
|
+
}[];
|
|
51
|
+
gauges: {
|
|
52
|
+
name: string;
|
|
53
|
+
help: string;
|
|
54
|
+
points: {
|
|
55
|
+
labels: Labels;
|
|
56
|
+
value: number;
|
|
57
|
+
}[];
|
|
58
|
+
}[];
|
|
18
59
|
}
|
|
19
60
|
/** Counters for one server process, reset when it restarts (`slotlock_start_time_seconds`). */
|
|
20
61
|
export declare function createSlotlockMetrics(info: {
|
package/metrics.js
CHANGED
|
@@ -7,6 +7,7 @@ const REFUSAL_REASONS = [
|
|
|
7
7
|
'series_writes_busy',
|
|
8
8
|
'maintenance_busy',
|
|
9
9
|
'request_too_large',
|
|
10
|
+
'quota_exceeded',
|
|
10
11
|
];
|
|
11
12
|
const MAINTENANCE_COUNTS = [
|
|
12
13
|
'extended',
|
|
@@ -17,6 +18,7 @@ const MAINTENANCE_COUNTS = [
|
|
|
17
18
|
'staleSources',
|
|
18
19
|
'noShows',
|
|
19
20
|
'waitlistOffers',
|
|
21
|
+
'approvalsExpired',
|
|
20
22
|
];
|
|
21
23
|
/** A label value: an operation, protocol or outcome code. Anything else is counted as `other`. */
|
|
22
24
|
const LABEL = /^[a-z0-9_.]{1,64}$/;
|
|
@@ -26,7 +28,6 @@ function formatLabels(labels) {
|
|
|
26
28
|
const entries = Object.entries(labels);
|
|
27
29
|
if (entries.length === 0)
|
|
28
30
|
return '';
|
|
29
|
-
// Label values are from LABEL's set or the version strings below: escaped anyway.
|
|
30
31
|
const escaped = entries.map(([name, value]) => `${name}="${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n')}"`);
|
|
31
32
|
return `{${escaped.join(',')}}`;
|
|
32
33
|
}
|
|
@@ -44,6 +45,9 @@ class Counter {
|
|
|
44
45
|
current.value += amount;
|
|
45
46
|
this.values.set(id, current);
|
|
46
47
|
}
|
|
48
|
+
points() {
|
|
49
|
+
return [...this.values.values()].map(({ labels, value }) => ({ labels, value }));
|
|
50
|
+
}
|
|
47
51
|
render(type = 'counter') {
|
|
48
52
|
const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} ${type}`];
|
|
49
53
|
for (const { labels, value } of this.values.values()) {
|
|
@@ -76,6 +80,9 @@ class Histogram {
|
|
|
76
80
|
current.count += 1;
|
|
77
81
|
this.series.set(id, current);
|
|
78
82
|
}
|
|
83
|
+
points() {
|
|
84
|
+
return [...this.series.values()].map(({ labels, buckets, sum, count }) => ({ labels, cumulative: [...buckets], sum, count }));
|
|
85
|
+
}
|
|
79
86
|
render() {
|
|
80
87
|
const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} histogram`];
|
|
81
88
|
for (const { labels, buckets, sum, count } of this.series.values()) {
|
|
@@ -100,6 +107,7 @@ export function createSlotlockMetrics(info) {
|
|
|
100
107
|
const refused = new Counter('slotlock_refusals_total', 'Requests refused before they ran, by reason.');
|
|
101
108
|
const passes = new Counter('slotlock_maintenance_passes_total', 'Maintenance passes, by whether one was capped.');
|
|
102
109
|
const upkeep = new Counter('slotlock_maintenance_items_total', 'What maintenance did, by kind: series extended, holds pruned, no-shows released, waitlist offers made, and so on.');
|
|
110
|
+
const clients = new Counter('slotlock_requests_by_client_total', 'Authenticated requests by the integrator their Slotlock-Client field names (known ones, other or none).');
|
|
103
111
|
const deliveries = new Counter('slotlock_webhook_deliveries_total', 'Webhook changes delivered, and deliveries that failed and will be retried.');
|
|
104
112
|
return {
|
|
105
113
|
event(event) {
|
|
@@ -135,6 +143,9 @@ export function createSlotlockMetrics(info) {
|
|
|
135
143
|
refusedScrape() {
|
|
136
144
|
refused.add({ reason: 'metrics_auth_refused' });
|
|
137
145
|
},
|
|
146
|
+
client(name) {
|
|
147
|
+
clients.add({ client: label(name) });
|
|
148
|
+
},
|
|
138
149
|
webhooks(result) {
|
|
139
150
|
if (result.delivered > 0)
|
|
140
151
|
deliveries.add({ result: 'delivered' }, result.delivered);
|
|
@@ -160,8 +171,18 @@ export function createSlotlockMetrics(info) {
|
|
|
160
171
|
...passes.render(),
|
|
161
172
|
...upkeep.render(),
|
|
162
173
|
...deliveries.render(),
|
|
174
|
+
...clients.render(),
|
|
163
175
|
];
|
|
164
176
|
return `${lines.join('\n')}\n`;
|
|
165
177
|
},
|
|
178
|
+
collect() {
|
|
179
|
+
const counter = (metric) => ({ name: metric.name, help: metric.help, points: metric.points() });
|
|
180
|
+
return {
|
|
181
|
+
startedAtMs: started * 1000,
|
|
182
|
+
counters: [calls, confirmations, subscriptions, refused, passes, upkeep, deliveries, clients].map(counter),
|
|
183
|
+
histograms: [{ name: duration.name, help: duration.help, bounds: BUCKETS, points: duration.points() }],
|
|
184
|
+
gauges: [{ name: 'slotlock_subscriptions_open', help: 'MCP subscriptions open now.', points: [{ labels: {}, value: open }] }],
|
|
185
|
+
};
|
|
186
|
+
},
|
|
166
187
|
};
|
|
167
188
|
}
|
package/node-server.d.ts
CHANGED
|
@@ -4,8 +4,8 @@ export type SlotlockNodeServerErrorPhase = 'client' | 'listen' | 'request' | 'se
|
|
|
4
4
|
/** What the listener knows about a request's connection, beside the Request itself. */
|
|
5
5
|
export interface SlotlockNodeServerConnection {
|
|
6
6
|
/**
|
|
7
|
-
* The peer's address as the socket reports it (
|
|
8
|
-
*
|
|
7
|
+
* The peer's address as the socket reports it (`::ffff:a.b.c.d` for IPv4 on a dual-stack
|
|
8
|
+
* listener). Behind a proxy it is the proxy's; never taken from a request field.
|
|
9
9
|
*/
|
|
10
10
|
remoteAddress?: string;
|
|
11
11
|
}
|
|
@@ -13,15 +13,15 @@ export interface SlotlockNodeServerConnection {
|
|
|
13
13
|
export interface SlotlockNodeServerFetchTarget {
|
|
14
14
|
fetch(request: Request, connection?: SlotlockNodeServerConnection): Promise<Response>;
|
|
15
15
|
/**
|
|
16
|
-
* Called once when `close()` begins, after the listener stops accepting
|
|
17
|
-
*
|
|
16
|
+
* Called once when `close()` begins, after the listener stops accepting, to end long-lived
|
|
17
|
+
* responses (MCP subscription streams) within the grace period.
|
|
18
18
|
*/
|
|
19
19
|
shutdown?(): Promise<void> | void;
|
|
20
20
|
}
|
|
21
21
|
export interface SlotlockNodeServerOptions {
|
|
22
22
|
/**
|
|
23
|
-
* Canonical origin
|
|
24
|
-
*
|
|
23
|
+
* Canonical origin for the Request passed to authentication and policy callbacks, explicit so an
|
|
24
|
+
* untrusted Host or forwarding header can never choose security context.
|
|
25
25
|
*/
|
|
26
26
|
requestOrigin: string;
|
|
27
27
|
handlerTimeoutMs?: number;
|
|
@@ -57,8 +57,8 @@ export interface SlotlockNodeServer {
|
|
|
57
57
|
}): Promise<SlotlockNodeServerCloseResult>;
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
|
-
* Adapt the Fetch boundary
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* Adapt the Fetch boundary from `createSlotlockAgentServer` to a hardened Node HTTP listener.
|
|
61
|
+
* HTTP-only by design: TLS termination and proxy trust are deployment concerns, and Request
|
|
62
|
+
* identity always uses the explicit `requestOrigin`.
|
|
63
63
|
*/
|
|
64
64
|
export declare function createSlotlockNodeServer(target: SlotlockNodeServerFetchTarget, options: SlotlockNodeServerOptions): SlotlockNodeServer;
|
package/node-server.js
CHANGED
|
@@ -75,16 +75,13 @@ function appendIncomingHeaders(request) {
|
|
|
75
75
|
return headers;
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
78
|
-
* The request body as a web stream
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* written the rest is discarded as it arrives, as Node discards a body no handler reads, within
|
|
83
|
-
* `requestTimeout`.
|
|
78
|
+
* The request body as a cancellable web stream. Cancelling a `Readable.toWeb` stream destroys the
|
|
79
|
+
* IncomingMessage (Node emits 'aborted') and leaves the unread rest on the connection, holding
|
|
80
|
+
* `close()` until forced. Cancelling this one only stops delivery; once the response is written the
|
|
81
|
+
* rest is discarded as it arrives, within `requestTimeout`.
|
|
84
82
|
*/
|
|
85
83
|
function requestBody(request) {
|
|
86
84
|
let controller;
|
|
87
|
-
// Once the stream is closed, errored or cancelled, what arrives is dropped.
|
|
88
85
|
let settled = false;
|
|
89
86
|
const stream = new ReadableStream({
|
|
90
87
|
start(started) {
|
|
@@ -112,7 +109,7 @@ function requestBody(request) {
|
|
|
112
109
|
request.resume();
|
|
113
110
|
},
|
|
114
111
|
cancel() {
|
|
115
|
-
// Held until the response is written, so the client is not read from
|
|
112
|
+
// Held until the response is written, so the client is not read from meanwhile.
|
|
116
113
|
settled = true;
|
|
117
114
|
request.pause();
|
|
118
115
|
},
|
|
@@ -120,8 +117,7 @@ function requestBody(request) {
|
|
|
120
117
|
return {
|
|
121
118
|
stream,
|
|
122
119
|
discard() {
|
|
123
|
-
// A target still reading
|
|
124
|
-
// never settles.
|
|
120
|
+
// A target still reading an answered body gets an error, not a read that never settles.
|
|
125
121
|
if (!settled) {
|
|
126
122
|
settled = true;
|
|
127
123
|
controller?.error(abortReason('request_body_discarded'));
|
|
@@ -135,9 +131,9 @@ function toWebRequest(request, requestOrigin, signal) {
|
|
|
135
131
|
if (!target.startsWith('/') || target.startsWith('//')) {
|
|
136
132
|
throw new Error('invalid_request_target');
|
|
137
133
|
}
|
|
138
|
-
// URL parsing resolves dot segments, `%2E` spellings included
|
|
139
|
-
// would
|
|
140
|
-
//
|
|
134
|
+
// URL parsing resolves dot segments, `%2E` spellings included
|
|
135
|
+
// (`/v1/resources/%2E%2E/availability` would become `/v1/availability`), and treats a backslash
|
|
136
|
+
// as a slash.
|
|
141
137
|
const path = target.split(/[?#]/, 1)[0] ?? '';
|
|
142
138
|
if (path.split(/[\\/]/).some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment))) {
|
|
143
139
|
throw new Error('invalid_request_target');
|
|
@@ -145,9 +141,8 @@ function toWebRequest(request, requestOrigin, signal) {
|
|
|
145
141
|
const url = new URL(target, requestOrigin);
|
|
146
142
|
if (url.origin !== requestOrigin)
|
|
147
143
|
throw new Error('invalid_request_target');
|
|
148
|
-
// Node's fetch
|
|
149
|
-
//
|
|
150
|
-
// Keep the cast at this adapter boundary; real-socket tests exercise the actual undici contract.
|
|
144
|
+
// Node's fetch needs `duplex: 'half'` for streamed bodies, which the DOM RequestInit does not
|
|
145
|
+
// declare; the cast stays at this boundary and real-socket tests cover undici's contract.
|
|
151
146
|
const init = {
|
|
152
147
|
method: request.method ?? 'GET',
|
|
153
148
|
headers: appendIncomingHeaders(request),
|
|
@@ -163,7 +158,7 @@ function toWebRequest(request, requestOrigin, signal) {
|
|
|
163
158
|
return { request: new Request(url, init), ...(body ? { body } : {}) };
|
|
164
159
|
}
|
|
165
160
|
catch (error) {
|
|
166
|
-
// A method the Fetch API forbids
|
|
161
|
+
// A method the Fetch API forbids: nothing will read the body, so drop it.
|
|
167
162
|
body?.discard();
|
|
168
163
|
throw error;
|
|
169
164
|
}
|
|
@@ -212,9 +207,9 @@ function formatListenerOrigin(host, port) {
|
|
|
212
207
|
return `http://${host.includes(':') ? `[${host}]` : host}:${port}`;
|
|
213
208
|
}
|
|
214
209
|
/**
|
|
215
|
-
* Adapt the Fetch boundary
|
|
216
|
-
*
|
|
217
|
-
*
|
|
210
|
+
* Adapt the Fetch boundary from `createSlotlockAgentServer` to a hardened Node HTTP listener.
|
|
211
|
+
* HTTP-only by design: TLS termination and proxy trust are deployment concerns, and Request
|
|
212
|
+
* identity always uses the explicit `requestOrigin`.
|
|
218
213
|
*/
|
|
219
214
|
export function createSlotlockNodeServer(target, options) {
|
|
220
215
|
const requestTimeoutMs = boundedInteger('requestTimeoutMs', options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, 1, MAX_TIMEOUT_MS);
|
|
@@ -231,8 +226,8 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
231
226
|
config.onError = options.onError;
|
|
232
227
|
const activeRequests = new Set();
|
|
233
228
|
let state = 'new';
|
|
234
|
-
//
|
|
235
|
-
//
|
|
229
|
+
// A function, so TypeScript does not keep the narrowing across awaits: `close()` can change the
|
|
230
|
+
// state meanwhile.
|
|
236
231
|
const closing = () => state === 'closing';
|
|
237
232
|
let removeLifecycleAbort;
|
|
238
233
|
/** Rejects a listen() still starting with `listen_aborted`; set while one is. */
|
|
@@ -241,8 +236,8 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
241
236
|
const server = createServer({
|
|
242
237
|
headersTimeout: headersTimeoutMs,
|
|
243
238
|
requestTimeout: requestTimeoutMs,
|
|
244
|
-
// Node enforces both only when it checks
|
|
245
|
-
//
|
|
239
|
+
// Node enforces both only when it checks connections (every 30 s by default), so a trickled
|
|
240
|
+
// body could outlast requestTimeout by that much.
|
|
246
241
|
connectionsCheckingInterval: Math.min(DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS, headersTimeoutMs, requestTimeoutMs),
|
|
247
242
|
keepAliveTimeout: boundedInteger('keepAliveTimeoutMs', options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS, 1, MAX_TIMEOUT_MS),
|
|
248
243
|
maxHeaderSize: boundedInteger('maxHeaderBytes', options.maxHeaderBytes ?? DEFAULT_MAX_HEADER_BYTES, 1_024, MAX_HEADER_BYTES),
|
|
@@ -307,11 +302,9 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
307
302
|
if (timeout)
|
|
308
303
|
clearTimeout(timeout);
|
|
309
304
|
activeRequests.delete(controller);
|
|
310
|
-
// The answer is written: what is left of
|
|
311
|
-
// too large, say) is discarded as it arrives rather than held.
|
|
305
|
+
// The answer is written: discard what is left of an unread body as it arrives.
|
|
312
306
|
if (!request.complete) {
|
|
313
307
|
body?.discard();
|
|
314
|
-
// The connection is idle only once that rest has arrived: close it then if shutting down.
|
|
315
308
|
request.once('end', () => {
|
|
316
309
|
setImmediate(() => {
|
|
317
310
|
if (closing())
|
|
@@ -319,8 +312,8 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
319
312
|
});
|
|
320
313
|
});
|
|
321
314
|
}
|
|
322
|
-
//
|
|
323
|
-
//
|
|
315
|
+
// Otherwise a keep-alive connection finishing during shutdown holds `server.close()` until
|
|
316
|
+
// its idle timeout.
|
|
324
317
|
if (closing())
|
|
325
318
|
server.closeIdleConnections();
|
|
326
319
|
}
|
|
@@ -341,9 +334,8 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
341
334
|
removeLifecycleAbort?.();
|
|
342
335
|
removeLifecycleAbort = undefined;
|
|
343
336
|
if (state === 'starting') {
|
|
344
|
-
// Closed before
|
|
345
|
-
//
|
|
346
|
-
// signal rejects it.
|
|
337
|
+
// Closed before listening: Node abandons such a listen with neither 'listening' nor
|
|
338
|
+
// 'error', so reject the pending listen() here.
|
|
347
339
|
state = 'closing';
|
|
348
340
|
cancelStartup?.();
|
|
349
341
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
@@ -363,7 +355,7 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
363
355
|
});
|
|
364
356
|
server.closeIdleConnections();
|
|
365
357
|
});
|
|
366
|
-
// Not awaited: a slow
|
|
358
|
+
// Not awaited: a slow hook must not hold shutdown past its grace period.
|
|
367
359
|
void (async () => {
|
|
368
360
|
try {
|
|
369
361
|
await target.shutdown?.();
|
|
@@ -434,7 +426,6 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
434
426
|
const onListening = () => {
|
|
435
427
|
cleanupStartup();
|
|
436
428
|
if (cancelled) {
|
|
437
|
-
// close() came first; a listener that bound all the same goes with it.
|
|
438
429
|
server.close();
|
|
439
430
|
return;
|
|
440
431
|
}
|