@wtfalch/foundry 0.19.0 → 0.20.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.
@@ -0,0 +1,227 @@
1
+ import { EdgeApiError } from '@wtfalch/edge';
2
+ /** Edge answers its platform writes from one shared slot of 4 (company-less writes). */
3
+ export const EDGE_DNS_WRITE_CONCURRENCY = 4;
4
+ /**
5
+ * Edge lists a TXT record raw, quoted (`"v=spf1 ..."`, long values in several
6
+ * quoted chunks), but returns it unquoted from a create or update. Everything
7
+ * foundry compares sees the unquoted form.
8
+ */
9
+ export function unquoteTxt(content) {
10
+ const trimmed = content.trim();
11
+ if (!trimmed.startsWith('"'))
12
+ return content;
13
+ const chunks = [...trimmed.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => (m[1] ?? '').replace(/\\(.)/g, '$1'));
14
+ return chunks.length > 0 ? chunks.join('') : content;
15
+ }
16
+ function summary(record) {
17
+ return {
18
+ id: record.id,
19
+ type: record.type,
20
+ name: record.name,
21
+ content: record.type === 'TXT' ? unquoteTxt(record.content) : record.content,
22
+ };
23
+ }
24
+ export function limiter(max) {
25
+ let active = 0;
26
+ const waiting = [];
27
+ return async (task) => {
28
+ while (active >= max)
29
+ await new Promise((resolve) => waiting.push(resolve));
30
+ active++;
31
+ try {
32
+ return await task();
33
+ }
34
+ finally {
35
+ active--;
36
+ waiting.shift()?.();
37
+ }
38
+ };
39
+ }
40
+ const isEdgeNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
41
+ const isEdgeForbidden = (error) => error instanceof EdgeApiError && error.code === 'forbidden';
42
+ /**
43
+ * Edge's company-less platform calls share one per-org slot of 4 and answer a
44
+ * full slot with `rate_limited` and a retry hint, without queueing. The SDK
45
+ * does not retry, so every call here waits the hint out, up to this many tries.
46
+ */
47
+ export const EDGE_DNS_RATE_LIMIT_ATTEMPTS = 5;
48
+ const DEFAULT_RETRY_AFTER_SECONDS = 2;
49
+ const MAX_RETRY_AFTER_SECONDS = 30;
50
+ /**
51
+ * Retries a call edge answered `rate_limited`, waiting out its hint (capped at
52
+ * 30 s), up to {@link EDGE_DNS_RATE_LIMIT_ATTEMPTS} tries. Shared by every
53
+ * edge adapter, since all platform calls draw on the one slot.
54
+ */
55
+ export function rateLimitRetrier(sleepOption) {
56
+ const sleep = sleepOption ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
57
+ return async (task) => {
58
+ for (let attempt = 1;; attempt++) {
59
+ try {
60
+ return await task();
61
+ }
62
+ catch (error) {
63
+ if (!(error instanceof EdgeApiError) ||
64
+ error.code !== 'rate_limited' ||
65
+ attempt >= EDGE_DNS_RATE_LIMIT_ATTEMPTS) {
66
+ throw error;
67
+ }
68
+ await sleep(Math.min(error.retryAfterSeconds ?? DEFAULT_RETRY_AFTER_SECONDS, MAX_RETRY_AFTER_SECONDS) * 1000);
69
+ }
70
+ }
71
+ };
72
+ }
73
+ const OWNED_BY_COMPANY = 'owned by the company; foundry will not change it';
74
+ /**
75
+ * Edge answers an update or delete of a live record with no edge row
76
+ * `not_found` ("adopt it first"). So the adapter adopts a record the moment it
77
+ * is about to be changed or removed: every record it has returned that the
78
+ * list reported with `owner: null` is remembered, and adopted just before the
79
+ * first write that names it. Adopt is idempotent and the list reports the
80
+ * owner once adopted, so nothing is recorded in the plan and a later apply
81
+ * adopts nothing. A record foundry only reads (a foreign record it refuses to
82
+ * overwrite) is never adopted. An `unsupported_type`, `taken` or `organisation`
83
+ * skip is an error; an `unclaimed` record is left alone. A company's own record
84
+ * (edge answers `forbidden`) is never changed: the error names zone, name and
85
+ * type. A `rate_limited` answer is retried with edge's hint, up to 5 tries.
86
+ */
87
+ export function createEdgeDns(client, options = {}) {
88
+ const retrying = rateLimitRetrier(options.sleep);
89
+ /** The SDK client with every call retried on `rate_limited`. */
90
+ const dns = {
91
+ list: (input) => retrying(() => client.list(input)),
92
+ create: (input) => retrying(() => client.create(input)),
93
+ update: (zone, id, patch) => retrying(() => client.update(zone, id, patch)),
94
+ remove: (zone, id) => retrying(() => client.remove(zone, id)),
95
+ adopt: (input) => retrying(() => client.adopt(input)),
96
+ };
97
+ const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
98
+ /** zone -> record id -> a seen live record with no edge row yet. */
99
+ const unadopted = new Map();
100
+ /** zone -> record id -> every record seen, to name one in an error. */
101
+ const seen = new Map();
102
+ const describe = (zone, recordId) => {
103
+ const record = seen.get(zone)?.get(recordId);
104
+ return record ? `${record.type} ${record.name} in ${zone}` : `record ${recordId} in ${zone}`;
105
+ };
106
+ const remember = (zone, records) => {
107
+ const all = seen.get(zone) ?? new Map();
108
+ for (const record of records)
109
+ all.set(record.id, record);
110
+ seen.set(zone, all);
111
+ const pending = unadopted.get(zone) ?? new Map();
112
+ for (const record of records) {
113
+ if (record.owner === null)
114
+ pending.set(record.id, record);
115
+ else
116
+ pending.delete(record.id);
117
+ }
118
+ unadopted.set(zone, pending);
119
+ };
120
+ const adopt = async (zone, record) => {
121
+ const result = await writeSlot(() => dns.adopt({
122
+ zone,
123
+ records: [
124
+ {
125
+ name: record.name,
126
+ type: record.type,
127
+ id: record.id,
128
+ content: record.content,
129
+ },
130
+ ],
131
+ }));
132
+ const skipped = result.skipped[0];
133
+ if (skipped) {
134
+ throw new Error(skipped.reason === 'organisation'
135
+ ? `edge dns: ${record.type} ${record.name} in ${zone} is ${OWNED_BY_COMPANY}`
136
+ : `edge dns: ${record.type} ${record.name} in ${zone} cannot be adopted (${skipped.reason}); remove or fix it by hand`);
137
+ }
138
+ unadopted.get(zone)?.delete(record.id);
139
+ };
140
+ /** Adopts `recordId` when it was seen without an edge row. */
141
+ const ensureAdopted = async (zone, recordId) => {
142
+ const record = unadopted.get(zone)?.get(recordId);
143
+ if (record)
144
+ await adopt(zone, record);
145
+ };
146
+ /** Runs a write; on `not_found` adopts the live record once and retries. */
147
+ const writeExisting = async (zone, recordId, write) => {
148
+ await ensureAdopted(zone, recordId);
149
+ // `forbidden` also answers a revoked or under-scoped platform key, so the
150
+ // record is listed again and the company message is used only when its
151
+ // owner really is an organisation; anything else is the original error.
152
+ const refuseCompanyRecord = async (error) => {
153
+ if (!isEdgeForbidden(error))
154
+ throw error;
155
+ const known = seen.get(zone)?.get(recordId);
156
+ let owner;
157
+ try {
158
+ const { records } = await dns.list({
159
+ zone,
160
+ ...(known ? { name: known.name, type: known.type } : {}),
161
+ });
162
+ remember(zone, records);
163
+ owner = records.find((r) => r.id === recordId)?.owner;
164
+ }
165
+ catch {
166
+ throw error;
167
+ }
168
+ if (owner !== 'organisation')
169
+ throw error;
170
+ throw new Error(`edge dns: ${describe(zone, recordId)} is ${OWNED_BY_COMPANY}`);
171
+ };
172
+ try {
173
+ return await writeSlot(write);
174
+ }
175
+ catch (error) {
176
+ if (!isEdgeNotFound(error))
177
+ return refuseCompanyRecord(error);
178
+ const live = (await dns.list({ zone })).records;
179
+ remember(zone, live);
180
+ const record = live.find((r) => r.id === recordId);
181
+ if (!record || record.owner !== null)
182
+ throw error;
183
+ await adopt(zone, record);
184
+ try {
185
+ return await writeSlot(write);
186
+ }
187
+ catch (retryError) {
188
+ return refuseCompanyRecord(retryError);
189
+ }
190
+ }
191
+ };
192
+ const body = (rec) => ({
193
+ content: rec.content,
194
+ ...(rec.proxied === undefined ? {} : { proxied: rec.proxied }),
195
+ ...(rec.ttl === undefined ? {} : { ttl: rec.ttl }),
196
+ ...(rec.priority === undefined ? {} : { priority: rec.priority }),
197
+ });
198
+ const list = async (zone, opts) => {
199
+ const { records } = await dns.list({
200
+ zone,
201
+ ...(opts?.name ? { name: opts.name } : {}),
202
+ ...(opts?.type ? { type: opts.type } : {}),
203
+ });
204
+ remember(zone, records);
205
+ return records.map(summary);
206
+ };
207
+ return {
208
+ findDnsRecord: (zone, name, type) => list(zone, { name, type }),
209
+ listDnsRecords: list,
210
+ async createDnsRecord(zone, rec) {
211
+ const created = await writeSlot(() => dns.create({
212
+ zone,
213
+ name: rec.name,
214
+ type: rec.type,
215
+ ...body(rec),
216
+ }));
217
+ return { id: created.id, name: created.name, content: summary(created).content };
218
+ },
219
+ async updateDnsRecord(zone, recordId, rec) {
220
+ const updated = await writeExisting(zone, recordId, () => dns.update(zone, recordId, body(rec)));
221
+ return { id: updated.id, name: updated.name, content: summary(updated).content };
222
+ },
223
+ async deleteDnsRecord(zone, recordId) {
224
+ await writeExisting(zone, recordId, () => dns.remove(zone, recordId));
225
+ },
226
+ };
227
+ }
@@ -0,0 +1,54 @@
1
+ import type { createEdgeClient } from '@wtfalch/edge';
2
+ import { type WriteSlot } from './edge-dns.js';
3
+ /** The edge SDK's platform rate limiting client: `createEdgeClient({ ... }).platform.ratelimits`. */
4
+ export type EdgePlatformRateLimits = ReturnType<typeof createEdgeClient>['platform']['ratelimits'];
5
+ /** One rate limiting rule, in the shape foundry has always sent Cloudflare. */
6
+ export interface RateLimitRuleInput {
7
+ description: string;
8
+ expression: string;
9
+ action: string;
10
+ ratelimit: {
11
+ characteristics: string[];
12
+ period: number;
13
+ requests_per_period: number;
14
+ mitigation_timeout: number;
15
+ };
16
+ }
17
+ /**
18
+ * Foundry's Cloudflare rate limiting calls. Every one goes through this; the
19
+ * one implementation is {@link createEdgeRateLimits}, which speaks to the edge
20
+ * service with the platform credential.
21
+ */
22
+ export interface RateLimitPort {
23
+ /** Every live rule in the zone, whoever owns it. */
24
+ listRateLimitRules(zoneId: string): Promise<Array<{
25
+ id: string;
26
+ description: string | null;
27
+ }>>;
28
+ /** Makes the rule, or returns the one already made for that zone and description. Never changes an existing rule. */
29
+ createRateLimitRule(zoneId: string, rule: RateLimitRuleInput): Promise<{
30
+ id: string;
31
+ }>;
32
+ }
33
+ /** Edge answers a create another create of the same rule is making `conflict` + `busy`. */
34
+ export declare const EDGE_RATELIMITS_BUSY_ATTEMPTS = 5;
35
+ export declare const hasReason: (error: unknown, reason: string) => boolean;
36
+ /** Waits 1 s, 2 s, 4 s, then 8 s (the cap) between tries of a `busy` answer. */
37
+ export declare function busyRetrier(sleepOption?: (ms: number) => Promise<void>): <T>(task: () => Promise<T>) => Promise<T>;
38
+ export interface EdgeRateLimitsOptions {
39
+ sleep?: (ms: number) => Promise<void>;
40
+ /** Share one slot of {@link EDGE_DNS_WRITE_CONCURRENCY} with the other edge adapters: edge counts them all. */
41
+ writeSlot?: WriteSlot;
42
+ }
43
+ /**
44
+ * {@link RateLimitPort} over edge's `platform.ratelimits`.
45
+ *
46
+ * Edge answers a create of a live rule that has no edge row `exists`. So the
47
+ * adapter adopts that rule the moment it is about to create it: it lists the
48
+ * description, and adopts a view whose `owner` is `null`. Adopt is idempotent
49
+ * and the list reports the owner afterwards, so nothing is recorded in the
50
+ * plan. A rule edge reports as `taken` is an error. Create is keyed by zone
51
+ * and description and never compares content, so a rule that exists comes back
52
+ * unchanged. A `rate_limited` or `busy` answer is retried.
53
+ */
54
+ export declare function createEdgeRateLimits(client: EdgePlatformRateLimits, options?: EdgeRateLimitsOptions): RateLimitPort;
@@ -0,0 +1,79 @@
1
+ import { EdgeApiError } from '@wtfalch/edge';
2
+ import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-dns.js';
3
+ /** Edge answers a create another create of the same rule is making `conflict` + `busy`. */
4
+ export const EDGE_RATELIMITS_BUSY_ATTEMPTS = 5;
5
+ const BUSY_FIRST_DELAY_MS = 1000;
6
+ const BUSY_MAX_DELAY_MS = 8000;
7
+ export const hasReason = (error, reason) => error instanceof EdgeApiError && error.code === 'conflict' && String(error.reason) === reason;
8
+ /** Waits 1 s, 2 s, 4 s, then 8 s (the cap) between tries of a `busy` answer. */
9
+ export function busyRetrier(sleepOption) {
10
+ const sleep = sleepOption ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
11
+ return async (task) => {
12
+ for (let attempt = 1;; attempt++) {
13
+ try {
14
+ return await task();
15
+ }
16
+ catch (error) {
17
+ if (!hasReason(error, 'busy') || attempt >= EDGE_RATELIMITS_BUSY_ATTEMPTS)
18
+ throw error;
19
+ await sleep(Math.min(BUSY_FIRST_DELAY_MS * 2 ** (attempt - 1), BUSY_MAX_DELAY_MS));
20
+ }
21
+ }
22
+ };
23
+ }
24
+ /**
25
+ * {@link RateLimitPort} over edge's `platform.ratelimits`.
26
+ *
27
+ * Edge answers a create of a live rule that has no edge row `exists`. So the
28
+ * adapter adopts that rule the moment it is about to create it: it lists the
29
+ * description, and adopts a view whose `owner` is `null`. Adopt is idempotent
30
+ * and the list reports the owner afterwards, so nothing is recorded in the
31
+ * plan. A rule edge reports as `taken` is an error. Create is keyed by zone
32
+ * and description and never compares content, so a rule that exists comes back
33
+ * unchanged. A `rate_limited` or `busy` answer is retried.
34
+ */
35
+ export function createEdgeRateLimits(client, options = {}) {
36
+ const rateRetry = rateLimitRetrier(options.sleep);
37
+ const busyRetry = busyRetrier(options.sleep);
38
+ const retrying = (task) => busyRetry(() => rateRetry(task));
39
+ const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
40
+ /** `zoneId/description` keys already adopted (or created) through this adapter. */
41
+ const adopted = new Set();
42
+ const adoptLive = async (zoneId, description) => {
43
+ const key = `${zoneId}/${description}`;
44
+ if (adopted.has(key))
45
+ return;
46
+ const { rules } = await retrying(() => client.list({ zoneId, description }));
47
+ const view = rules.find((r) => r.description === description && r.state !== 'removed');
48
+ if (view && view.owner === null) {
49
+ const result = await writeSlot(() => retrying(() => client.adopt({ rules: [{ zoneId, description, id: view.id }] })));
50
+ if (result.skipped.length > 0) {
51
+ throw new Error(`edge rate limits: rule '${description}' in zone ${zoneId} cannot be adopted (${result.skipped[0]?.reason}); remove or fix it by hand`);
52
+ }
53
+ }
54
+ adopted.add(key);
55
+ };
56
+ return {
57
+ async listRateLimitRules(zoneId) {
58
+ const { rules } = await retrying(() => client.list({ zoneId }));
59
+ return rules
60
+ .filter((r) => r.state !== 'removed')
61
+ .map((r) => ({ id: r.id, description: r.description }));
62
+ },
63
+ async createRateLimitRule(zoneId, rule) {
64
+ await adoptLive(zoneId, rule.description);
65
+ const create = () => writeSlot(() => retrying(() => client.create({ zoneId, ...rule })));
66
+ try {
67
+ return { id: (await create()).id };
68
+ }
69
+ catch (error) {
70
+ if (!hasReason(error, 'exists'))
71
+ throw error;
72
+ // The rule went live between the adopt and the create: adopt it and try once more.
73
+ adopted.delete(`${zoneId}/${rule.description}`);
74
+ await adoptLive(zoneId, rule.description);
75
+ return { id: (await create()).id };
76
+ }
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,44 @@
1
+ import type { TunnelIngressRule, createEdgeClient } from '@wtfalch/edge';
2
+ import { type WriteSlot } from './edge-dns.js';
3
+ /** The edge SDK's platform tunnel client: `createEdgeClient({ ... }).platform.tunnels`. */
4
+ export type EdgePlatformTunnels = ReturnType<typeof createEdgeClient>['platform']['tunnels'];
5
+ /** One ingress rule. `path` tells rules on one hostname apart; the catch-all has none. */
6
+ export type TunnelIngress = TunnelIngressRule;
7
+ export interface TunnelSummary {
8
+ /** Cloudflare's tunnel id: what every edge tunnel call takes. */
9
+ id: string;
10
+ name: string;
11
+ }
12
+ /**
13
+ * Foundry's tunnel calls. Every tunnel call in foundry goes through this; the
14
+ * one implementation is {@link createEdgeTunnels}, which speaks to the edge
15
+ * service with the platform credential.
16
+ */
17
+ export interface TunnelPort {
18
+ /** The tunnel named `name` that foundry may use (the platform's own, or one nobody adopted yet). */
19
+ findTunnelByName(name: string): Promise<TunnelSummary | undefined>;
20
+ createTunnel(name: string): Promise<TunnelSummary>;
21
+ /** Replaces the whole ingress; the last rule is the catch-all and has no hostname. */
22
+ putTunnelConfig(tunnelId: string, config: {
23
+ ingress: TunnelIngress[];
24
+ }): Promise<void>;
25
+ /** The connector token: a live credential. Hand it to the connector and nowhere else. */
26
+ getTunnelToken(tunnelId: string): Promise<string>;
27
+ deleteTunnel(tunnelId: string): Promise<void>;
28
+ }
29
+ /**
30
+ * Edge answers a config, token or delete call for a live tunnel with no edge
31
+ * row `not_found` ("create or adopt it first"). So the adapter adopts a tunnel
32
+ * the moment it is about to be configured, read the token of or deleted: a
33
+ * tunnel it found by name (or that a create refused with `exists`) and whose
34
+ * owner the list reported as `null` is remembered, and adopted just before the
35
+ * first such call. A tunnel foundry recorded in an earlier apply and now names
36
+ * by id only is looked up on `not_found`, adopted if unowned, and the call
37
+ * retried once. Adopt is idempotent and the list reports the owner afterwards,
38
+ * so nothing is recorded in the plan and a later apply adopts nothing. A
39
+ * name claim's tunnel (`organisation`) is never touched. A `rate_limited` answer is retried with edge's hint, up to 5 tries.
40
+ */
41
+ export declare function createEdgeTunnels(client: EdgePlatformTunnels, options?: {
42
+ sleep?: (ms: number) => Promise<void>;
43
+ writeSlot?: WriteSlot;
44
+ }): TunnelPort;
@@ -0,0 +1,108 @@
1
+ import { EdgeApiError } from '@wtfalch/edge';
2
+ import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-dns.js';
3
+ const isNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
4
+ /** Edge's 409 for a live tunnel with the name and no edge row. */
5
+ const isExists = (error) => error instanceof EdgeApiError && error.code === 'conflict' && error.reason === 'exists';
6
+ /**
7
+ * Edge answers a config, token or delete call for a live tunnel with no edge
8
+ * row `not_found` ("create or adopt it first"). So the adapter adopts a tunnel
9
+ * the moment it is about to be configured, read the token of or deleted: a
10
+ * tunnel it found by name (or that a create refused with `exists`) and whose
11
+ * owner the list reported as `null` is remembered, and adopted just before the
12
+ * first such call. A tunnel foundry recorded in an earlier apply and now names
13
+ * by id only is looked up on `not_found`, adopted if unowned, and the call
14
+ * retried once. Adopt is idempotent and the list reports the owner afterwards,
15
+ * so nothing is recorded in the plan and a later apply adopts nothing. A
16
+ * name claim's tunnel (`organisation`) is never touched. A `rate_limited` answer is retried with edge's hint, up to 5 tries.
17
+ */
18
+ export function createEdgeTunnels(client, options = {}) {
19
+ const retrying = rateLimitRetrier(options.sleep);
20
+ const tunnels = {
21
+ list: (filter) => retrying(() => client.list(filter)),
22
+ create: (input) => retrying(() => client.create(input)),
23
+ putConfig: (id, input) => retrying(() => client.putConfig(id, input)),
24
+ token: (id) => retrying(() => client.token(id)),
25
+ remove: (id) => retrying(() => client.remove(id)),
26
+ adopt: (input) => retrying(() => client.adopt(input)),
27
+ };
28
+ const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
29
+ /** tunnel id -> a seen live tunnel with no edge row yet. */
30
+ const unadopted = new Map();
31
+ const remember = (views) => {
32
+ for (const view of views) {
33
+ if (view.owner === null)
34
+ unadopted.set(view.id, view);
35
+ else
36
+ unadopted.delete(view.id);
37
+ }
38
+ };
39
+ const adopt = async (view) => {
40
+ const result = await writeSlot(() => tunnels.adopt({ tunnels: [{ name: view.name, id: view.id }] }));
41
+ const skipped = result.skipped[0];
42
+ if (skipped) {
43
+ throw new Error(skipped.reason === 'organisation'
44
+ ? `edge tunnels: ${view.name} is a company's own tunnel; foundry will not change it`
45
+ : `edge tunnels: ${view.name} cannot be adopted (${skipped.reason}); remove or fix it by hand`);
46
+ }
47
+ if (result.notFound.length > 0) {
48
+ throw new EdgeApiError(404, 'not_found', `edge tunnels: ${view.name} is no longer live`);
49
+ }
50
+ unadopted.delete(view.id);
51
+ };
52
+ /** Runs a call on a tunnel; adopts it first when seen unowned, and once more on `not_found`. */
53
+ const onTunnel = async (tunnelId, call) => {
54
+ const seen = unadopted.get(tunnelId);
55
+ if (seen)
56
+ await adopt(seen);
57
+ try {
58
+ return await call();
59
+ }
60
+ catch (error) {
61
+ if (!isNotFound(error))
62
+ throw error;
63
+ const live = (await tunnels.list()).tunnels;
64
+ remember(live);
65
+ const view = live.find((t) => t.id === tunnelId);
66
+ if (!view || view.owner !== null)
67
+ throw error;
68
+ await adopt(view);
69
+ return call();
70
+ }
71
+ };
72
+ const usable = (views) => views.find((v) => v.owner === 'platform') ?? views.find((v) => v.owner === null);
73
+ const findTunnelByName = async (name) => {
74
+ const { tunnels: found } = await tunnels.list({ name });
75
+ remember(found);
76
+ const view = usable(found.filter((v) => v.name === name && v.state !== 'removed'));
77
+ return view ? { id: view.id, name: view.name } : undefined;
78
+ };
79
+ return {
80
+ findTunnelByName,
81
+ async createTunnel(name) {
82
+ try {
83
+ const created = await writeSlot(() => tunnels.create({ name }));
84
+ return { id: created.id, name: created.name };
85
+ }
86
+ catch (error) {
87
+ // A live tunnel of this name exists with no edge row (made before edge
88
+ // owned tunnels): use it, adopting it before its first config call.
89
+ if (!isExists(error))
90
+ throw error;
91
+ const found = await findTunnelByName(name);
92
+ if (!found)
93
+ throw error;
94
+ return found;
95
+ }
96
+ },
97
+ async putTunnelConfig(tunnelId, config) {
98
+ await onTunnel(tunnelId, () => writeSlot(() => tunnels.putConfig(tunnelId, config)));
99
+ },
100
+ async getTunnelToken(tunnelId) {
101
+ const { token } = await onTunnel(tunnelId, () => tunnels.token(tunnelId));
102
+ return token;
103
+ },
104
+ async deleteTunnel(tunnelId) {
105
+ await onTunnel(tunnelId, () => writeSlot(() => tunnels.remove(tunnelId)));
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,31 @@
1
+ import type { createEdgeClient } from '@wtfalch/edge';
2
+ import { type WriteSlot } from './edge-dns.js';
3
+ import type { WorkersPort } from './executor.js';
4
+ /** The edge SDK's platform Workers client: `createEdgeClient({ ... }).platform.workers`. */
5
+ export type EdgePlatformWorkers = ReturnType<typeof createEdgeClient>['platform']['workers'];
6
+ /** Edge answers a first upload of a name that another upload is making, or a delete in flight, `conflict` + `busy`. */
7
+ export declare const EDGE_WORKERS_BUSY_ATTEMPTS = 5;
8
+ /** What the host supplies: fetches the artifact and returns its bytes, verified against `sha256`. Foundry never fetches. */
9
+ export interface EdgeWorkersOptions {
10
+ fetchArtifact: (url: string, sha256: string) => Promise<Uint8Array>;
11
+ sleep?: (ms: number) => Promise<void>;
12
+ /** Share one slot of {@link EDGE_DNS_WRITE_CONCURRENCY} with the other edge adapters: edge counts them all. */
13
+ writeSlot?: WriteSlot;
14
+ }
15
+ /**
16
+ * {@link WorkersPort} over edge's `platform.workers`. The host fetches the
17
+ * artifact (`fetchArtifact`); this sends its base64 bytes and the sha256 to
18
+ * edge, which checks the digest again. The bytes are never kept, logged or
19
+ * put into an error here.
20
+ *
21
+ * Edge refuses to touch a live script or route that has no edge row (an upload
22
+ * of a live name answers `exists`, a delete answers `not_found`). So the
23
+ * adapter adopts one the moment it is about to be uploaded over or deleted:
24
+ * it lists the name (or the zone's routes), and adopts only a view whose
25
+ * `owner` is `null`. Adopt is idempotent and the list reports the owner
26
+ * afterwards, so nothing is recorded in the plan. A `skipped` route is an
27
+ * error. `scriptExists` and `findRoute` only read: a foreign script or route
28
+ * foundry refuses to overwrite is never adopted. A 404 on a delete means gone.
29
+ * A `rate_limited` or `busy` answer is retried.
30
+ */
31
+ export declare function createEdgeWorkers(client: EdgePlatformWorkers, options: EdgeWorkersOptions): WorkersPort;