@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.
- package/README.md +119 -78
- package/dist/adapter.d.ts +6 -5
- package/dist/adapter.js +8 -8
- package/dist/build/index.d.ts +1 -0
- package/dist/build/index.js +1 -0
- package/dist/hosting/activation.d.ts +37 -0
- package/dist/hosting/activation.js +108 -0
- package/dist/hosting/assets.d.ts +14 -0
- package/dist/hosting/assets.js +53 -0
- package/dist/hosting/bounded.d.ts +10 -0
- package/dist/hosting/bounded.js +32 -0
- package/dist/hosting/bytes.d.ts +6 -0
- package/dist/hosting/bytes.js +25 -0
- package/dist/hosting/client.d.ts +144 -0
- package/dist/hosting/client.js +491 -0
- package/dist/hosting/dispatcher/cron.d.mts +10 -0
- package/dist/hosting/dispatcher/cron.mjs +135 -0
- package/dist/hosting/dispatcher/deployments.d.mts +12 -0
- package/dist/hosting/dispatcher/deployments.mjs +164 -0
- package/dist/hosting/dispatcher/dispatcher.d.mts +43 -0
- package/dist/hosting/dispatcher/dispatcher.mjs +96 -0
- package/dist/hosting/dispatcher/env.d.mts +51 -0
- package/dist/hosting/dispatcher/failure.d.mts +4 -0
- package/dist/hosting/dispatcher/failure.mjs +15 -0
- package/dist/hosting/dispatcher/index.d.mts +4 -0
- package/dist/hosting/dispatcher/index.mjs +10 -0
- package/dist/hosting/dispatcher/modules.d.ts +25 -0
- package/dist/hosting/dispatcher/modules.js +56 -0
- package/dist/hosting/dispatcher/services.d.mts +28 -0
- package/dist/hosting/dispatcher/services.mjs +231 -0
- package/dist/hosting/identity.d.ts +15 -0
- package/dist/hosting/identity.js +36 -0
- package/dist/hosting/index.d.ts +34 -0
- package/dist/hosting/index.js +32 -0
- package/dist/hosting/kv.d.ts +89 -0
- package/dist/hosting/kv.js +206 -0
- package/dist/hosting/listing.d.ts +34 -0
- package/dist/hosting/listing.js +50 -0
- package/dist/hosting/names.d.ts +16 -0
- package/dist/hosting/names.js +28 -0
- package/dist/hosting/provision/account.d.ts +11 -0
- package/dist/hosting/provision/account.js +86 -0
- package/dist/hosting/provision/dispatcher.d.ts +44 -0
- package/dist/hosting/provision/dispatcher.js +101 -0
- package/dist/hosting/provision/ingress.d.ts +12 -0
- package/dist/hosting/provision/ingress.js +31 -0
- package/dist/hosting/provision/plan.d.ts +143 -0
- package/dist/hosting/provision/plan.js +246 -0
- package/dist/hosting/provision/routing.d.ts +82 -0
- package/dist/hosting/provision/routing.js +226 -0
- package/dist/hosting/readiness.d.ts +53 -0
- package/dist/hosting/readiness.js +82 -0
- package/dist/hosting/refusal.d.ts +28 -0
- package/dist/hosting/refusal.js +13 -0
- package/dist/hosting/retirement.d.ts +69 -0
- package/dist/hosting/retirement.js +106 -0
- package/dist/hosting/retry-after.d.ts +6 -0
- package/dist/hosting/retry-after.js +55 -0
- package/dist/hosting/script.d.ts +186 -0
- package/dist/hosting/script.js +324 -0
- package/dist/hosting/testing/fake-cloudflare.d.ts +211 -0
- package/dist/hosting/testing/fake-cloudflare.js +756 -0
- package/dist/hosting/testing/index.d.ts +6 -0
- package/dist/hosting/testing/index.js +6 -0
- package/dist/hosting/time.d.ts +4 -0
- package/dist/hosting/time.js +20 -0
- package/dist/identity/material.d.ts +0 -1
- package/dist/identity/material.js +1 -10
- package/dist/legacy/direct/index.d.ts +5 -9
- package/dist/legacy/direct/index.js +5 -9
- package/dist/namespace/configure.d.ts +2 -6
- package/dist/namespace/configure.js +15 -12
- package/dist/namespace/coordinates.d.ts +3 -26
- package/dist/namespace/coordinates.js +13 -45
- package/dist/namespace/deploy-release.d.ts +23 -30
- package/dist/namespace/deploy-release.js +123 -153
- package/dist/namespace/hosting.d.ts +29 -0
- package/dist/namespace/hosting.js +120 -0
- package/dist/namespace/index.d.ts +7 -10
- package/dist/namespace/index.js +7 -10
- package/dist/namespace/list.d.ts +11 -26
- package/dist/namespace/list.js +49 -125
- package/dist/namespace/readiness.d.ts +10 -20
- package/dist/namespace/readiness.js +15 -65
- package/dist/namespace/record.d.ts +21 -0
- package/dist/namespace/record.js +53 -0
- package/dist/release/index.d.ts +0 -1
- package/dist/release/index.js +0 -1
- package/package.json +11 -7
- package/template/astrale.config.ts +10 -7
- package/dist/namespace/api.d.ts +0 -71
- package/dist/namespace/api.js +0 -265
- package/dist/namespace/calls.d.ts +0 -29
- package/dist/namespace/calls.js +0 -104
- package/dist/namespace/routing.d.ts +0 -55
- package/dist/namespace/routing.js +0 -165
- package/dist/namespace/script.d.ts +0 -106
- package/dist/namespace/script.js +0 -278
- package/dist/release/staged-worker-name.d.ts +0 -3
- package/dist/release/staged-worker-name.js +0 -10
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { canonicalUtcCrons, matches } from './cron.mjs'
|
|
2
|
+
import { dispatchDeployment, isDeploymentLabel } from './deployments.mjs'
|
|
3
|
+
import { errorText } from './failure.mjs'
|
|
4
|
+
|
|
5
|
+
// @ts-check
|
|
6
|
+
/* eslint-disable no-console -- the cron fan-out reports each schedule event to Workers logs. */
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('./env.mjs').DispatcherEnvironment} DispatcherEnvironment */
|
|
9
|
+
/** @typedef {import('./env.mjs').ScheduleEnvironment} ScheduleEnvironment */
|
|
10
|
+
/** @typedef {import('./env.mjs').DispatchFetcher} DispatchFetcher */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @template {ScheduleEnvironment} Environment
|
|
14
|
+
* @typedef {{
|
|
15
|
+
* scheduleName?: (name: string, environment: Environment) => boolean,
|
|
16
|
+
* scheduleTarget?: (
|
|
17
|
+
* name: string,
|
|
18
|
+
* environment: Environment,
|
|
19
|
+
* ) => Promise<{ fetcher: DispatchFetcher, deployment?: string } | null | undefined>,
|
|
20
|
+
* }} ScheduleExtension
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every name a deployment line gives, as a deployer admits one: the 16-character fingerprint
|
|
25
|
+
* alone when opaque, `<readable>-<fingerprint>` when readable, and never a deployment label.
|
|
26
|
+
*/
|
|
27
|
+
const SERVICE_NAME = /^(?:[a-z0-9]+(?:-[a-z0-9]+)*-)?[a-z2-7]{16}$/u
|
|
28
|
+
/** The longest line a deploy gives. */
|
|
29
|
+
const MAXIMUM_SERVICE_NAME_LENGTH = 40
|
|
30
|
+
const SCHEDULED_TIMEOUT_MS = 10_000
|
|
31
|
+
const FAN_OUT_CONCURRENCY = 16
|
|
32
|
+
|
|
33
|
+
/** @param {string} name */
|
|
34
|
+
export function isServiceName(name) {
|
|
35
|
+
return (
|
|
36
|
+
name.length <= MAXIMUM_SERVICE_NAME_LENGTH &&
|
|
37
|
+
SERVICE_NAME.test(name) &&
|
|
38
|
+
!isDeploymentLabel(name)
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The deployment one name serves now: `current:<name>`, admitted as one of its own labels.
|
|
44
|
+
*
|
|
45
|
+
* @param {Pick<DispatcherEnvironment, 'SCHEDULES'>} environment
|
|
46
|
+
* @param {string} service
|
|
47
|
+
* @returns {Promise<string | null>}
|
|
48
|
+
*/
|
|
49
|
+
async function currentDeployment(environment, service) {
|
|
50
|
+
const label = await environment.SCHEDULES.get(`current:${service}`)
|
|
51
|
+
if (label === null) return null
|
|
52
|
+
if (!isDeploymentLabel(label) || !label.startsWith(`${service}-`)) {
|
|
53
|
+
throw new TypeError('The current deployment of the name is not one of its labels.')
|
|
54
|
+
}
|
|
55
|
+
return label
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Route one request to the deployment a name points at (`current:<name>`), exactly as that
|
|
60
|
+
* deployment's own URL routes it: its record, its tombstone and its call point apply. A name with
|
|
61
|
+
* no current deployment serves nothing.
|
|
62
|
+
*
|
|
63
|
+
* @param {Request} request
|
|
64
|
+
* @param {DispatcherEnvironment} environment
|
|
65
|
+
* @param {string} service
|
|
66
|
+
*/
|
|
67
|
+
export async function dispatchService(request, environment, service) {
|
|
68
|
+
/** @type {string | null} */
|
|
69
|
+
let label
|
|
70
|
+
try {
|
|
71
|
+
label = await currentDeployment(environment, service)
|
|
72
|
+
} catch {
|
|
73
|
+
return new Response('dispatch error', { status: 502 })
|
|
74
|
+
}
|
|
75
|
+
if (label === null) return missingServiceResponse(request, service)
|
|
76
|
+
return dispatchDeployment(request, environment, label)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The answer for a name nothing serves: `/invoke` answers the invocation envelope of an
|
|
81
|
+
* unavailable callee, every other path a plain 404 the ingress probe recognizes.
|
|
82
|
+
*
|
|
83
|
+
* @param {Request} request
|
|
84
|
+
* @param {string} service
|
|
85
|
+
*/
|
|
86
|
+
export function missingServiceResponse(request, service) {
|
|
87
|
+
const headers = { 'cache-control': 'no-store' }
|
|
88
|
+
const url = new URL(request.url)
|
|
89
|
+
if (url.pathname !== '/invoke') {
|
|
90
|
+
return new Response(`no such service: ${service}`, { status: 404, headers })
|
|
91
|
+
}
|
|
92
|
+
return new Response(
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
requestId: null,
|
|
95
|
+
invocation: { source: url.origin, id: 'unavailable' },
|
|
96
|
+
error: { code: 5001, message: 'Service is unavailable.' },
|
|
97
|
+
}),
|
|
98
|
+
{
|
|
99
|
+
status: 503,
|
|
100
|
+
headers: { ...headers, 'content-type': 'application/vnd.astrale+json' },
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fire every cron of `cron:<name>` that matches this minute on the deployment the name serves now
|
|
107
|
+
* (`current:<name>`), while it is active (`record:<label>`) and not retired (`retirement:<label>`),
|
|
108
|
+
* or on the target an extension gives. One name's failure never stops the others.
|
|
109
|
+
*
|
|
110
|
+
* @template {ScheduleEnvironment} Environment
|
|
111
|
+
* @param {{ scheduledTime?: number }} controller
|
|
112
|
+
* @param {Environment} environment
|
|
113
|
+
* @param {ScheduleExtension<Environment>} [extension]
|
|
114
|
+
*/
|
|
115
|
+
export async function fanOutSchedules(controller, environment, extension = {}) {
|
|
116
|
+
const scheduledAt = controller.scheduledTime ?? Date.now()
|
|
117
|
+
const date = new Date(scheduledAt)
|
|
118
|
+
const cursors = new Set()
|
|
119
|
+
/** @type {string | undefined} */
|
|
120
|
+
let cursor
|
|
121
|
+
do {
|
|
122
|
+
const page = await environment.SCHEDULES.list({ prefix: 'cron:', cursor })
|
|
123
|
+
await pool(page.keys, FAN_OUT_CONCURRENCY, ({ name }) =>
|
|
124
|
+
fire(environment, extension, name.slice('cron:'.length), name, date, scheduledAt),
|
|
125
|
+
)
|
|
126
|
+
if (page.list_complete) cursor = undefined
|
|
127
|
+
else {
|
|
128
|
+
const next = page.cursor
|
|
129
|
+
if (typeof next !== 'string' || next.length === 0 || cursors.has(next)) {
|
|
130
|
+
throw new TypeError('Schedule cursor did not advance.')
|
|
131
|
+
}
|
|
132
|
+
cursors.add(next)
|
|
133
|
+
cursor = next
|
|
134
|
+
}
|
|
135
|
+
} while (cursor !== undefined)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Run one bounded sliding-window fan-out.
|
|
140
|
+
*
|
|
141
|
+
* @template Value
|
|
142
|
+
* @param {readonly Value[]} items
|
|
143
|
+
* @param {number} limit
|
|
144
|
+
* @param {(value: Value) => void | Promise<void>} operation
|
|
145
|
+
*/
|
|
146
|
+
export async function pool(items, limit, operation) {
|
|
147
|
+
const iterator = items[Symbol.iterator]()
|
|
148
|
+
const worker = async () => {
|
|
149
|
+
for (let item = iterator.next(); !item.done; item = iterator.next()) {
|
|
150
|
+
await operation(item.value)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @template {ScheduleEnvironment} Environment
|
|
158
|
+
* @param {Environment} environment
|
|
159
|
+
* @param {ScheduleExtension<Environment>} extension
|
|
160
|
+
* @param {string} service
|
|
161
|
+
* @param {string} key
|
|
162
|
+
* @param {Date} date
|
|
163
|
+
* @param {number} scheduledAt
|
|
164
|
+
*/
|
|
165
|
+
async function fire(environment, extension, service, key, date, scheduledAt) {
|
|
166
|
+
if (!isServiceName(service) && extension.scheduleName?.(service, environment) !== true) {
|
|
167
|
+
console.warn(JSON.stringify({ event: 'service.schedule.invalid-service', service }))
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
/** @type {unknown} */
|
|
171
|
+
let crons
|
|
172
|
+
try {
|
|
173
|
+
crons = JSON.parse((await environment.SCHEDULES.get(key)) ?? '[]')
|
|
174
|
+
} catch {
|
|
175
|
+
console.warn(JSON.stringify({ event: 'service.schedule.invalid', service }))
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
const schedules = canonicalUtcCrons(crons)
|
|
179
|
+
if (schedules === undefined) {
|
|
180
|
+
console.warn(JSON.stringify({ event: 'service.schedule.invalid', service }))
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
const cron = schedules.find((value) => matches(value, date))
|
|
184
|
+
if (cron === undefined) return
|
|
185
|
+
try {
|
|
186
|
+
const target = await extension.scheduleTarget?.(service, environment)
|
|
187
|
+
if (target === null) return
|
|
188
|
+
/** @type {DispatchFetcher} */
|
|
189
|
+
let fetcher
|
|
190
|
+
/** @type {string | undefined} */
|
|
191
|
+
let deployment
|
|
192
|
+
if (target !== undefined) {
|
|
193
|
+
fetcher = target.fetcher
|
|
194
|
+
deployment = target.deployment
|
|
195
|
+
} else {
|
|
196
|
+
// A name only the extension admits fires only on a target the extension gives.
|
|
197
|
+
if (!isServiceName(service)) return
|
|
198
|
+
const label = await currentDeployment(environment, service)
|
|
199
|
+
if (label === null) return
|
|
200
|
+
const [retirement, record] = await Promise.all([
|
|
201
|
+
environment.SCHEDULES.get(`retirement:${label}`),
|
|
202
|
+
environment.SCHEDULES.get(`record:${label}`),
|
|
203
|
+
])
|
|
204
|
+
if (retirement !== null || record === null) return
|
|
205
|
+
fetcher = environment.DEPLOYMENTS.get(label)
|
|
206
|
+
deployment = label
|
|
207
|
+
}
|
|
208
|
+
const response = await fetcher.fetch('https://cron.internal/__scheduled', {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: {
|
|
211
|
+
'x-astrale-cron': cron,
|
|
212
|
+
'x-astrale-scheduled-time': String(scheduledAt),
|
|
213
|
+
},
|
|
214
|
+
signal: AbortSignal.timeout(SCHEDULED_TIMEOUT_MS),
|
|
215
|
+
})
|
|
216
|
+
if (!response.ok) {
|
|
217
|
+
console.warn(
|
|
218
|
+
JSON.stringify({
|
|
219
|
+
event: 'service.schedule.rejected',
|
|
220
|
+
service,
|
|
221
|
+
...(deployment === undefined ? {} : { deployment }),
|
|
222
|
+
status: response.status,
|
|
223
|
+
}),
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
} catch (cause) {
|
|
227
|
+
console.error(
|
|
228
|
+
JSON.stringify({ event: 'service.schedule.failed', service, error: errorText(cause) }),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The private key of one deployment, as its Worker reads `ASTRALE_SIGNING_IDENTITY`. */
|
|
2
|
+
export interface DeploymentSigningIdentity {
|
|
3
|
+
/** RFC 7638 thumbprint of the public key: the `astrale-key:` tag and the JWK's `kid`. */
|
|
4
|
+
readonly keyId: string;
|
|
5
|
+
/**
|
|
6
|
+
* The private Ed25519 JWK as JSON text, `{ kty, crv, x, d, alg: 'EdDSA', kid }`: bound once as a
|
|
7
|
+
* `secret_text` and never logged, returned or stored anywhere else.
|
|
8
|
+
*/
|
|
9
|
+
readonly secret: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Generate the Ed25519 key of one new deployment with WebCrypto, so it runs in Node and in a
|
|
13
|
+
* Worker. Every deployment is its own issuer with a key of its own.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createSigningIdentity(): Promise<DeploymentSigningIdentity>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { base64url } from './bytes.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate the Ed25519 key of one new deployment with WebCrypto, so it runs in Node and in a
|
|
4
|
+
* Worker. Every deployment is its own issuer with a key of its own.
|
|
5
|
+
*/
|
|
6
|
+
export async function createSigningIdentity() {
|
|
7
|
+
const pair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, true, [
|
|
8
|
+
'sign',
|
|
9
|
+
'verify',
|
|
10
|
+
]));
|
|
11
|
+
const exported = (await crypto.subtle.exportKey('jwk', pair.privateKey));
|
|
12
|
+
if (exported.kty !== 'OKP' ||
|
|
13
|
+
exported.crv !== 'Ed25519' ||
|
|
14
|
+
typeof exported.x !== 'string' ||
|
|
15
|
+
typeof exported.d !== 'string') {
|
|
16
|
+
throw new TypeError('The generated deployment key is not an Ed25519 key.');
|
|
17
|
+
}
|
|
18
|
+
const keyId = await thumbprint(exported.crv, exported.kty, exported.x);
|
|
19
|
+
return Object.freeze({
|
|
20
|
+
keyId,
|
|
21
|
+
secret: JSON.stringify({
|
|
22
|
+
kty: exported.kty,
|
|
23
|
+
crv: exported.crv,
|
|
24
|
+
x: exported.x,
|
|
25
|
+
d: exported.d,
|
|
26
|
+
alg: 'EdDSA',
|
|
27
|
+
kid: keyId,
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** RFC 7638: SHA-256 of the required public members in lexicographic order, base64url. */
|
|
32
|
+
async function thumbprint(crv, kty, x) {
|
|
33
|
+
const members = JSON.stringify({ crv, kty, x });
|
|
34
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(members));
|
|
35
|
+
return base64url(new Uint8Array(digest));
|
|
36
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@astrale-os/adapter-cloudflare/hosting` — host immutable Astrale deployments on your own
|
|
3
|
+
* Cloudflare account with Workers for Platforms and a domain of your own, with no Astrale
|
|
4
|
+
* infrastructure:
|
|
5
|
+
*
|
|
6
|
+
* - provision once: a dispatch namespace, its routing KV, the dispatcher Worker and the wildcard
|
|
7
|
+
* ingress of the routing domain (`planHosting`, `provisionHosting`; `astrale-domain cloudflare init`);
|
|
8
|
+
* - deploy: one create-only script per deployment label, activated by its record in the routing
|
|
9
|
+
* KV, opaque bytes the dispatcher serves verbatim (`uploadDeploymentScript`,
|
|
10
|
+
* `activateDeployment`; `cloudflare({ namespace })` and Admin's Services do it);
|
|
11
|
+
* - operate: point a name at a deployment, schedule crons, retire in two phases, list a line.
|
|
12
|
+
*
|
|
13
|
+
* Everything but provisioning runs in Node and inside a Worker (`fetch` and WebCrypto only).
|
|
14
|
+
* Provisioning reads the dispatcher modules shipped with this package, so it runs in Node.
|
|
15
|
+
*/
|
|
16
|
+
export { CLOUDFLARE_API_BASE, CloudflareApiError, CloudflareClient, DEFAULT_RATE_LIMIT, isNotFound, OPERATOR_RATE_LIMIT, type CloudflareClientOptions, type CloudflareFailureKind, type CloudflarePage, type RateLimitPolicy, } from './client.js';
|
|
17
|
+
export { HostingRefusal } from './refusal.js';
|
|
18
|
+
export { callsDataset, isWorkerName, routingKvTitle } from './names.js';
|
|
19
|
+
export { CRON_PREFIX, cronKey, CURRENT_PREFIX, currentKey, decodeCurrent, decodeSchedules, encodeSchedules, findKvNamespace, isLabelOf, RECORD_PREFIX, recordKey, RETIREMENT_PREFIX, retirementKey, RoutingKv, WITHDRAWN, type RoutingKvLocator, } from './kv.js';
|
|
20
|
+
export { ARTIFACT_TAG_PREFIX, artifactOf, ASSETS_BINDING, BUILD_DIGEST_BINDING, decodeScript, deleteScript, deploymentBindings, deploymentTags, isExistingScript, KEY_TAG_PREFIX, LINE_TAG_PREFIX, observeScript, putScriptSecret, ROUTER_BINDING, scriptPath, scriptSecretNames, scriptSettings, SETTINGS_BINDING, settingsBind, SIGNING_IDENTITY_BINDING, uploadDeploymentScript, VERSION_METADATA_BINDING, versionUuid, WORKER_URL_BINDING, type DeploymentBindingsInput, type NamespaceScript, type ScriptBinding, type ScriptModule, type ScriptSettings, type ScriptUpload, } from './script.js';
|
|
21
|
+
export { uploadAssets, type ScriptAsset } from './assets.js';
|
|
22
|
+
export { createSigningIdentity, type DeploymentSigningIdentity } from './identity.js';
|
|
23
|
+
export { activateDeployment, promoteDeployment, readCurrent, readRecord, readSchedules, replaceSchedules, sameRecord, } from './activation.js';
|
|
24
|
+
export { completeRetirement, markRetired, readRetirement, RETIREMENT_PROBE_PATH, RETIREMENT_PROBE_RETRY_MS, RETIREMENT_PROPAGATION_MS, retirementVisible, type RetirementCompletion, } from './retirement.js';
|
|
25
|
+
export { dispatchFetcher, publicFetcher, VERSION_HEADER, versionFetch, waitForReadiness, type ReadinessFetcher, } from './readiness.js';
|
|
26
|
+
export { listLineDeployments, type LineDeployment } from './listing.js';
|
|
27
|
+
export { createDispatcher } from './dispatcher/dispatcher.mjs';
|
|
28
|
+
export type { Dispatcher, DispatcherExtension, ScheduleTarget } from './dispatcher/dispatcher.mjs';
|
|
29
|
+
export type { DispatcherEnvironment, DispatchFetcher, DispatchNamespaceBinding, RoutingKvBinding, } from './dispatcher/env.mjs';
|
|
30
|
+
export { DISPATCHER_MAIN_MODULE, DISPATCHER_MODULE_NAMES, dispatcherModules, type DispatcherModule, } from './dispatcher/modules.js';
|
|
31
|
+
export { DISPATCHER_COMPATIBILITY_DATE, DISPATCHER_COMPATIBILITY_FLAGS, dispatcherBindings, type WorkerBinding, } from './provision/dispatcher.js';
|
|
32
|
+
export { dispatcherIngressReady } from './provision/ingress.js';
|
|
33
|
+
export { findZone, routeReaches } from './provision/routing.js';
|
|
34
|
+
export { applyHosting, openHostingKv, planHosting, provisionHosting, type HostingConfig, type HostingPlan, type HostingProvisioning, } from './provision/plan.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@astrale-os/adapter-cloudflare/hosting` — host immutable Astrale deployments on your own
|
|
3
|
+
* Cloudflare account with Workers for Platforms and a domain of your own, with no Astrale
|
|
4
|
+
* infrastructure:
|
|
5
|
+
*
|
|
6
|
+
* - provision once: a dispatch namespace, its routing KV, the dispatcher Worker and the wildcard
|
|
7
|
+
* ingress of the routing domain (`planHosting`, `provisionHosting`; `astrale-domain cloudflare init`);
|
|
8
|
+
* - deploy: one create-only script per deployment label, activated by its record in the routing
|
|
9
|
+
* KV, opaque bytes the dispatcher serves verbatim (`uploadDeploymentScript`,
|
|
10
|
+
* `activateDeployment`; `cloudflare({ namespace })` and Admin's Services do it);
|
|
11
|
+
* - operate: point a name at a deployment, schedule crons, retire in two phases, list a line.
|
|
12
|
+
*
|
|
13
|
+
* Everything but provisioning runs in Node and inside a Worker (`fetch` and WebCrypto only).
|
|
14
|
+
* Provisioning reads the dispatcher modules shipped with this package, so it runs in Node.
|
|
15
|
+
*/
|
|
16
|
+
export { CLOUDFLARE_API_BASE, CloudflareApiError, CloudflareClient, DEFAULT_RATE_LIMIT, isNotFound, OPERATOR_RATE_LIMIT, } from './client.js';
|
|
17
|
+
export { HostingRefusal } from './refusal.js';
|
|
18
|
+
export { callsDataset, isWorkerName, routingKvTitle } from './names.js';
|
|
19
|
+
export { CRON_PREFIX, cronKey, CURRENT_PREFIX, currentKey, decodeCurrent, decodeSchedules, encodeSchedules, findKvNamespace, isLabelOf, RECORD_PREFIX, recordKey, RETIREMENT_PREFIX, retirementKey, RoutingKv, WITHDRAWN, } from './kv.js';
|
|
20
|
+
export { ARTIFACT_TAG_PREFIX, artifactOf, ASSETS_BINDING, BUILD_DIGEST_BINDING, decodeScript, deleteScript, deploymentBindings, deploymentTags, isExistingScript, KEY_TAG_PREFIX, LINE_TAG_PREFIX, observeScript, putScriptSecret, ROUTER_BINDING, scriptPath, scriptSecretNames, scriptSettings, SETTINGS_BINDING, settingsBind, SIGNING_IDENTITY_BINDING, uploadDeploymentScript, VERSION_METADATA_BINDING, versionUuid, WORKER_URL_BINDING, } from './script.js';
|
|
21
|
+
export { uploadAssets } from './assets.js';
|
|
22
|
+
export { createSigningIdentity } from './identity.js';
|
|
23
|
+
export { activateDeployment, promoteDeployment, readCurrent, readRecord, readSchedules, replaceSchedules, sameRecord, } from './activation.js';
|
|
24
|
+
export { completeRetirement, markRetired, readRetirement, RETIREMENT_PROBE_PATH, RETIREMENT_PROBE_RETRY_MS, RETIREMENT_PROPAGATION_MS, retirementVisible, } from './retirement.js';
|
|
25
|
+
export { dispatchFetcher, publicFetcher, VERSION_HEADER, versionFetch, waitForReadiness, } from './readiness.js';
|
|
26
|
+
export { listLineDeployments } from './listing.js';
|
|
27
|
+
export { createDispatcher } from './dispatcher/dispatcher.mjs';
|
|
28
|
+
export { DISPATCHER_MAIN_MODULE, DISPATCHER_MODULE_NAMES, dispatcherModules, } from './dispatcher/modules.js';
|
|
29
|
+
export { DISPATCHER_COMPATIBILITY_DATE, DISPATCHER_COMPATIBILITY_FLAGS, dispatcherBindings, } from './provision/dispatcher.js';
|
|
30
|
+
export { dispatcherIngressReady } from './provision/ingress.js';
|
|
31
|
+
export { findZone, routeReaches } from './provision/routing.js';
|
|
32
|
+
export { applyHosting, openHostingKv, planHosting, provisionHosting, } from './provision/plan.js';
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { CloudflareClient } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* The routing KV contract every dispatcher reads (`SCHEDULES`) and every writer of the kit keeps:
|
|
4
|
+
*
|
|
5
|
+
* | key | value | metadata |
|
|
6
|
+
* |---|---|---|
|
|
7
|
+
* | `record:<label>` | the deployment's record, opaque bytes; `-` when it publishes none; never rewritten | `{"contentType":<its media type>}`, `{"contentType":null}` without a record |
|
|
8
|
+
* | `retirement:<label>` | `withdrawn`; any value retires the label at the dispatcher | none |
|
|
9
|
+
* | `current:<name>` | the label a name serves now, one of `<name>-…` | none |
|
|
10
|
+
* | `cron:<name>` | JSON array of distinct canonical five-field UTC crons; deleted when empty | none |
|
|
11
|
+
*
|
|
12
|
+
* `record:<label>` activates the label and outlives its retirement. One written before records
|
|
13
|
+
* were opaque has no metadata: it is the RFC 8785 JSON of a `DeploymentRecordV1`, served as one.
|
|
14
|
+
* Workers KV has no compare-and-set: every write is read back before it counts.
|
|
15
|
+
*/
|
|
16
|
+
export declare const RECORD_PREFIX = "record:";
|
|
17
|
+
export declare const RETIREMENT_PREFIX = "retirement:";
|
|
18
|
+
export declare const CURRENT_PREFIX = "current:";
|
|
19
|
+
export declare const CRON_PREFIX = "cron:";
|
|
20
|
+
/** The one tombstone value the kit writes: a withdrawal by the deployment's owner. */
|
|
21
|
+
export declare const WITHDRAWN = "withdrawn";
|
|
22
|
+
/** The key whose presence activates a label at the dispatcher. */
|
|
23
|
+
export declare function recordKey(label: string): string;
|
|
24
|
+
/** The key whose presence retires a label at the dispatcher, whatever the record says. */
|
|
25
|
+
export declare function retirementKey(label: string): string;
|
|
26
|
+
/** The key that points a name (a deployment line) at the deployment its host serves now. */
|
|
27
|
+
export declare function currentKey(name: string): string;
|
|
28
|
+
/** The key of a name's UTC crons, which the dispatcher fires on its current deployment. */
|
|
29
|
+
export declare function cronKey(name: string): string;
|
|
30
|
+
/** Admit the label `current:<name>` holds: a deployment label of that name's line. */
|
|
31
|
+
export declare function decodeCurrent(text: string, name: string): string;
|
|
32
|
+
/** Whether `label` is a deployment label whose line is `name`, as the dispatcher admits it. */
|
|
33
|
+
export declare function isLabelOf(label: string, name: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* The text `cron:<name>` holds for these crons: their canonical form as JSON, or undefined when
|
|
36
|
+
* there are none and the key is deleted. Crons that are not distinct five-field UTC expressions are
|
|
37
|
+
* refused before anything is written.
|
|
38
|
+
*/
|
|
39
|
+
export declare function encodeSchedules(crons: readonly string[]): {
|
|
40
|
+
readonly crons: readonly string[];
|
|
41
|
+
readonly text: string | undefined;
|
|
42
|
+
};
|
|
43
|
+
/** Admit the text `cron:<name>` holds as canonical crons. */
|
|
44
|
+
export declare function decodeSchedules(text: string, name: string): readonly string[];
|
|
45
|
+
/** Where a routing KV is: by the id a caller keeps, or by its title (`routingKvTitle`). */
|
|
46
|
+
export type RoutingKvLocator = {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
} | {
|
|
49
|
+
readonly title: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* One routing KV, through the account API. Reads answer the stored text (undefined when absent);
|
|
53
|
+
* every write and delete is read back before it counts, and fails `unavailable` otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export declare class RoutingKv {
|
|
56
|
+
private readonly client;
|
|
57
|
+
readonly id: string;
|
|
58
|
+
private constructor();
|
|
59
|
+
/**
|
|
60
|
+
* Open the routing KV by id, or find it by title; a title the account holds no KV under is a
|
|
61
|
+
* `not-provisioned` refusal: only provisioning creates it.
|
|
62
|
+
*/
|
|
63
|
+
static open(client: CloudflareClient, locator: RoutingKvLocator): Promise<RoutingKv>;
|
|
64
|
+
/** The text stored at `key`, or undefined. */
|
|
65
|
+
get(key: string): Promise<string | undefined>;
|
|
66
|
+
/** Write `text` at `key`, then read it back. */
|
|
67
|
+
put(key: string, text: string, contentType: string): Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* The bytes stored at `key` and their metadata (`null` when they were written without), or
|
|
70
|
+
* undefined when the key is absent.
|
|
71
|
+
*/
|
|
72
|
+
getWithMetadata(key: string): Promise<{
|
|
73
|
+
readonly bytes: Uint8Array;
|
|
74
|
+
readonly metadata: unknown;
|
|
75
|
+
} | undefined>;
|
|
76
|
+
/** Write `bytes` at `key` with its JSON `metadata`, then read both back. */
|
|
77
|
+
putWithMetadata(key: string, bytes: Uint8Array, metadata: Readonly<Record<string, unknown>>): Promise<void>;
|
|
78
|
+
/** Delete `key`, then read back its absence. */
|
|
79
|
+
delete(key: string): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Every key name that starts with `prefix`, in Cloudflare's (lexicographic) order, following
|
|
82
|
+
* the listing's cursor to its end.
|
|
83
|
+
*/
|
|
84
|
+
keys(prefix: string): Promise<readonly string[]>;
|
|
85
|
+
private value;
|
|
86
|
+
private path;
|
|
87
|
+
}
|
|
88
|
+
/** The id of the account's KV namespace with exactly this title, or undefined. */
|
|
89
|
+
export declare function findKvNamespace(client: CloudflareClient, title: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { parseDeploymentLabel } from '@astrale-os/sdk/deployment/address';
|
|
2
|
+
import { CloudflareApiError, isNotFound } from './client.js';
|
|
3
|
+
import { canonicalUtcCrons } from './dispatcher/cron.mjs';
|
|
4
|
+
import { HostingRefusal } from './refusal.js';
|
|
5
|
+
/**
|
|
6
|
+
* The routing KV contract every dispatcher reads (`SCHEDULES`) and every writer of the kit keeps:
|
|
7
|
+
*
|
|
8
|
+
* | key | value | metadata |
|
|
9
|
+
* |---|---|---|
|
|
10
|
+
* | `record:<label>` | the deployment's record, opaque bytes; `-` when it publishes none; never rewritten | `{"contentType":<its media type>}`, `{"contentType":null}` without a record |
|
|
11
|
+
* | `retirement:<label>` | `withdrawn`; any value retires the label at the dispatcher | none |
|
|
12
|
+
* | `current:<name>` | the label a name serves now, one of `<name>-…` | none |
|
|
13
|
+
* | `cron:<name>` | JSON array of distinct canonical five-field UTC crons; deleted when empty | none |
|
|
14
|
+
*
|
|
15
|
+
* `record:<label>` activates the label and outlives its retirement. One written before records
|
|
16
|
+
* were opaque has no metadata: it is the RFC 8785 JSON of a `DeploymentRecordV1`, served as one.
|
|
17
|
+
* Workers KV has no compare-and-set: every write is read back before it counts.
|
|
18
|
+
*/
|
|
19
|
+
export const RECORD_PREFIX = 'record:';
|
|
20
|
+
export const RETIREMENT_PREFIX = 'retirement:';
|
|
21
|
+
export const CURRENT_PREFIX = 'current:';
|
|
22
|
+
export const CRON_PREFIX = 'cron:';
|
|
23
|
+
/** The one tombstone value the kit writes: a withdrawal by the deployment's owner. */
|
|
24
|
+
export const WITHDRAWN = 'withdrawn';
|
|
25
|
+
/** The key whose presence activates a label at the dispatcher. */
|
|
26
|
+
export function recordKey(label) {
|
|
27
|
+
return `${RECORD_PREFIX}${label}`;
|
|
28
|
+
}
|
|
29
|
+
/** The key whose presence retires a label at the dispatcher, whatever the record says. */
|
|
30
|
+
export function retirementKey(label) {
|
|
31
|
+
return `${RETIREMENT_PREFIX}${label}`;
|
|
32
|
+
}
|
|
33
|
+
/** The key that points a name (a deployment line) at the deployment its host serves now. */
|
|
34
|
+
export function currentKey(name) {
|
|
35
|
+
return `${CURRENT_PREFIX}${name}`;
|
|
36
|
+
}
|
|
37
|
+
/** The key of a name's UTC crons, which the dispatcher fires on its current deployment. */
|
|
38
|
+
export function cronKey(name) {
|
|
39
|
+
return `${CRON_PREFIX}${name}`;
|
|
40
|
+
}
|
|
41
|
+
/** Admit the label `current:<name>` holds: a deployment label of that name's line. */
|
|
42
|
+
export function decodeCurrent(text, name) {
|
|
43
|
+
if (!isLabelOf(text, name)) {
|
|
44
|
+
throw new HostingRefusal('route-invalid', `current:${name} names ${text}, which is not a deployment of ${name}.`);
|
|
45
|
+
}
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
/** Whether `label` is a deployment label whose line is `name`, as the dispatcher admits it. */
|
|
49
|
+
export function isLabelOf(label, name) {
|
|
50
|
+
if (!label.startsWith(`${name}-`))
|
|
51
|
+
return false;
|
|
52
|
+
try {
|
|
53
|
+
return parseDeploymentLabel(label).line === name;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The text `cron:<name>` holds for these crons: their canonical form as JSON, or undefined when
|
|
61
|
+
* there are none and the key is deleted. Crons that are not distinct five-field UTC expressions are
|
|
62
|
+
* refused before anything is written.
|
|
63
|
+
*/
|
|
64
|
+
export function encodeSchedules(crons) {
|
|
65
|
+
const canonical = canonicalUtcCrons(crons);
|
|
66
|
+
if (canonical === undefined) {
|
|
67
|
+
throw new HostingRefusal('schedules-invalid', 'Schedules must be distinct five-field UTC cron expressions.');
|
|
68
|
+
}
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
crons: canonical,
|
|
71
|
+
text: canonical.length === 0 ? undefined : JSON.stringify(canonical),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** Admit the text `cron:<name>` holds as canonical crons. */
|
|
75
|
+
export function decodeSchedules(text, name) {
|
|
76
|
+
let value;
|
|
77
|
+
try {
|
|
78
|
+
value = JSON.parse(text);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
value = undefined;
|
|
82
|
+
}
|
|
83
|
+
const crons = canonicalUtcCrons(value);
|
|
84
|
+
if (crons === undefined) {
|
|
85
|
+
throw new HostingRefusal('schedules-invalid', `cron:${name} holds no canonical UTC crons.`);
|
|
86
|
+
}
|
|
87
|
+
return crons;
|
|
88
|
+
}
|
|
89
|
+
/** The most keys one page of a KV key listing returns, as Cloudflare caps it. */
|
|
90
|
+
const KEYS_PAGE = 1_000;
|
|
91
|
+
/**
|
|
92
|
+
* One routing KV, through the account API. Reads answer the stored text (undefined when absent);
|
|
93
|
+
* every write and delete is read back before it counts, and fails `unavailable` otherwise.
|
|
94
|
+
*/
|
|
95
|
+
export class RoutingKv {
|
|
96
|
+
client;
|
|
97
|
+
id;
|
|
98
|
+
constructor(client, id) {
|
|
99
|
+
this.client = client;
|
|
100
|
+
this.id = id;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Open the routing KV by id, or find it by title; a title the account holds no KV under is a
|
|
104
|
+
* `not-provisioned` refusal: only provisioning creates it.
|
|
105
|
+
*/
|
|
106
|
+
static async open(client, locator) {
|
|
107
|
+
if ('id' in locator) {
|
|
108
|
+
if (locator.id.length === 0)
|
|
109
|
+
throw new TypeError('Routing KV id is required.');
|
|
110
|
+
return new RoutingKv(client, locator.id);
|
|
111
|
+
}
|
|
112
|
+
const id = await findKvNamespace(client, locator.title);
|
|
113
|
+
if (id === undefined) {
|
|
114
|
+
throw new HostingRefusal('not-provisioned', `The account has no routing KV titled ${locator.title}; the namespace is not provisioned ` +
|
|
115
|
+
'(`astrale-domain cloudflare init <environment>` provisions it).');
|
|
116
|
+
}
|
|
117
|
+
return new RoutingKv(client, id);
|
|
118
|
+
}
|
|
119
|
+
/** The text stored at `key`, or undefined. */
|
|
120
|
+
async get(key) {
|
|
121
|
+
return this.client.text('GET', this.value(key));
|
|
122
|
+
}
|
|
123
|
+
/** Write `text` at `key`, then read it back. */
|
|
124
|
+
async put(key, text, contentType) {
|
|
125
|
+
await this.client.text('PUT', this.value(key), { text, contentType });
|
|
126
|
+
if ((await this.get(key)) !== text)
|
|
127
|
+
notKept(key);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The bytes stored at `key` and their metadata (`null` when they were written without), or
|
|
131
|
+
* undefined when the key is absent.
|
|
132
|
+
*/
|
|
133
|
+
async getWithMetadata(key) {
|
|
134
|
+
const bytes = await this.client.bytes(this.value(key));
|
|
135
|
+
if (bytes === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
let metadata;
|
|
138
|
+
try {
|
|
139
|
+
metadata = await this.client.json('GET', this.path('metadata', key));
|
|
140
|
+
}
|
|
141
|
+
catch (cause) {
|
|
142
|
+
if (isNotFound(cause))
|
|
143
|
+
return undefined;
|
|
144
|
+
throw cause;
|
|
145
|
+
}
|
|
146
|
+
return Object.freeze({ bytes, metadata: metadata ?? null });
|
|
147
|
+
}
|
|
148
|
+
/** Write `bytes` at `key` with its JSON `metadata`, then read both back. */
|
|
149
|
+
async putWithMetadata(key, bytes, metadata) {
|
|
150
|
+
const text = JSON.stringify(metadata);
|
|
151
|
+
const form = new FormData();
|
|
152
|
+
form.append('value', new Blob([bytes]));
|
|
153
|
+
form.append('metadata', text);
|
|
154
|
+
await this.client.form('PUT', this.value(key), form);
|
|
155
|
+
const stored = await this.getWithMetadata(key);
|
|
156
|
+
if (stored === undefined ||
|
|
157
|
+
stored.bytes.byteLength !== bytes.byteLength ||
|
|
158
|
+
stored.bytes.some((byte, index) => byte !== bytes[index]) ||
|
|
159
|
+
JSON.stringify(stored.metadata) !== text) {
|
|
160
|
+
notKept(key);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Delete `key`, then read back its absence. */
|
|
164
|
+
async delete(key) {
|
|
165
|
+
await this.client.text('DELETE', this.value(key));
|
|
166
|
+
if ((await this.get(key)) !== undefined)
|
|
167
|
+
notKept(key);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Every key name that starts with `prefix`, in Cloudflare's (lexicographic) order, following
|
|
171
|
+
* the listing's cursor to its end.
|
|
172
|
+
*/
|
|
173
|
+
async keys(prefix) {
|
|
174
|
+
const query = new URLSearchParams({ prefix, limit: String(KEYS_PAGE) });
|
|
175
|
+
const entries = await this.client.cursorList(this.client.account(`/storage/kv/namespaces/${encodeURIComponent(this.id)}/keys?${query}`));
|
|
176
|
+
return Object.freeze(entries.map((entry) => {
|
|
177
|
+
const name = entry?.name;
|
|
178
|
+
if (typeof name !== 'string' || !name.startsWith(prefix)) {
|
|
179
|
+
throw new CloudflareApiError('upstream', 'Cloudflare returned an invalid KV key listing.');
|
|
180
|
+
}
|
|
181
|
+
return name;
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
value(key) {
|
|
185
|
+
return this.path('values', key);
|
|
186
|
+
}
|
|
187
|
+
path(kind, key) {
|
|
188
|
+
return this.client.account(`/storage/kv/namespaces/${encodeURIComponent(this.id)}/${kind}/${encodeURIComponent(key)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** The id of the account's KV namespace with exactly this title, or undefined. */
|
|
192
|
+
export async function findKvNamespace(client, title) {
|
|
193
|
+
const matches = (entry) => entry?.title === title;
|
|
194
|
+
const listed = await client.pagedList(client.account('/storage/kv/namespaces'), matches);
|
|
195
|
+
const found = listed.find(matches);
|
|
196
|
+
if (found === undefined)
|
|
197
|
+
return undefined;
|
|
198
|
+
if (typeof found.id !== 'string' || found.id.length === 0) {
|
|
199
|
+
throw new CloudflareApiError('upstream', 'Cloudflare returned an invalid KV namespace id.');
|
|
200
|
+
}
|
|
201
|
+
return found.id;
|
|
202
|
+
}
|
|
203
|
+
function notKept(key) {
|
|
204
|
+
const kind = key.slice(0, key.indexOf(':') + 1) || 'a key';
|
|
205
|
+
throw new CloudflareApiError('unavailable', `Cloudflare did not keep the write at ${kind}.`);
|
|
206
|
+
}
|