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.
Files changed (56) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/CONTRIBUTING.md +55 -19
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +403 -103
  7. package/SECURITY.md +70 -5
  8. package/SPEC.md +161 -21
  9. package/agent-server.d.ts +76 -10
  10. package/agent-server.js +381 -146
  11. package/agent-store-backend.d.ts +22 -0
  12. package/agent-store-backend.js +598 -24
  13. package/api-keys.d.ts +22 -7
  14. package/api-keys.js +38 -8
  15. package/application-api.d.ts +67 -0
  16. package/application-api.js +552 -0
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +10 -1
  20. package/dashboard.js +23 -5
  21. package/ddl.d.ts +47 -4
  22. package/ddl.js +679 -25
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/hooks.d.ts +7 -0
  27. package/hooks.js +13 -0
  28. package/ical-worker-thread.js +4 -2
  29. package/ical-worker.d.ts +8 -5
  30. package/ical-worker.js +9 -2
  31. package/index.d.ts +12 -8
  32. package/index.js +7 -5
  33. package/maintenance.d.ts +67 -0
  34. package/maintenance.js +213 -0
  35. package/node-server.d.ts +9 -1
  36. package/node-server.js +127 -6
  37. package/openapi.json +6024 -0
  38. package/package.json +3 -2
  39. package/rate-limit.d.ts +71 -0
  40. package/rate-limit.js +718 -0
  41. package/rest-api.d.ts +33 -18
  42. package/rest-api.js +141 -63
  43. package/rules.d.ts +19 -0
  44. package/rules.js +38 -7
  45. package/self-host.d.ts +26 -9
  46. package/self-host.js +427 -47
  47. package/slotlock-manifest.schema.json +6 -1
  48. package/slotlock.manifest.json +7 -2
  49. package/store.d.ts +359 -27
  50. package/store.js +2077 -296
  51. package/sync.d.ts +43 -2
  52. package/sync.js +1950 -277
  53. package/timezone.d.ts +77 -3
  54. package/timezone.js +265 -21
  55. package/types.d.ts +217 -10
  56. 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
- init.body = Readable.toWeb(request);
158
+ body = requestBody(request);
159
+ init.body = body.stream;
93
160
  init.duplex = 'half';
94
161
  }
95
- return new Request(url, init);
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 outcome = await Promise.race([target.fetch(webRequest), handlerTimeout]);
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);