@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,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
+ }