slotlock 0.5.1 → 0.5.2

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/store.d.ts CHANGED
@@ -635,6 +635,83 @@ export interface SlotlockStore extends SlotlockEventFeed {
635
635
  id: string;
636
636
  holderRef?: string;
637
637
  }): Promise<boolean>;
638
+ /** The tenant's dashboard assistant settings, or null when it has none. */
639
+ getAssistantSettings(params: {
640
+ tenantRef: string;
641
+ }): Promise<SlotlockAssistantSettingsRecord | null>;
642
+ /**
643
+ * Set the tenant's assistant settings. `key` is the sealed key and its hint, null for none, or
644
+ * `'keep'` to keep the one stored (none, for a tenant without settings). A stored key is kept only
645
+ * for the provider and base URL it was entered for: `'keep'` with another provider or URL changes
646
+ * nothing and answers `{ saved: false }`, decided in the one statement that writes, so racing
647
+ * saves cannot carry the key elsewhere.
648
+ */
649
+ putAssistantSettings(params: {
650
+ tenantRef: string;
651
+ provider: 'anthropic' | 'openai';
652
+ baseUrl: string;
653
+ model: string;
654
+ key: {
655
+ sealed: string;
656
+ hint: string;
657
+ } | null | 'keep';
658
+ updatedBy?: string;
659
+ }): Promise<{
660
+ saved: boolean;
661
+ }>;
662
+ /** Remove the tenant's assistant settings, its key with them. False when it had none. */
663
+ deleteAssistantSettings(params: {
664
+ tenantRef: string;
665
+ }): Promise<boolean>;
666
+ /** A member's live assistant conversation, or null when none is (or it expired). */
667
+ getAssistantConversation(params: {
668
+ tenantRef: string;
669
+ memberRef: string;
670
+ }): Promise<SlotlockAssistantConversationRecord | null>;
671
+ /**
672
+ * Store a member's conversation for `ttlMs` (1 second to a day) from now, replacing its messages.
673
+ * Its actions become those stored already that `keep` names, in their stored state
674
+ * (settleAssistantAction changes it), and the `added` ones not stored already: an action is never
675
+ * put back into a conversation that no longer has it. The tenant's expired conversations are
676
+ * deleted with it, and the tenant is listed for sweepAssistantConversations.
677
+ */
678
+ putAssistantConversation(params: {
679
+ tenantRef: string;
680
+ memberRef: string;
681
+ messages: unknown[];
682
+ added: Record<string, Record<string, unknown>>;
683
+ keep: readonly string[];
684
+ ttlMs: number;
685
+ }): Promise<void>;
686
+ /**
687
+ * Change one proposed action, only while its `status` is `from`: its fields are merged with
688
+ * `patch`. The action as changed, or null when it is not in that state (or not there): of two
689
+ * racing approvals, one wins.
690
+ */
691
+ settleAssistantAction(params: {
692
+ tenantRef: string;
693
+ memberRef: string;
694
+ actionId: string;
695
+ from: string;
696
+ patch: Record<string, unknown>;
697
+ }): Promise<Record<string, unknown> | null>;
698
+ /** Forget a member's conversation. False when there was none. */
699
+ deleteAssistantConversation(params: {
700
+ tenantRef: string;
701
+ memberRef: string;
702
+ }): Promise<boolean>;
703
+ /** Delete up to `limit` (default 1,000) of the tenant's expired assistant conversations: how many. */
704
+ pruneAssistantConversations(params: {
705
+ tenantRef: string;
706
+ limit?: number;
707
+ }): Promise<number>;
708
+ /**
709
+ * Delete up to `limit` (1-100,000, default 10,000) expired assistant conversations of every
710
+ * tenant (slotlock.sweep_assistant_conversations): how many, and nothing else.
711
+ */
712
+ sweepAssistantConversations(params?: {
713
+ limit?: number;
714
+ }): Promise<number>;
638
715
  /**
639
716
  * Offer freed time to the waitlist, in the order entries joined: each waiting entry whose window
640
717
  * is free (on its resource, or on the first free member of its pool) gets a hold for its holder
@@ -735,6 +812,29 @@ export interface SlotlockStore extends SlotlockEventFeed {
735
812
  coverageMaxAgeMs?: number;
736
813
  }): Promise<Interval | null>;
737
814
  }
815
+ /**
816
+ * The model a tenant's dashboard assistant uses (dashboard Settings, Assistant). The API key is held
817
+ * only sealed (assistant.ts sealSlotlockAssistantKey), never in the clear.
818
+ */
819
+ export interface SlotlockAssistantSettingsRecord {
820
+ provider: 'anthropic' | 'openai';
821
+ baseUrl: string;
822
+ model: string;
823
+ /** The API key, sealed with the deployment's assistant secret; null when it has none. */
824
+ sealedKey: string | null;
825
+ /** The key's last four characters, to show; null when it has none. */
826
+ keyHint: string | null;
827
+ updatedBy: string | null;
828
+ updatedAt: Date;
829
+ }
830
+ /** One member's live conversation with the dashboard assistant, as assistant.ts keeps it. */
831
+ export interface SlotlockAssistantConversationRecord {
832
+ messages: unknown[];
833
+ /** The writes it proposed, by id. */
834
+ actions: Record<string, Record<string, unknown>>;
835
+ updatedAt: Date;
836
+ expiresAt: Date;
837
+ }
738
838
  /** One entry of a tenant's waitlist (joinWaitlist). */
739
839
  export interface SlotlockWaitlistEntry {
740
840
  id: string;
@@ -874,4 +974,4 @@ export declare function createSlotlockStore(sql: StoreSql, options?: SlotlockSto
874
974
  * Every store method that writes for a tenant: what a suspended tenant's withTenant store refuses.
875
975
  * Maintenance (rolling series, pruning, purging) and erasure are not among them.
876
976
  */
877
- export declare const SLOTLOCK_TENANT_WRITE_METHODS: readonly ["createResource", "archiveResource", "restoreResource", "deleteResource", "setResourceAvailability", "setResourceBookingRules", "setResourcePool", "setTenantAvailability", "createReservation", "createExternalReservation", "rescheduleExternalReservation", "cancelExternalReservation", "acquireHold", "confirmHold", "releaseHold", "putCalendarEvent", "cancelCalendarEvent", "cancelCalendarEventById", "recordCalendarCoverage", "createWebhookEndpoint", "deleteWebhookEndpoint", "enableWebhookEndpoint", "recordCoverageFailure", "deleteCoverage", "checkInReservation", "joinWaitlist", "leaveWaitlist"];
977
+ export declare const SLOTLOCK_TENANT_WRITE_METHODS: readonly ["createResource", "archiveResource", "restoreResource", "deleteResource", "setResourceAvailability", "setResourceBookingRules", "setResourcePool", "setTenantAvailability", "createReservation", "createExternalReservation", "rescheduleExternalReservation", "cancelExternalReservation", "acquireHold", "confirmHold", "releaseHold", "putCalendarEvent", "cancelCalendarEvent", "cancelCalendarEventById", "recordCalendarCoverage", "createWebhookEndpoint", "deleteWebhookEndpoint", "enableWebhookEndpoint", "recordCoverageFailure", "deleteCoverage", "checkInReservation", "joinWaitlist", "leaveWaitlist", "putAssistantSettings", "deleteAssistantSettings", "putAssistantConversation", "settleAssistantAction", "deleteAssistantConversation"];
package/store.js CHANGED
@@ -1842,6 +1842,11 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1842
1842
  'checkInReservation',
1843
1843
  'joinWaitlist',
1844
1844
  'leaveWaitlist',
1845
+ 'putAssistantSettings',
1846
+ 'deleteAssistantSettings',
1847
+ 'putAssistantConversation',
1848
+ 'settleAssistantAction',
1849
+ 'deleteAssistantConversation',
1845
1850
  ]);
1846
1851
  function tenantSuspendedError() {
1847
1852
  return Object.assign(new Error('Slotlock tenant is suspended: it may read, and write nothing'), {
@@ -4525,6 +4530,159 @@ function buildSlotlockStore(sql, options, refusal) {
4525
4530
  return true;
4526
4531
  });
4527
4532
  },
4533
+ async getAssistantSettings(params) {
4534
+ if (!isValidIdentity(params.tenantRef))
4535
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4536
+ const [row] = await sql `
4537
+ SELECT provider, base_url, model, sealed_key, key_hint, updated_by, updated_at
4538
+ FROM slotlock.assistant_settings WHERE tenant_ref = ${params.tenantRef}`;
4539
+ if (!row)
4540
+ return null;
4541
+ return {
4542
+ provider: row.provider,
4543
+ baseUrl: row.base_url,
4544
+ model: row.model,
4545
+ sealedKey: row.sealed_key,
4546
+ keyHint: row.key_hint,
4547
+ updatedBy: row.updated_by,
4548
+ updatedAt: row.updated_at,
4549
+ };
4550
+ },
4551
+ async putAssistantSettings(params) {
4552
+ if (!isValidIdentity(params.tenantRef))
4553
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4554
+ const keep = params.key === 'keep';
4555
+ const key = params.key === 'keep' ? null : params.key;
4556
+ const saved = await sql `
4557
+ INSERT INTO slotlock.assistant_settings AS stored
4558
+ (tenant_ref, provider, base_url, model, sealed_key, key_hint, updated_by, updated_at)
4559
+ VALUES (${params.tenantRef}, ${params.provider}, ${params.baseUrl}, ${params.model},
4560
+ ${key?.sealed ?? null}, ${key?.hint ?? null}, ${params.updatedBy ?? null},
4561
+ statement_timestamp())
4562
+ ON CONFLICT (tenant_ref) DO UPDATE SET
4563
+ provider = EXCLUDED.provider,
4564
+ base_url = EXCLUDED.base_url,
4565
+ model = EXCLUDED.model,
4566
+ sealed_key = CASE WHEN ${keep}::boolean THEN stored.sealed_key ELSE EXCLUDED.sealed_key END,
4567
+ key_hint = CASE WHEN ${keep}::boolean THEN stored.key_hint ELSE EXCLUDED.key_hint END,
4568
+ updated_by = EXCLUDED.updated_by,
4569
+ updated_at = EXCLUDED.updated_at
4570
+ WHERE NOT ${keep}::boolean
4571
+ OR stored.sealed_key IS NULL
4572
+ OR (stored.provider = EXCLUDED.provider AND stored.base_url = EXCLUDED.base_url)
4573
+ RETURNING 1`;
4574
+ return { saved: saved.length > 0 };
4575
+ },
4576
+ async deleteAssistantSettings(params) {
4577
+ if (!isValidIdentity(params.tenantRef))
4578
+ return false;
4579
+ const rows = await sql `
4580
+ DELETE FROM slotlock.assistant_settings WHERE tenant_ref = ${params.tenantRef} RETURNING 1`;
4581
+ return rows.length > 0;
4582
+ },
4583
+ async getAssistantConversation(params) {
4584
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
4585
+ return null;
4586
+ const [row] = await sql `
4587
+ SELECT messages, actions, updated_at, expires_at FROM slotlock.assistant_conversations
4588
+ WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
4589
+ AND expires_at > statement_timestamp()`;
4590
+ if (!row)
4591
+ return null;
4592
+ return {
4593
+ messages: row.messages,
4594
+ actions: row.actions,
4595
+ updatedAt: row.updated_at,
4596
+ expiresAt: row.expires_at,
4597
+ };
4598
+ },
4599
+ async putAssistantConversation(params) {
4600
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef)) {
4601
+ throw invalidIdentityError('Slotlock tenant and member references must be 1-500 UTF-8 bytes');
4602
+ }
4603
+ if (!Number.isSafeInteger(params.ttlMs) || params.ttlMs < 1_000 || params.ttlMs > 86_400_000) {
4604
+ throw invalidIdentityError('Slotlock assistant conversation ttlMs must be 1 second to a day');
4605
+ }
4606
+ const messages = sql.json(params.messages);
4607
+ const added = sql.json(params.added);
4608
+ const keep = [...params.keep];
4609
+ await sql `
4610
+ DELETE FROM slotlock.assistant_conversations
4611
+ WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()`;
4612
+ await sql `
4613
+ INSERT INTO slotlock.assistant_conversations AS stored
4614
+ (tenant_ref, member_ref, messages, actions, updated_at, expires_at)
4615
+ VALUES (${params.tenantRef}, ${params.memberRef}, ${messages}, ${added},
4616
+ statement_timestamp(),
4617
+ statement_timestamp() + make_interval(secs => ${params.ttlMs / 1_000}))
4618
+ ON CONFLICT (tenant_ref, member_ref) DO UPDATE SET
4619
+ messages = EXCLUDED.messages,
4620
+ -- The stored ones kept, as they stand, then the new ones: never a settled one again.
4621
+ actions = (
4622
+ SELECT coalesce(jsonb_object_agg(existing.key, existing.value), '{}'::jsonb)
4623
+ FROM jsonb_each(stored.actions) AS existing
4624
+ WHERE existing.key = ANY(${keep}::text[])
4625
+ ) || (
4626
+ SELECT coalesce(jsonb_object_agg(fresh.key, fresh.value), '{}'::jsonb)
4627
+ FROM jsonb_each(EXCLUDED.actions) AS fresh
4628
+ WHERE NOT stored.actions ? fresh.key
4629
+ ),
4630
+ updated_at = EXCLUDED.updated_at,
4631
+ expires_at = EXCLUDED.expires_at`;
4632
+ await sql `
4633
+ SELECT slotlock.register_assistant_tenant(
4634
+ ${params.tenantRef}::text,
4635
+ statement_timestamp() + make_interval(secs => ${params.ttlMs / 1_000}))`;
4636
+ },
4637
+ async settleAssistantAction(params) {
4638
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
4639
+ return null;
4640
+ const patch = sql.json(params.patch);
4641
+ const [row] = await sql `
4642
+ UPDATE slotlock.assistant_conversations
4643
+ SET actions = jsonb_set(actions, ARRAY[${params.actionId}::text],
4644
+ (actions -> ${params.actionId}::text) || ${patch}::jsonb)
4645
+ WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
4646
+ AND expires_at > statement_timestamp()
4647
+ AND actions -> ${params.actionId}::text ->> 'status' = ${params.from}
4648
+ RETURNING actions -> ${params.actionId}::text AS action`;
4649
+ return row?.action ?? null;
4650
+ },
4651
+ async pruneAssistantConversations(params) {
4652
+ if (!isValidIdentity(params.tenantRef))
4653
+ return 0;
4654
+ const limit = params.limit ?? 1_000;
4655
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) {
4656
+ throw invalidIdentityError('Slotlock assistant prune limit must be 1-10,000');
4657
+ }
4658
+ const rows = await sql `
4659
+ DELETE FROM slotlock.assistant_conversations
4660
+ WHERE tenant_ref = ${params.tenantRef}
4661
+ AND member_ref IN (
4662
+ SELECT member_ref FROM slotlock.assistant_conversations
4663
+ WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()
4664
+ LIMIT ${limit})
4665
+ AND expires_at <= statement_timestamp()
4666
+ RETURNING 1`;
4667
+ return rows.length;
4668
+ },
4669
+ async sweepAssistantConversations(params) {
4670
+ const limit = params?.limit ?? 10_000;
4671
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100_000) {
4672
+ throw invalidIdentityError('Slotlock assistant sweep limit must be 1-100,000');
4673
+ }
4674
+ const [row] = await sql `
4675
+ SELECT slotlock.sweep_assistant_conversations(${limit}::integer) AS deleted`;
4676
+ return Number(row?.deleted ?? 0);
4677
+ },
4678
+ async deleteAssistantConversation(params) {
4679
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
4680
+ return false;
4681
+ const rows = await sql `
4682
+ DELETE FROM slotlock.assistant_conversations
4683
+ WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef} RETURNING 1`;
4684
+ return rows.length > 0;
4685
+ },
4528
4686
  async offerWaitlist(params) {
4529
4687
  if (!isValidIdentity(params.tenantRef)) {
4530
4688
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
package/webhooks.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { lookup as dnsLookup } from 'node:dns';
1
2
  import type { SlotlockChange, SlotlockEventFeed } from './event-feed.js';
2
3
  /** The CloudEvents envelope a change is delivered and listed as. */
3
4
  export interface SlotlockCloudEvent {
@@ -49,6 +50,29 @@ export declare function createSlotlockWebhookSender(options?: {
49
50
  timeoutMs?: number;
50
51
  allowPrivateNetworks?: boolean;
51
52
  }): SlotlockWebhookSender;
53
+ /**
54
+ * A `dns.lookup` that answers every address a name resolves to, and refuses (an error with `code`)
55
+ * a name with any private one unless `allowPrivateNetworks`. Given to a socket's connect, it checks
56
+ * the addresses the connection will use, so a name rebound to a private address after an earlier
57
+ * check is still refused. `resolve` stands in for `dns.lookup` (tests).
58
+ */
59
+ export declare function slotlockPrivateAddressLookup(options: {
60
+ allowPrivateNetworks: boolean;
61
+ code: string;
62
+ what: string;
63
+ resolve?: typeof dnsLookup;
64
+ }): typeof dnsLookup;
65
+ /**
66
+ * A `fetch` for a server reaching a URL a tenant chose (the dashboard assistant's model): Node's
67
+ * http client with the webhooks' address check. A URL naming a private address, or a host that
68
+ * resolves to one when the socket connects, is refused with an error whose `code` is
69
+ * `private_address`, unless `allowPrivateNetworks`. No redirect is followed (the answer is
70
+ * returned as is) and no proxy from the environment is used. Takes a string body only.
71
+ */
72
+ export declare function createSlotlockGuardedFetch(options?: {
73
+ allowPrivateNetworks?: boolean;
74
+ resolve?: typeof dnsLookup;
75
+ }): typeof fetch;
52
76
  export interface SlotlockWebhookPassResult {
53
77
  /** Changes an endpoint accepted. */
54
78
  delivered: number;
package/webhooks.js CHANGED
@@ -8,6 +8,7 @@ import { lookup as dnsLookup } from 'node:dns';
8
8
  import { Agent as HttpAgent, request as httpRequest } from 'node:http';
9
9
  import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';
10
10
  import { BlockList, isIP } from 'node:net';
11
+ import { Readable } from 'node:stream';
11
12
  import { ignoreRejection } from './hooks.js';
12
13
  /** A change as CloudEvents 1.0 (structured mode): `type` is `io.pylota.slotlock.<change type>`. */
13
14
  export function slotlockCloudEvent(change) {
@@ -104,28 +105,16 @@ export function isPrivateAddress(address) {
104
105
  */
105
106
  export function createSlotlockWebhookSender(options = {}) {
106
107
  const timeoutMs = options.timeoutMs ?? 15_000;
107
- const lookup = ((hostname, lookupOptions, callback) => {
108
- dnsLookup(hostname, { ...lookupOptions, all: true }, (error, addresses) => {
109
- if (error)
110
- return callback(error);
111
- const list = addresses;
112
- if (!options.allowPrivateNetworks && list.some(({ address }) => isPrivateAddress(address))) {
113
- return callback(Object.assign(new Error(`webhook host ${hostname} resolves to a private address`), { code: 'webhook_private_address' }));
114
- }
115
- const all = lookupOptions.all;
116
- if (all)
117
- return callback(null, list);
118
- const first = list[0];
119
- return first ? callback(null, first.address, first.family) : callback(new Error('no address'));
120
- });
108
+ const lookup = slotlockPrivateAddressLookup({
109
+ allowPrivateNetworks: options.allowPrivateNetworks === true,
110
+ code: 'webhook_private_address',
111
+ what: 'webhook',
121
112
  });
122
113
  return (request) => new Promise((resolve, reject) => {
123
114
  const url = new URL(request.url);
124
- if (isIP(url.hostname.replace(/^\[|\]$/g, '')) && !options.allowPrivateNetworks) {
125
- if (isPrivateAddress(url.hostname.replace(/^\[|\]$/g, ''))) {
126
- reject(Object.assign(new Error('webhook URL names a private address'), { code: 'webhook_private_address' }));
127
- return;
128
- }
115
+ if (!options.allowPrivateNetworks && namesPrivateAddress(url)) {
116
+ reject(Object.assign(new Error('webhook URL names a private address'), { code: 'webhook_private_address' }));
117
+ return;
129
118
  }
130
119
  const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
131
120
  const outgoing = send(url, {
@@ -164,6 +153,94 @@ export function createSlotlockWebhookSender(options = {}) {
164
153
  outgoing.end(request.body);
165
154
  });
166
155
  }
156
+ /** Whether a URL's host is an IP address literal that isPrivateAddress refuses. */
157
+ function namesPrivateAddress(url) {
158
+ const host = url.hostname.replace(/^\[|\]$/g, '');
159
+ return isIP(host) !== 0 && isPrivateAddress(host);
160
+ }
161
+ /**
162
+ * A `dns.lookup` that answers every address a name resolves to, and refuses (an error with `code`)
163
+ * a name with any private one unless `allowPrivateNetworks`. Given to a socket's connect, it checks
164
+ * the addresses the connection will use, so a name rebound to a private address after an earlier
165
+ * check is still refused. `resolve` stands in for `dns.lookup` (tests).
166
+ */
167
+ export function slotlockPrivateAddressLookup(options) {
168
+ const resolve = options.resolve ?? dnsLookup;
169
+ return ((hostname, lookupOptions, callback) => {
170
+ resolve(hostname, { ...lookupOptions, all: true }, (error, addresses) => {
171
+ if (error)
172
+ return callback(error);
173
+ const list = addresses;
174
+ if (!options.allowPrivateNetworks && list.some(({ address }) => isPrivateAddress(address))) {
175
+ return callback(Object.assign(new Error(`${options.what} host ${hostname} resolves to a private address`), { code: options.code }));
176
+ }
177
+ const all = lookupOptions.all;
178
+ if (all)
179
+ return callback(null, list);
180
+ const first = list[0];
181
+ return first ? callback(null, first.address, first.family) : callback(new Error('no address'));
182
+ });
183
+ });
184
+ }
185
+ /**
186
+ * A `fetch` for a server reaching a URL a tenant chose (the dashboard assistant's model): Node's
187
+ * http client with the webhooks' address check. A URL naming a private address, or a host that
188
+ * resolves to one when the socket connects, is refused with an error whose `code` is
189
+ * `private_address`, unless `allowPrivateNetworks`. No redirect is followed (the answer is
190
+ * returned as is) and no proxy from the environment is used. Takes a string body only.
191
+ */
192
+ export function createSlotlockGuardedFetch(options = {}) {
193
+ const allow = options.allowPrivateNetworks === true;
194
+ const lookup = slotlockPrivateAddressLookup({
195
+ allowPrivateNetworks: allow,
196
+ code: 'private_address',
197
+ what: 'requested',
198
+ ...(options.resolve ? { resolve: options.resolve } : {}),
199
+ });
200
+ const refused = () => Object.assign(new Error('the URL names a private address'), { code: 'private_address' });
201
+ return (async (input, init = {}) => {
202
+ const url = new URL(input instanceof Request ? input.url : String(input));
203
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
204
+ throw new TypeError('unsupported URL');
205
+ if (!allow && namesPrivateAddress(url))
206
+ throw refused();
207
+ const body = init.body === undefined || init.body === null ? undefined : String(init.body);
208
+ const headers = {};
209
+ new Headers(init.headers).forEach((value, name) => {
210
+ headers[name] = value;
211
+ });
212
+ if (body !== undefined)
213
+ headers['content-length'] = String(Buffer.byteLength(body));
214
+ const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
215
+ return new Promise((resolve, reject) => {
216
+ const outgoing = send(url, {
217
+ method: init.method ?? 'GET',
218
+ headers,
219
+ // An agent of its own: a proxy from the environment would skip the address check.
220
+ agent: url.protocol === 'https:' ? new HttpsAgent({ lookup }) : new HttpAgent({ lookup }),
221
+ lookup,
222
+ ...(init.signal ? { signal: init.signal } : {}),
223
+ }, (response) => {
224
+ const status = response.statusCode ?? 0;
225
+ const answer = new Headers();
226
+ for (const [name, value] of Object.entries(response.headers)) {
227
+ for (const each of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
228
+ answer.append(name, each);
229
+ }
230
+ }
231
+ if (status < 200 || status > 599) {
232
+ response.destroy();
233
+ reject(new Error(`unexpected HTTP status ${status}`));
234
+ return;
235
+ }
236
+ const stream = status === 204 || status === 304 ? null : Readable.toWeb(response);
237
+ resolve(new Response(stream, { status, headers: answer }));
238
+ });
239
+ outgoing.on('error', reject);
240
+ outgoing.end(body);
241
+ });
242
+ });
243
+ }
167
244
  /**
168
245
  * One delivery pass for a tenant: claim each endpoint with changes due, send them in order until
169
246
  * one fails, and record how far each got. A 2xx is delivered; 410 disables the endpoint; anything