@astrale-os/adapter-cloudflare 0.5.0-beta.176 → 0.5.0-beta.178

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 (100) hide show
  1. package/README.md +119 -78
  2. package/dist/adapter.d.ts +6 -5
  3. package/dist/adapter.js +8 -8
  4. package/dist/build/index.d.ts +1 -0
  5. package/dist/build/index.js +1 -0
  6. package/dist/hosting/activation.d.ts +37 -0
  7. package/dist/hosting/activation.js +108 -0
  8. package/dist/hosting/assets.d.ts +14 -0
  9. package/dist/hosting/assets.js +53 -0
  10. package/dist/hosting/bounded.d.ts +10 -0
  11. package/dist/hosting/bounded.js +32 -0
  12. package/dist/hosting/bytes.d.ts +6 -0
  13. package/dist/hosting/bytes.js +25 -0
  14. package/dist/hosting/client.d.ts +144 -0
  15. package/dist/hosting/client.js +491 -0
  16. package/dist/hosting/dispatcher/cron.d.mts +10 -0
  17. package/dist/hosting/dispatcher/cron.mjs +135 -0
  18. package/dist/hosting/dispatcher/deployments.d.mts +12 -0
  19. package/dist/hosting/dispatcher/deployments.mjs +164 -0
  20. package/dist/hosting/dispatcher/dispatcher.d.mts +43 -0
  21. package/dist/hosting/dispatcher/dispatcher.mjs +96 -0
  22. package/dist/hosting/dispatcher/env.d.mts +51 -0
  23. package/dist/hosting/dispatcher/failure.d.mts +4 -0
  24. package/dist/hosting/dispatcher/failure.mjs +15 -0
  25. package/dist/hosting/dispatcher/index.d.mts +4 -0
  26. package/dist/hosting/dispatcher/index.mjs +10 -0
  27. package/dist/hosting/dispatcher/modules.d.ts +25 -0
  28. package/dist/hosting/dispatcher/modules.js +56 -0
  29. package/dist/hosting/dispatcher/services.d.mts +28 -0
  30. package/dist/hosting/dispatcher/services.mjs +231 -0
  31. package/dist/hosting/identity.d.ts +15 -0
  32. package/dist/hosting/identity.js +36 -0
  33. package/dist/hosting/index.d.ts +34 -0
  34. package/dist/hosting/index.js +32 -0
  35. package/dist/hosting/kv.d.ts +89 -0
  36. package/dist/hosting/kv.js +206 -0
  37. package/dist/hosting/listing.d.ts +34 -0
  38. package/dist/hosting/listing.js +50 -0
  39. package/dist/hosting/names.d.ts +16 -0
  40. package/dist/hosting/names.js +28 -0
  41. package/dist/hosting/provision/account.d.ts +11 -0
  42. package/dist/hosting/provision/account.js +86 -0
  43. package/dist/hosting/provision/dispatcher.d.ts +44 -0
  44. package/dist/hosting/provision/dispatcher.js +101 -0
  45. package/dist/hosting/provision/ingress.d.ts +12 -0
  46. package/dist/hosting/provision/ingress.js +31 -0
  47. package/dist/hosting/provision/plan.d.ts +143 -0
  48. package/dist/hosting/provision/plan.js +246 -0
  49. package/dist/hosting/provision/routing.d.ts +82 -0
  50. package/dist/hosting/provision/routing.js +226 -0
  51. package/dist/hosting/readiness.d.ts +53 -0
  52. package/dist/hosting/readiness.js +82 -0
  53. package/dist/hosting/refusal.d.ts +28 -0
  54. package/dist/hosting/refusal.js +13 -0
  55. package/dist/hosting/retirement.d.ts +69 -0
  56. package/dist/hosting/retirement.js +106 -0
  57. package/dist/hosting/retry-after.d.ts +6 -0
  58. package/dist/hosting/retry-after.js +55 -0
  59. package/dist/hosting/script.d.ts +186 -0
  60. package/dist/hosting/script.js +324 -0
  61. package/dist/hosting/testing/fake-cloudflare.d.ts +211 -0
  62. package/dist/hosting/testing/fake-cloudflare.js +756 -0
  63. package/dist/hosting/testing/index.d.ts +6 -0
  64. package/dist/hosting/testing/index.js +6 -0
  65. package/dist/hosting/time.d.ts +4 -0
  66. package/dist/hosting/time.js +20 -0
  67. package/dist/identity/material.d.ts +0 -1
  68. package/dist/identity/material.js +1 -10
  69. package/dist/legacy/direct/index.d.ts +5 -9
  70. package/dist/legacy/direct/index.js +5 -9
  71. package/dist/namespace/configure.d.ts +2 -6
  72. package/dist/namespace/configure.js +15 -12
  73. package/dist/namespace/coordinates.d.ts +3 -26
  74. package/dist/namespace/coordinates.js +13 -45
  75. package/dist/namespace/deploy-release.d.ts +23 -30
  76. package/dist/namespace/deploy-release.js +123 -153
  77. package/dist/namespace/hosting.d.ts +29 -0
  78. package/dist/namespace/hosting.js +120 -0
  79. package/dist/namespace/index.d.ts +7 -10
  80. package/dist/namespace/index.js +7 -10
  81. package/dist/namespace/list.d.ts +11 -26
  82. package/dist/namespace/list.js +49 -125
  83. package/dist/namespace/readiness.d.ts +10 -20
  84. package/dist/namespace/readiness.js +15 -65
  85. package/dist/namespace/record.d.ts +21 -0
  86. package/dist/namespace/record.js +53 -0
  87. package/dist/release/index.d.ts +0 -1
  88. package/dist/release/index.js +0 -1
  89. package/package.json +11 -7
  90. package/template/astrale.config.ts +10 -7
  91. package/dist/namespace/api.d.ts +0 -71
  92. package/dist/namespace/api.js +0 -265
  93. package/dist/namespace/calls.d.ts +0 -29
  94. package/dist/namespace/calls.js +0 -104
  95. package/dist/namespace/routing.d.ts +0 -55
  96. package/dist/namespace/routing.js +0 -165
  97. package/dist/namespace/script.d.ts +0 -106
  98. package/dist/namespace/script.js +0 -278
  99. package/dist/release/staged-worker-name.d.ts +0 -3
  100. package/dist/release/staged-worker-name.js +0 -10
@@ -0,0 +1,144 @@
1
+ /** Cloudflare's REST API. The kit calls it directly: Wrangler has no create-only upload. */
2
+ export declare const CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4";
3
+ /**
4
+ * How the client waits out Cloudflare rate limits (HTTP 429) before failing as `unavailable`.
5
+ * Cloudflare refuses a rate-limited request before acting on it, so resending is always safe.
6
+ */
7
+ export interface RateLimitPolicy {
8
+ /** Rate-limited requests resent, each after the provider's `Retry-After` (else 1 s, doubling). */
9
+ readonly retries: number;
10
+ /** Longest single wait; a later `Retry-After` fails at once as `unavailable`. */
11
+ readonly maximumWaitMs: number;
12
+ }
13
+ /** A deploy's policy: three resends, none waiting more than 10 s. */
14
+ export declare const DEFAULT_RATE_LIMIT: RateLimitPolicy;
15
+ /**
16
+ * An operator command's policy: it has no deadline, so it waits out the five minutes Cloudflare
17
+ * blocks a user (the token owner) that went over its API limit.
18
+ */
19
+ export declare const OPERATOR_RATE_LIMIT: RateLimitPolicy;
20
+ export interface CloudflareClientOptions {
21
+ readonly accountId: string;
22
+ /** The API token, sent only to `base`; never logged, never part of an error. */
23
+ readonly token: string;
24
+ /** Default: the global `fetch`. With another one, `base` may name any host (a test fake). */
25
+ readonly fetch?: typeof fetch;
26
+ /** Default `CLOUDFLARE_API_BASE`; without `fetch`, only api.cloudflare.com or a loopback host. */
27
+ readonly base?: string;
28
+ /** Cancels every request and wait; a cancelled call rejects with the signal's reason. */
29
+ readonly signal?: AbortSignal;
30
+ /** An absolute instant (epoch ms) no request or rate-limit wait outlives. */
31
+ readonly deadline?: number;
32
+ /** Per-request timeout; multipart uploads get at least 120 s. Default 30 s. */
33
+ readonly timeoutMs?: number;
34
+ /** Default `DEFAULT_RATE_LIMIT`; `false` fails a rate-limited request at once. */
35
+ readonly rateLimit?: RateLimitPolicy | false;
36
+ /** Wait between rate-limited attempts; replaceable in tests. */
37
+ readonly pause?: (ms: number, signal?: AbortSignal) => Promise<void>;
38
+ /** The clock `Retry-After` dates and the deadline are read on. */
39
+ readonly now?: () => number;
40
+ }
41
+ /** What a failure says about the request: whether to fix it, retry it, or treat it as absent. */
42
+ export type CloudflareFailureKind =
43
+ /** 404: the resource does not exist. */
44
+ 'not_found'
45
+ /** 401/403: the token lacks the permission. */
46
+ | 'unauthorized'
47
+ /** Another 4xx: the request is refused as it is (412 for a create-only upload over a script). */
48
+ | 'invalid'
49
+ /** 5xx, 429, a network failure or a timeout: the request may or may not have been applied. */
50
+ | 'unavailable'
51
+ /** A success Cloudflare answered in a shape the kit does not admit. */
52
+ | 'upstream';
53
+ /**
54
+ * A Cloudflare API refusal or failure. `status` is the HTTP status (absent when no response came
55
+ * back); `codes` are the error codes of Cloudflare's envelope (10018 for a create-only upload over
56
+ * an existing script). The message names the operation, never an account, script, key or value.
57
+ */
58
+ export declare class CloudflareApiError extends Error {
59
+ readonly name = "CloudflareApiError";
60
+ readonly kind: CloudflareFailureKind;
61
+ readonly status: number | undefined;
62
+ readonly codes: readonly number[];
63
+ constructor(kind: CloudflareFailureKind, message: string, status?: number, codes?: readonly number[], options?: {
64
+ readonly cause?: unknown;
65
+ });
66
+ }
67
+ /** One page of a listing, as Cloudflare's `result_info` reports it. */
68
+ export interface CloudflarePage {
69
+ readonly result: unknown;
70
+ readonly cursor?: string;
71
+ readonly page?: number;
72
+ readonly totalPages?: number;
73
+ }
74
+ /**
75
+ * The Cloudflare account API the hosting kit calls: dispatch namespaces and their scripts, account
76
+ * Workers, Workers KV, zones' DNS records and Worker routes. It runs wherever `fetch` does (Node or
77
+ * a Worker), reads bounded bodies, waits out rate limits by policy and turns every failure into a
78
+ * `CloudflareApiError`. Responses are never followed across redirects.
79
+ */
80
+ export declare class CloudflareClient {
81
+ readonly accountId: string;
82
+ private readonly token;
83
+ private readonly fetcher;
84
+ private readonly base;
85
+ private readonly signal;
86
+ private readonly deadline;
87
+ private readonly timeoutMs;
88
+ private readonly rateLimit;
89
+ private readonly pause;
90
+ private readonly now;
91
+ constructor(options: CloudflareClientOptions);
92
+ /** `/accounts/<account><path>`. */
93
+ account(path: string): string;
94
+ /** `/zones/<zone><path>`. */
95
+ zone(zoneId: string, path: string): string;
96
+ /** Send a JSON request (or none) and return the envelope's `result`. */
97
+ json(method: string, path: string, body?: unknown): Promise<unknown>;
98
+ /** Send an effect whose success may answer an envelope or no body at all. */
99
+ effect(method: string, path: string, body?: unknown): Promise<void>;
100
+ /** One page of a JSON listing, its page evidence admitted. */
101
+ page(path: string): Promise<CloudflarePage>;
102
+ /**
103
+ * Every entry of a cursor-paged listing (`?cursor=`), across all of its pages. `path` may carry a
104
+ * query already. A cursor that repeats fails: the listing would never end.
105
+ */
106
+ cursorList(path: string): Promise<readonly unknown[]>;
107
+ /**
108
+ * Every entry of a numbered listing (`?per_page=&page=`), across all of its pages, stopping at
109
+ * the first page `found` matches (when given). A page that is not the one asked, or a total that
110
+ * changes between pages, fails.
111
+ */
112
+ pagedList(path: string, found?: (entry: unknown) => boolean, perPage?: number): Promise<readonly unknown[]>;
113
+ /**
114
+ * Send a multipart form (a script upload, an asset bucket) and return the envelope's `result`.
115
+ * `bearer` replaces the API token (an asset upload session's token).
116
+ */
117
+ form(method: string, path: string, body: FormData, options?: {
118
+ readonly bearer?: string;
119
+ readonly headers?: Readonly<Record<string, string>>;
120
+ }): Promise<unknown>;
121
+ /**
122
+ * A raw text request, for KV values: the response body as text, or undefined when a GET or a
123
+ * DELETE answers 404. Bodies are bounded to 1 MiB.
124
+ */
125
+ text(method: string, path: string, body?: {
126
+ readonly text: string;
127
+ readonly contentType: string;
128
+ }): Promise<string | undefined>;
129
+ /**
130
+ * A raw GET of bytes, for a KV value whose bytes are its meaning (a deployment record): the
131
+ * response body, or undefined when it answers 404. Bodies are bounded to 1 MiB.
132
+ */
133
+ bytes(path: string): Promise<Uint8Array | undefined>;
134
+ private send;
135
+ private request;
136
+ /** The request timeout, bounded by the deadline and the caller's cancellation. */
137
+ private requestSignal;
138
+ /** The wait before resending a rate-limited request, or undefined when its response stands. */
139
+ private rateLimitWait;
140
+ private read;
141
+ private unavailable;
142
+ }
143
+ /** Whether a failure is Cloudflare saying the resource does not exist. */
144
+ export declare function isNotFound(cause: unknown): boolean;
@@ -0,0 +1,491 @@
1
+ import { retryAfterMs } from './retry-after.js';
2
+ import { wait } from './time.js';
3
+ /** Cloudflare's REST API. The kit calls it directly: Wrangler has no create-only upload. */
4
+ export const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4';
5
+ const DEFAULT_TIMEOUT_MS = 30_000;
6
+ /** A multipart upload (a script, an asset bucket) may take longer than any other call. */
7
+ const FORM_TIMEOUT_FLOOR_MS = 120_000;
8
+ const MAXIMUM_JSON_RESPONSE_BYTES = 8 * 1024 * 1024;
9
+ const MAXIMUM_TEXT_RESPONSE_BYTES = 1024 * 1024;
10
+ /** Without `Retry-After`, a rate-limited resend backs off from one second, doubling. */
11
+ const RATE_LIMIT_BACKOFF_MS = 1_000;
12
+ /** The most entries one page of a numbered listing asks for, as Cloudflare caps KV namespaces. */
13
+ const PAGE_SIZE = 1_000;
14
+ /** A deploy's policy: three resends, none waiting more than 10 s. */
15
+ export const DEFAULT_RATE_LIMIT = Object.freeze({
16
+ retries: 3,
17
+ maximumWaitMs: 10_000,
18
+ });
19
+ /**
20
+ * An operator command's policy: it has no deadline, so it waits out the five minutes Cloudflare
21
+ * blocks a user (the token owner) that went over its API limit.
22
+ */
23
+ export const OPERATOR_RATE_LIMIT = Object.freeze({
24
+ retries: 5,
25
+ maximumWaitMs: 300_000,
26
+ });
27
+ /**
28
+ * A Cloudflare API refusal or failure. `status` is the HTTP status (absent when no response came
29
+ * back); `codes` are the error codes of Cloudflare's envelope (10018 for a create-only upload over
30
+ * an existing script). The message names the operation, never an account, script, key or value.
31
+ */
32
+ export class CloudflareApiError extends Error {
33
+ name = 'CloudflareApiError';
34
+ kind;
35
+ status;
36
+ codes;
37
+ constructor(kind, message, status, codes = [], options) {
38
+ super(message, options);
39
+ this.kind = kind;
40
+ this.status = status;
41
+ this.codes = Object.freeze([...codes]);
42
+ }
43
+ }
44
+ /**
45
+ * The Cloudflare account API the hosting kit calls: dispatch namespaces and their scripts, account
46
+ * Workers, Workers KV, zones' DNS records and Worker routes. It runs wherever `fetch` does (Node or
47
+ * a Worker), reads bounded bodies, waits out rate limits by policy and turns every failure into a
48
+ * `CloudflareApiError`. Responses are never followed across redirects.
49
+ */
50
+ export class CloudflareClient {
51
+ accountId;
52
+ token;
53
+ fetcher;
54
+ base;
55
+ signal;
56
+ deadline;
57
+ timeoutMs;
58
+ rateLimit;
59
+ pause;
60
+ now;
61
+ constructor(options) {
62
+ if (!stable(options.accountId))
63
+ invalid('Cloudflare accountId is required.');
64
+ if (!stable(options.token)) {
65
+ throw new CloudflareApiError('unauthorized', 'Cloudflare API token is required.');
66
+ }
67
+ this.accountId = options.accountId;
68
+ this.token = options.token;
69
+ this.base = (options.base ?? CLOUDFLARE_API_BASE).replace(/\/+$/u, '');
70
+ if (options.fetch === undefined && !allowedHost(this.base)) {
71
+ invalid('Cloudflare API base must be api.cloudflare.com or a loopback host.');
72
+ }
73
+ this.fetcher = options.fetch ?? ((input, init) => fetch(input, init));
74
+ this.signal = options.signal;
75
+ this.deadline = options.deadline;
76
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
77
+ if (!positiveInteger(this.timeoutMs))
78
+ invalid('Cloudflare timeoutMs must be positive.');
79
+ const rateLimit = options.rateLimit ?? DEFAULT_RATE_LIMIT;
80
+ if (rateLimit !== false &&
81
+ (!Number.isSafeInteger(rateLimit.retries) ||
82
+ rateLimit.retries < 0 ||
83
+ !positiveInteger(rateLimit.maximumWaitMs))) {
84
+ invalid('Cloudflare rateLimit needs non-negative retries and a positive maximumWaitMs.');
85
+ }
86
+ this.rateLimit = rateLimit;
87
+ this.pause = options.pause ?? wait;
88
+ this.now = options.now ?? Date.now;
89
+ }
90
+ /** `/accounts/<account><path>`. */
91
+ account(path) {
92
+ return `/accounts/${encodeURIComponent(this.accountId)}${path}`;
93
+ }
94
+ /** `/zones/<zone><path>`. */
95
+ zone(zoneId, path) {
96
+ if (!stable(zoneId))
97
+ invalid('Cloudflare zone id is required.');
98
+ return `/zones/${encodeURIComponent(zoneId)}${path}`;
99
+ }
100
+ /** Send a JSON request (or none) and return the envelope's `result`. */
101
+ async json(method, path, body) {
102
+ return (await this.send(method, path, jsonBody(body))).result;
103
+ }
104
+ /** Send an effect whose success may answer an envelope or no body at all. */
105
+ async effect(method, path, body) {
106
+ await this.send(method, path, jsonBody(body), true);
107
+ }
108
+ /** One page of a JSON listing, its page evidence admitted. */
109
+ async page(path) {
110
+ const envelope = await this.send('GET', path, {});
111
+ const info = envelope.resultInfo;
112
+ if (info !== undefined && info !== null && !isRecord(info))
113
+ upstream('page evidence');
114
+ const cursor = isRecord(info) ? info.cursor : undefined;
115
+ const page = isRecord(info) ? info.page : undefined;
116
+ const totalPages = isRecord(info) ? info.total_pages : undefined;
117
+ if (cursor !== undefined && cursor !== null && typeof cursor !== 'string') {
118
+ upstream('page cursor');
119
+ }
120
+ if (page !== undefined && !positiveInteger(page))
121
+ upstream('page evidence');
122
+ if (totalPages !== undefined && !Number.isSafeInteger(totalPages))
123
+ upstream('page evidence');
124
+ return Object.freeze({
125
+ result: envelope.result,
126
+ ...(typeof cursor === 'string' && cursor.length > 0 ? { cursor } : {}),
127
+ ...(page === undefined ? {} : { page: page }),
128
+ ...(totalPages === undefined ? {} : { totalPages: totalPages }),
129
+ });
130
+ }
131
+ /**
132
+ * Every entry of a cursor-paged listing (`?cursor=`), across all of its pages. `path` may carry a
133
+ * query already. A cursor that repeats fails: the listing would never end.
134
+ */
135
+ async cursorList(path) {
136
+ const entries = [];
137
+ const seen = new Set();
138
+ let cursor;
139
+ for (;;) {
140
+ const separator = path.includes('?') ? '&' : '?';
141
+ const page = await this.page(cursor === undefined ? path : `${path}${separator}cursor=${encodeURIComponent(cursor)}`);
142
+ if (!Array.isArray(page.result))
143
+ upstream('listing');
144
+ entries.push(...page.result);
145
+ if (page.cursor === undefined)
146
+ return Object.freeze(entries);
147
+ if (seen.has(page.cursor)) {
148
+ throw new CloudflareApiError('upstream', 'Cloudflare repeated a listing cursor.');
149
+ }
150
+ seen.add(page.cursor);
151
+ cursor = page.cursor;
152
+ }
153
+ }
154
+ /**
155
+ * Every entry of a numbered listing (`?per_page=&page=`), across all of its pages, stopping at
156
+ * the first page `found` matches (when given). A page that is not the one asked, or a total that
157
+ * changes between pages, fails.
158
+ */
159
+ async pagedList(path, found, perPage = PAGE_SIZE) {
160
+ const entries = [];
161
+ const separator = path.includes('?') ? '&' : '?';
162
+ let total;
163
+ for (let number = 1;; number += 1) {
164
+ const page = await this.page(`${path}${separator}per_page=${perPage}&page=${number}`);
165
+ if (!Array.isArray(page.result))
166
+ upstream('listing');
167
+ if ((page.page !== undefined && page.page !== number) ||
168
+ (total !== undefined && page.totalPages !== undefined && page.totalPages !== total)) {
169
+ upstream('page evidence');
170
+ }
171
+ total = page.totalPages ?? total;
172
+ entries.push(...page.result);
173
+ if (found !== undefined && page.result.some(found))
174
+ return Object.freeze(entries);
175
+ if (page.totalPages === undefined || number >= page.totalPages) {
176
+ return Object.freeze(entries);
177
+ }
178
+ }
179
+ }
180
+ /**
181
+ * Send a multipart form (a script upload, an asset bucket) and return the envelope's `result`.
182
+ * `bearer` replaces the API token (an asset upload session's token).
183
+ */
184
+ async form(method, path, body, options = {}) {
185
+ return (await this.send(method, path, {
186
+ body,
187
+ ...(options.bearer === undefined ? {} : { bearer: options.bearer }),
188
+ ...(options.headers === undefined ? {} : { headers: options.headers }),
189
+ timeoutMs: Math.max(this.timeoutMs, FORM_TIMEOUT_FLOOR_MS),
190
+ })).result;
191
+ }
192
+ /**
193
+ * A raw text request, for KV values: the response body as text, or undefined when a GET or a
194
+ * DELETE answers 404. Bodies are bounded to 1 MiB.
195
+ */
196
+ async text(method, path, body) {
197
+ const response = await this.request(method, path, body === undefined ? {} : { body: body.text, headers: { 'content-type': body.contentType } });
198
+ if (response.status === 404 && (method === 'GET' || method === 'DELETE')) {
199
+ await discard(response);
200
+ return undefined;
201
+ }
202
+ const text = await this.read(response, MAXIMUM_TEXT_RESPONSE_BYTES, method, path);
203
+ if (!response.ok)
204
+ throw failure(method, path, response.status, decodeEnvelope(text));
205
+ return text;
206
+ }
207
+ /**
208
+ * A raw GET of bytes, for a KV value whose bytes are its meaning (a deployment record): the
209
+ * response body, or undefined when it answers 404. Bodies are bounded to 1 MiB.
210
+ */
211
+ async bytes(path) {
212
+ const response = await this.request('GET', path, {});
213
+ if (response.status === 404) {
214
+ await discard(response);
215
+ return undefined;
216
+ }
217
+ let bytes;
218
+ try {
219
+ bytes = await boundedBytes(response, MAXIMUM_TEXT_RESPONSE_BYTES);
220
+ }
221
+ catch (cause) {
222
+ if (cause instanceof CloudflareApiError)
223
+ throw cause;
224
+ this.unavailable('GET', path, cause);
225
+ }
226
+ if (!response.ok) {
227
+ throw failure('GET', path, response.status, decodeEnvelope(new TextDecoder().decode(bytes)));
228
+ }
229
+ return bytes;
230
+ }
231
+ async send(method, path, input, allowEmptySuccess = false) {
232
+ const response = await this.request(method, path, input);
233
+ const text = await this.read(response, MAXIMUM_JSON_RESPONSE_BYTES, method, path);
234
+ const envelope = decodeEnvelope(text);
235
+ if (!response.ok || envelope?.success === false) {
236
+ throw failure(method, path, response.status, envelope);
237
+ }
238
+ if (envelope === undefined) {
239
+ if (allowEmptySuccess && text.length === 0) {
240
+ return { success: true, errors: [], result: null };
241
+ }
242
+ throw new CloudflareApiError('upstream', `Cloudflare ${method} ${describe(path)} returned no JSON envelope.`, response.status);
243
+ }
244
+ return envelope;
245
+ }
246
+ async request(method, path, input) {
247
+ if (!path.startsWith('/accounts/') && !path.startsWith('/zones')) {
248
+ throw new TypeError('Cloudflare API path is invalid.');
249
+ }
250
+ for (let attempt = 0;; attempt += 1) {
251
+ this.signal?.throwIfAborted();
252
+ let response;
253
+ try {
254
+ response = await this.fetcher(`${this.base}${path}`, {
255
+ method,
256
+ headers: { authorization: `Bearer ${input.bearer ?? this.token}`, ...input.headers },
257
+ ...(input.body === undefined ? {} : { body: input.body }),
258
+ // Workers admit only `follow` and `manual`; a redirect is a failure either way.
259
+ redirect: 'manual',
260
+ signal: this.requestSignal(input.timeoutMs ?? this.timeoutMs),
261
+ });
262
+ }
263
+ catch (cause) {
264
+ this.unavailable(method, path, cause);
265
+ }
266
+ const waitMs = this.rateLimitWait(response, attempt);
267
+ if (waitMs === undefined)
268
+ return response;
269
+ await discard(response);
270
+ try {
271
+ await this.pause(waitMs, this.signal);
272
+ }
273
+ catch (cause) {
274
+ this.unavailable(method, path, cause);
275
+ }
276
+ }
277
+ }
278
+ /** The request timeout, bounded by the deadline and the caller's cancellation. */
279
+ requestSignal(timeoutMs) {
280
+ const bounded = this.deadline === undefined
281
+ ? timeoutMs
282
+ : Math.min(timeoutMs, Math.max(1, Math.ceil(this.deadline - this.now())));
283
+ const timeout = AbortSignal.timeout(bounded);
284
+ return this.signal === undefined ? timeout : AbortSignal.any([this.signal, timeout]);
285
+ }
286
+ /** The wait before resending a rate-limited request, or undefined when its response stands. */
287
+ rateLimitWait(response, attempt) {
288
+ const policy = this.rateLimit;
289
+ if (response.status !== 429 || policy === false || attempt >= policy.retries)
290
+ return undefined;
291
+ const waitMs = retryAfterMs(response.headers.get('retry-after'), this.now()) ??
292
+ Math.min(policy.maximumWaitMs, RATE_LIMIT_BACKOFF_MS * 2 ** attempt);
293
+ if (waitMs > policy.maximumWaitMs)
294
+ return undefined;
295
+ if (this.deadline !== undefined && this.now() + waitMs >= this.deadline)
296
+ return undefined;
297
+ return waitMs;
298
+ }
299
+ async read(response, maximumBytes, method, path) {
300
+ try {
301
+ return await boundedText(response, maximumBytes);
302
+ }
303
+ catch (cause) {
304
+ if (cause instanceof CloudflareApiError)
305
+ throw cause;
306
+ this.unavailable(method, path, cause);
307
+ }
308
+ }
309
+ unavailable(method, path, cause) {
310
+ if (this.signal?.aborted === true)
311
+ throw this.signal.reason ?? cause;
312
+ throw new CloudflareApiError('unavailable', `Cloudflare ${method} ${describe(path)} is unavailable.`, undefined, [], { cause });
313
+ }
314
+ }
315
+ /** Whether a failure is Cloudflare saying the resource does not exist. */
316
+ export function isNotFound(cause) {
317
+ return cause instanceof CloudflareApiError && cause.kind === 'not_found';
318
+ }
319
+ function jsonBody(body) {
320
+ return body === undefined
321
+ ? {}
322
+ : { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } };
323
+ }
324
+ function decodeEnvelope(text) {
325
+ let value;
326
+ try {
327
+ value = JSON.parse(text);
328
+ }
329
+ catch {
330
+ return undefined;
331
+ }
332
+ if (!isRecord(value) || typeof value.success !== 'boolean')
333
+ return undefined;
334
+ const errors = value.errors;
335
+ if (errors !== undefined && errors !== null && !Array.isArray(errors))
336
+ return undefined;
337
+ const admitted = [];
338
+ for (const error of errors ?? []) {
339
+ if (!isRecord(error))
340
+ return undefined;
341
+ admitted.push({ code: error.code, message: error.message });
342
+ }
343
+ return Object.freeze({
344
+ success: value.success,
345
+ errors: Object.freeze(admitted),
346
+ result: value.result,
347
+ ...(Object.hasOwn(value, 'result_info') ? { resultInfo: value.result_info } : {}),
348
+ });
349
+ }
350
+ /** A Cloudflare failure, naming its error codes and messages but never a request body. */
351
+ function failure(method, path, status, envelope) {
352
+ const errors = envelope?.errors ?? [];
353
+ const codes = errors
354
+ .map((error) => error.code)
355
+ .filter((code) => typeof code === 'number');
356
+ const detail = errors
357
+ .map((error) => error.message)
358
+ .filter((message) => typeof message === 'string')
359
+ .map((message) => (message.length <= 200 ? message : `${message.slice(0, 197)}...`))
360
+ .join('; ')
361
+ .replace(/\.$/u, '');
362
+ return new CloudflareApiError(kindOf(status), `Cloudflare ${method} ${describe(path)} failed with HTTP ${status}` +
363
+ `${codes.length > 0 ? ` [${codes.join(', ')}]` : ''}${detail === '' ? '' : `: ${detail}`}.`, status, codes);
364
+ }
365
+ function kindOf(status) {
366
+ if (status === 401 || status === 403)
367
+ return 'unauthorized';
368
+ if (status === 404)
369
+ return 'not_found';
370
+ // A rate limit refuses the caller's pace, never the request: retrying later succeeds.
371
+ if (status === 429 || status >= 500)
372
+ return 'unavailable';
373
+ if (status >= 400)
374
+ return 'invalid';
375
+ return 'upstream';
376
+ }
377
+ /** What a path addresses, without its account, zone, namespace, script or key names. */
378
+ function describe(path) {
379
+ const route = path.split('?', 1)[0];
380
+ if (route.includes('/assets-upload-session'))
381
+ return 'asset upload session';
382
+ if (route.includes('/workers/assets/upload'))
383
+ return 'asset upload';
384
+ if (route.endsWith('/secrets'))
385
+ return 'script secret';
386
+ if (route.endsWith('/settings'))
387
+ return 'script settings';
388
+ if (route.endsWith('/subdomain'))
389
+ return 'workers.dev subdomain';
390
+ if (route.endsWith('/schedules'))
391
+ return 'script schedules';
392
+ if (route.includes('/dispatch/namespaces/') && route.includes('/scripts')) {
393
+ return 'namespace script';
394
+ }
395
+ if (route.includes('/dispatch/namespaces'))
396
+ return 'dispatch namespace';
397
+ if (route.includes('/workers/scripts'))
398
+ return 'Worker';
399
+ if (route.includes('/workers/domains'))
400
+ return 'Workers custom domain';
401
+ if (/\/storage\/kv\/namespaces\/[^/]+\/keys/u.test(route))
402
+ return 'KV keys';
403
+ if (/\/storage\/kv\/namespaces\/[^/]+\/values/u.test(route))
404
+ return 'KV value';
405
+ if (route.includes('/storage/kv/namespaces'))
406
+ return 'KV namespace';
407
+ if (route.includes('/dns_records'))
408
+ return 'DNS record';
409
+ if (route.includes('/workers/routes'))
410
+ return 'Worker route';
411
+ if (route.startsWith('/zones'))
412
+ return 'zone';
413
+ return 'request';
414
+ }
415
+ async function boundedText(response, maximumBytes) {
416
+ return new TextDecoder().decode(await boundedBytes(response, maximumBytes));
417
+ }
418
+ async function boundedBytes(response, maximumBytes) {
419
+ const declared = response.headers.get('content-length');
420
+ if (declared !== null) {
421
+ const bytes = Number(declared);
422
+ if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > maximumBytes) {
423
+ await discard(response);
424
+ throw new CloudflareApiError('upstream', 'Cloudflare response body is too large.');
425
+ }
426
+ }
427
+ if (response.body === null)
428
+ return new Uint8Array();
429
+ const reader = response.body.getReader();
430
+ const parts = [];
431
+ let size = 0;
432
+ for (;;) {
433
+ const part = await reader.read();
434
+ if (part.done)
435
+ break;
436
+ size += part.value.byteLength;
437
+ if (size > maximumBytes) {
438
+ try {
439
+ await reader.cancel('Cloudflare response body is too large.');
440
+ }
441
+ catch {
442
+ // Cancellation is cleanup; it cannot replace the overflow failure.
443
+ }
444
+ throw new CloudflareApiError('upstream', 'Cloudflare response body is too large.');
445
+ }
446
+ parts.push(part.value);
447
+ }
448
+ const bytes = new Uint8Array(size);
449
+ let offset = 0;
450
+ for (const part of parts) {
451
+ bytes.set(part, offset);
452
+ offset += part.byteLength;
453
+ }
454
+ return bytes;
455
+ }
456
+ function allowedHost(base) {
457
+ try {
458
+ const host = new URL(base).hostname.toLowerCase();
459
+ return (host === 'api.cloudflare.com' ||
460
+ host === 'localhost' ||
461
+ host === '127.0.0.1' ||
462
+ host === '[::1]' ||
463
+ host.endsWith('.localhost'));
464
+ }
465
+ catch {
466
+ return false;
467
+ }
468
+ }
469
+ async function discard(response) {
470
+ try {
471
+ await response.body?.cancel();
472
+ }
473
+ catch {
474
+ // Releasing a response is cleanup; it never decides an outcome.
475
+ }
476
+ }
477
+ function isRecord(value) {
478
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
479
+ }
480
+ function stable(value) {
481
+ return typeof value === 'string' && value.length > 0 && value.trim() === value;
482
+ }
483
+ function positiveInteger(value) {
484
+ return Number.isSafeInteger(value) && value > 0;
485
+ }
486
+ function invalid(message) {
487
+ throw new CloudflareApiError('invalid', message);
488
+ }
489
+ function upstream(what) {
490
+ throw new CloudflareApiError('upstream', `Cloudflare returned invalid ${what}.`);
491
+ }
@@ -0,0 +1,10 @@
1
+ export function isUtcCron(expression: unknown): expression is string
2
+ export function canonicalUtcCrons(input: unknown): readonly string[] | undefined
3
+ export function matches(expression: string, date: Date): boolean
4
+ export function fieldMatches(
5
+ field: string,
6
+ value: number,
7
+ minimum: number,
8
+ maximum: number,
9
+ names?: Readonly<Record<string, number>>,
10
+ ): boolean