slotlock 0.1.1 → 0.3.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 +455 -0
- package/CONTRIBUTING.md +55 -19
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +403 -103
- package/SECURITY.md +70 -5
- package/SPEC.md +161 -21
- package/agent-server.d.ts +76 -10
- package/agent-server.js +381 -146
- package/agent-store-backend.d.ts +22 -0
- package/agent-store-backend.js +598 -24
- package/api-keys.d.ts +22 -7
- package/api-keys.js +38 -8
- package/application-api.d.ts +67 -0
- package/application-api.js +552 -0
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +10 -1
- package/dashboard.js +23 -5
- package/ddl.d.ts +47 -4
- package/ddl.js +679 -25
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +12 -8
- package/index.js +7 -5
- package/maintenance.d.ts +67 -0
- package/maintenance.js +213 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +127 -6
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.d.ts +33 -18
- package/rest-api.js +141 -63
- package/rules.d.ts +19 -0
- package/rules.js +38 -7
- package/self-host.d.ts +26 -9
- package/self-host.js +427 -47
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +359 -27
- package/store.js +2077 -296
- package/sync.d.ts +43 -2
- package/sync.js +1950 -277
- package/timezone.d.ts +77 -3
- package/timezone.js +265 -21
- package/types.d.ts +217 -10
- package/weekly-hours.js +2 -2
package/node-server.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
/// <reference types="node" preserve="true" />
|
|
2
2
|
import { type Server } from 'node:http';
|
|
3
3
|
export type SlotlockNodeServerErrorPhase = 'client' | 'listen' | 'request' | 'server' | 'shutdown';
|
|
4
|
+
/** What the listener knows about a request's connection, beside the Request itself. */
|
|
5
|
+
export interface SlotlockNodeServerConnection {
|
|
6
|
+
/**
|
|
7
|
+
* The peer's address as the socket reports it (an IPv4 client of a dual-stack listener reads
|
|
8
|
+
* `::ffff:a.b.c.d`). Behind a proxy it is the proxy's; never taken from a request field.
|
|
9
|
+
*/
|
|
10
|
+
remoteAddress?: string;
|
|
11
|
+
}
|
|
4
12
|
/** The narrow Fetch-compatible boundary returned by `createSlotlockAgentServer`. */
|
|
5
13
|
export interface SlotlockNodeServerFetchTarget {
|
|
6
|
-
fetch(request: Request): Promise<Response>;
|
|
14
|
+
fetch(request: Request, connection?: SlotlockNodeServerConnection): Promise<Response>;
|
|
7
15
|
/**
|
|
8
16
|
* Called once when `close()` begins, after the listener stops accepting connections, to end
|
|
9
17
|
* long-lived responses (MCP subscription streams) so they drain within the grace period.
|
package/node-server.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// Kept in the emitted node-server.d.ts: this entry point's types import `node:http`.
|
|
2
2
|
/// <reference types="node" preserve="true" />
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
|
-
import { Readable } from 'node:stream';
|
|
4
|
+
import { Readable, finished } from 'node:stream';
|
|
5
5
|
import { pipeline } from 'node:stream/promises';
|
|
6
6
|
const DEFAULT_HANDLER_TIMEOUT_MS = 30_000;
|
|
7
7
|
const DEFAULT_HEADERS_TIMEOUT_MS = 10_000;
|
|
8
8
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
9
9
|
const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 5_000;
|
|
10
|
+
/** How often Node checks its connections against headersTimeout and requestTimeout, at most. */
|
|
11
|
+
const DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS = 1_000;
|
|
10
12
|
const DEFAULT_SHUTDOWN_GRACE_MS = 10_000;
|
|
11
13
|
const DEFAULT_MAX_HEADER_BYTES = 16_384;
|
|
12
14
|
const DEFAULT_MAX_REQUESTS_PER_SOCKET = 100;
|
|
@@ -72,11 +74,74 @@ function appendIncomingHeaders(request) {
|
|
|
72
74
|
}
|
|
73
75
|
return headers;
|
|
74
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* The request body as a web stream that a target may cancel, as it does a body over its size limit.
|
|
79
|
+
* Cancelling a `Readable.toWeb` stream destroys the IncomingMessage: Node emits 'aborted', read
|
|
80
|
+
* here as the client leaving, and the unread rest stays on the connection, which then holds
|
|
81
|
+
* `close()` until it is forced. Cancelling this one only stops delivery; once the response is
|
|
82
|
+
* written the rest is discarded as it arrives, as Node discards a body no handler reads, within
|
|
83
|
+
* `requestTimeout`.
|
|
84
|
+
*/
|
|
85
|
+
function requestBody(request) {
|
|
86
|
+
let controller;
|
|
87
|
+
// Once the stream is closed, errored or cancelled, what arrives is dropped.
|
|
88
|
+
let settled = false;
|
|
89
|
+
const stream = new ReadableStream({
|
|
90
|
+
start(started) {
|
|
91
|
+
controller = started;
|
|
92
|
+
request.pause();
|
|
93
|
+
request.on('data', (chunk) => {
|
|
94
|
+
if (settled)
|
|
95
|
+
return;
|
|
96
|
+
started.enqueue(new Uint8Array(chunk));
|
|
97
|
+
if ((started.desiredSize ?? 0) <= 0)
|
|
98
|
+
request.pause();
|
|
99
|
+
});
|
|
100
|
+
finished(request, (error) => {
|
|
101
|
+
if (settled)
|
|
102
|
+
return;
|
|
103
|
+
settled = true;
|
|
104
|
+
if (error)
|
|
105
|
+
started.error(error);
|
|
106
|
+
else
|
|
107
|
+
started.close();
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
pull() {
|
|
111
|
+
if (!settled)
|
|
112
|
+
request.resume();
|
|
113
|
+
},
|
|
114
|
+
cancel() {
|
|
115
|
+
// Held until the response is written, so the client is not read from while it waits.
|
|
116
|
+
settled = true;
|
|
117
|
+
request.pause();
|
|
118
|
+
},
|
|
119
|
+
}, { highWaterMark: request.readableHighWaterMark, size: (chunk) => chunk.byteLength });
|
|
120
|
+
return {
|
|
121
|
+
stream,
|
|
122
|
+
discard() {
|
|
123
|
+
// A target still reading a body it has already answered gets an error, not a read that
|
|
124
|
+
// never settles.
|
|
125
|
+
if (!settled) {
|
|
126
|
+
settled = true;
|
|
127
|
+
controller?.error(abortReason('request_body_discarded'));
|
|
128
|
+
}
|
|
129
|
+
request.resume();
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
75
133
|
function toWebRequest(request, requestOrigin, signal) {
|
|
76
134
|
const target = request.url ?? '';
|
|
77
135
|
if (!target.startsWith('/') || target.startsWith('//')) {
|
|
78
136
|
throw new Error('invalid_request_target');
|
|
79
137
|
}
|
|
138
|
+
// URL parsing resolves dot segments, `%2E` spellings included: `/v1/resources/%2E%2E/availability`
|
|
139
|
+
// would reach the target as `/v1/availability`, a path parameter of `..` naming another route.
|
|
140
|
+
// It also takes a backslash for a slash, so a segment ends at either.
|
|
141
|
+
const path = target.split(/[?#]/, 1)[0] ?? '';
|
|
142
|
+
if (path.split(/[\\/]/).some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment))) {
|
|
143
|
+
throw new Error('invalid_request_target');
|
|
144
|
+
}
|
|
80
145
|
const url = new URL(target, requestOrigin);
|
|
81
146
|
if (url.origin !== requestOrigin)
|
|
82
147
|
throw new Error('invalid_request_target');
|
|
@@ -88,11 +153,20 @@ function toWebRequest(request, requestOrigin, signal) {
|
|
|
88
153
|
headers: appendIncomingHeaders(request),
|
|
89
154
|
signal,
|
|
90
155
|
};
|
|
156
|
+
let body;
|
|
91
157
|
if (init.method !== 'GET' && init.method !== 'HEAD') {
|
|
92
|
-
|
|
158
|
+
body = requestBody(request);
|
|
159
|
+
init.body = body.stream;
|
|
93
160
|
init.duplex = 'half';
|
|
94
161
|
}
|
|
95
|
-
|
|
162
|
+
try {
|
|
163
|
+
return { request: new Request(url, init), ...(body ? { body } : {}) };
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
// A method the Fetch API forbids, say: nothing will read the body, so drop it as it arrives.
|
|
167
|
+
body?.discard();
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
96
170
|
}
|
|
97
171
|
const HOP_BY_HOP_RESPONSE_HEADERS = new Set([
|
|
98
172
|
'connection',
|
|
@@ -161,10 +235,15 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
161
235
|
// opening check, but `close()` can change the state while the handler awaits.
|
|
162
236
|
const closing = () => state === 'closing';
|
|
163
237
|
let removeLifecycleAbort;
|
|
238
|
+
/** Rejects a listen() still starting with `listen_aborted`; set while one is. */
|
|
239
|
+
let cancelStartup;
|
|
164
240
|
let closePromise;
|
|
165
241
|
const server = createServer({
|
|
166
242
|
headersTimeout: headersTimeoutMs,
|
|
167
243
|
requestTimeout: requestTimeoutMs,
|
|
244
|
+
// Node enforces both only when it checks its connections (every 30 s by default), so a body
|
|
245
|
+
// trickled after its answer could outlast requestTimeout by that much.
|
|
246
|
+
connectionsCheckingInterval: Math.min(DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS, headersTimeoutMs, requestTimeoutMs),
|
|
168
247
|
keepAliveTimeout: boundedInteger('keepAliveTimeoutMs', options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS, 1, MAX_TIMEOUT_MS),
|
|
169
248
|
maxHeaderSize: boundedInteger('maxHeaderBytes', options.maxHeaderBytes ?? DEFAULT_MAX_HEADER_BYTES, 1_024, MAX_HEADER_BYTES),
|
|
170
249
|
insecureHTTPParser: false,
|
|
@@ -187,8 +266,9 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
187
266
|
abortForClient();
|
|
188
267
|
});
|
|
189
268
|
let webRequest;
|
|
269
|
+
let body;
|
|
190
270
|
try {
|
|
191
|
-
webRequest = toWebRequest(request, config.requestOrigin, controller.signal);
|
|
271
|
+
({ request: webRequest, body } = toWebRequest(request, config.requestOrigin, controller.signal));
|
|
192
272
|
}
|
|
193
273
|
catch {
|
|
194
274
|
activeRequests.delete(controller);
|
|
@@ -202,7 +282,11 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
202
282
|
timeout.unref();
|
|
203
283
|
});
|
|
204
284
|
try {
|
|
205
|
-
const
|
|
285
|
+
const connection = {};
|
|
286
|
+
if (request.socket.remoteAddress !== undefined) {
|
|
287
|
+
connection.remoteAddress = request.socket.remoteAddress;
|
|
288
|
+
}
|
|
289
|
+
const outcome = await Promise.race([target.fetch(webRequest, connection), handlerTimeout]);
|
|
206
290
|
if (outcome === HANDLER_TIMEOUT) {
|
|
207
291
|
controller.abort(abortReason('handler_timeout'));
|
|
208
292
|
safeReport(config.onError, new Error('handler_timeout'), 'request');
|
|
@@ -223,6 +307,18 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
223
307
|
if (timeout)
|
|
224
308
|
clearTimeout(timeout);
|
|
225
309
|
activeRequests.delete(controller);
|
|
310
|
+
// The answer is written: what is left of a body the target did not read (one it refused as
|
|
311
|
+
// too large, say) is discarded as it arrives rather than held.
|
|
312
|
+
if (!request.complete) {
|
|
313
|
+
body?.discard();
|
|
314
|
+
// The connection is idle only once that rest has arrived: close it then if shutting down.
|
|
315
|
+
request.once('end', () => {
|
|
316
|
+
setImmediate(() => {
|
|
317
|
+
if (closing())
|
|
318
|
+
server.closeIdleConnections();
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
}
|
|
226
322
|
// A keep-alive connection whose response finished during shutdown would otherwise hold
|
|
227
323
|
// `server.close()` open until its idle timeout; Node closed only the idle ones at the start.
|
|
228
324
|
if (closing())
|
|
@@ -244,6 +340,16 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
244
340
|
closePromise = (async () => {
|
|
245
341
|
removeLifecycleAbort?.();
|
|
246
342
|
removeLifecycleAbort = undefined;
|
|
343
|
+
if (state === 'starting') {
|
|
344
|
+
// Closed before it was listening. Node abandons a listen whose server closes first, with
|
|
345
|
+
// neither 'listening' nor 'error', so the pending listen() is rejected here, as an aborted
|
|
346
|
+
// signal rejects it.
|
|
347
|
+
state = 'closing';
|
|
348
|
+
cancelStartup?.();
|
|
349
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
350
|
+
state = 'closed';
|
|
351
|
+
return { forced: false };
|
|
352
|
+
}
|
|
247
353
|
if (state === 'new' || state === 'closed') {
|
|
248
354
|
state = 'closed';
|
|
249
355
|
return { forced: false };
|
|
@@ -305,24 +411,33 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
305
411
|
state = 'starting';
|
|
306
412
|
return new Promise((resolve, reject) => {
|
|
307
413
|
let abortRequested = false;
|
|
414
|
+
let cancelled = false;
|
|
308
415
|
const onAbort = () => {
|
|
309
416
|
abortRequested = true;
|
|
310
417
|
if (state === 'listening')
|
|
311
418
|
void close();
|
|
312
419
|
};
|
|
313
420
|
const cleanupStartup = () => {
|
|
421
|
+
cancelStartup = undefined;
|
|
314
422
|
server.off('error', onListenError);
|
|
315
423
|
server.off('listening', onListening);
|
|
316
424
|
};
|
|
317
425
|
const onListenError = (error) => {
|
|
318
426
|
cleanupStartup();
|
|
319
427
|
listenOptions.signal?.removeEventListener('abort', onAbort);
|
|
320
|
-
state = 'closed';
|
|
321
428
|
safeReport(config.onError, error, 'listen');
|
|
429
|
+
if (cancelled)
|
|
430
|
+
return;
|
|
431
|
+
state = 'closed';
|
|
322
432
|
reject(error);
|
|
323
433
|
};
|
|
324
434
|
const onListening = () => {
|
|
325
435
|
cleanupStartup();
|
|
436
|
+
if (cancelled) {
|
|
437
|
+
// close() came first; a listener that bound all the same goes with it.
|
|
438
|
+
server.close();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
326
441
|
const address = server.address();
|
|
327
442
|
if (!address || typeof address === 'string') {
|
|
328
443
|
state = 'closing';
|
|
@@ -344,6 +459,12 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
344
459
|
origin: formatListenerOrigin(address.address, address.port),
|
|
345
460
|
});
|
|
346
461
|
};
|
|
462
|
+
cancelStartup = () => {
|
|
463
|
+
cancelled = true;
|
|
464
|
+
cancelStartup = undefined;
|
|
465
|
+
listenOptions.signal?.removeEventListener('abort', onAbort);
|
|
466
|
+
reject(abortReason('listen_aborted'));
|
|
467
|
+
};
|
|
347
468
|
listenOptions.signal?.addEventListener('abort', onAbort, { once: true });
|
|
348
469
|
server.once('error', onListenError);
|
|
349
470
|
server.once('listening', onListening);
|