@astrale-os/adapter-cloudflare 0.5.0-beta.177 → 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 +27 -23
- package/dist/build/index.d.ts +1 -0
- package/dist/build/index.js +1 -0
- package/dist/hosting/activation.d.ts +16 -9
- package/dist/hosting/activation.js +52 -15
- package/dist/hosting/client.d.ts +5 -0
- package/dist/hosting/client.js +37 -5
- package/dist/hosting/dispatcher/deployments.mjs +43 -12
- package/dist/hosting/dispatcher/env.d.mts +5 -0
- package/dist/hosting/index.d.ts +4 -3
- package/dist/hosting/index.js +4 -3
- package/dist/hosting/kv.d.ts +19 -19
- package/dist/hosting/kv.js +48 -44
- package/dist/hosting/listing.d.ts +7 -6
- package/dist/hosting/listing.js +6 -13
- package/dist/hosting/refusal.d.ts +1 -1
- package/dist/hosting/retirement.d.ts +7 -6
- package/dist/hosting/retirement.js +10 -23
- package/dist/hosting/script.d.ts +15 -10
- package/dist/hosting/script.js +20 -8
- package/dist/hosting/testing/fake-cloudflare.d.ts +10 -1
- package/dist/hosting/testing/fake-cloudflare.js +66 -9
- package/dist/namespace/deploy-release.js +45 -8
- package/dist/namespace/list.js +2 -1
- package/dist/namespace/record.d.ts +21 -0
- package/dist/namespace/record.js +53 -0
- package/package.json +2 -6
package/README.md
CHANGED
|
@@ -75,11 +75,14 @@ Every line is readable. Namespace mode is a thin layer over the hosting kit belo
|
|
|
75
75
|
else. Its tags name its artifact (build digest), line and key; never `astrale-service`, which is
|
|
76
76
|
Services'.
|
|
77
77
|
- **Activation.** The deploy writes `record:<label>`, whose presence makes the dispatcher route the
|
|
78
|
-
label
|
|
79
|
-
|
|
78
|
+
label: the canonical `DeploymentRecordV1`, published through the kit's opaque record with its
|
|
79
|
+
media type, which the dispatcher serves at `/.well-known/astrale/deployment.json`. It never holds
|
|
80
|
+
a provider version or a secret digest. Readiness then waits, bounded, until the URL serves the
|
|
81
|
+
sealed release from the provider version the upload created.
|
|
80
82
|
- **Reuse.** A redeploy of the same release finds the script under its label. It is reused only
|
|
81
|
-
when its artifact and line tags and its record name this build and configuration (
|
|
82
|
-
|
|
83
|
+
when its artifact and line tags and its record name this build and configuration (only the
|
|
84
|
+
record's commit may differ: the deployment keeps the record it was first activated with), or,
|
|
85
|
+
when an interrupted deploy left it unactivated, when it binds exactly this configuration. Then only that
|
|
83
86
|
script's declared secrets are rewritten in place, and readiness waits for the version the
|
|
84
87
|
rewrite minted. Anything else is a `deployment-conflict`, refused with nothing written; no other
|
|
85
88
|
deployment is ever touched. A `summary:<label>` an older adapter wrote is ignored.
|
|
@@ -112,21 +115,29 @@ Node and inside a Worker (`fetch` and WebCrypto only).
|
|
|
112
115
|
- **Client.** `CloudflareClient`: bounded bodies, `Retry-After`/429 waits by policy
|
|
113
116
|
(`DEFAULT_RATE_LIMIT`, `OPERATOR_RATE_LIMIT` or none), timeouts and a deadline, typed
|
|
114
117
|
`CloudflareApiError`s (`kind`, `status`, `codes`), cursor and numbered pagination, multipart
|
|
115
|
-
forms
|
|
116
|
-
- **Routing KV.** `RoutingKv` (by title or id) and the contract: `record:<label>` (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
forms, raw KV text and bytes.
|
|
119
|
+
- **Routing KV.** `RoutingKv` (by title or id) and the contract: `record:<label>` (the deployment's
|
|
120
|
+
record, opaque bytes with their media type in the value's metadata, `{"contentType":...}`, or
|
|
121
|
+
`-` with `{"contentType":null}` for a deployment that publishes none; written once and kept only
|
|
122
|
+
for the same bytes and media type), `retirement:<label>` (`withdrawn`), `current:<name>` (one of
|
|
123
|
+
the name's labels), `cron:<name>` (canonical UTC crons). A record written before records were
|
|
124
|
+
opaque has no metadata: it is a canonical `DeploymentRecordV1`, read and served as one. Every
|
|
125
|
+
write is read back before it counts.
|
|
120
126
|
- **Scripts.** `deploymentBindings`, `deploymentTags` (with a host's extra tags),
|
|
121
127
|
`uploadDeploymentScript` (create-only, every module kind, assets in parallel),
|
|
122
128
|
`observeScript`, `scriptSettings`, `scriptSecretNames`, `putScriptSecret`, `deleteScript`, and
|
|
123
129
|
`createSigningIdentity` (WebCrypto Ed25519).
|
|
124
|
-
- **Lifecycle.** `activateDeployment
|
|
130
|
+
- **Lifecycle.** `activateDeployment` (a label and its record, `WorkerRecord` of
|
|
131
|
+
`@astrale-os/sdk/deployment/worker`, or `null`), `readRecord`, `promoteDeployment`,
|
|
132
|
+
`replaceSchedules`, two-phase retirement
|
|
125
133
|
(`markRetired`, then `completeRetirement` once `retirementVisible` sees the dispatcher answer
|
|
126
134
|
`DEPLOYMENT_RETIRED`), `waitForReadiness` through the public URL or a dispatch binding, and
|
|
127
135
|
`listLineDeployments`.
|
|
128
136
|
- **Dispatcher.** Plain ES modules shipped with the package (`dispatcherModules()`), routing a label
|
|
129
|
-
to its script, a name to `current:<name>`, and firing `cron:<name>` on schedule.
|
|
137
|
+
to its script, a name to `current:<name>`, and firing `cron:<name>` on schedule. It serves a
|
|
138
|
+
label's record at `/.well-known/astrale/deployment.json`, its bytes and media type as published
|
|
139
|
+
(immutable, `405` but for `GET` and `HEAD`), and hands that path to a deployment that publishes
|
|
140
|
+
none. A host prepends
|
|
130
141
|
its own routing and schedule targets with `createDispatcher(extension)` in its own `index.mjs`,
|
|
131
142
|
without forking it. The public never reaches `/__scheduled`; nothing in the generated Worker
|
|
132
143
|
serves it yet.
|
|
@@ -232,18 +243,11 @@ until the adapter versions are aligned.
|
|
|
232
243
|
Services receives the placed modules as they are, so there the digest names the bytes the
|
|
233
244
|
deployment runs. Direct mode runs `wrangler deploy`, which bundles the placed modules again: there
|
|
234
245
|
the digest names the portable Worker the deployment was made from, not the bytes Cloudflare runs.
|
|
235
|
-
A host that receives a Worker upload recomputes the digest from the exact upload with
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
import { buildDigest } from '@astrale-os/sdk/deployment/build/manifest'
|
|
241
|
-
|
|
242
|
-
const digest = buildDigest(workerBuildManifest({ origin, revision, bundle, entry, modules, assets }))
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
Modules keep their uploaded name and an asset `/<path>` is the file `assets/<path>`. The bundler
|
|
246
|
-
version names this layout, not a package release. Both subpaths are runtime-safe.
|
|
246
|
+
A host that receives a Worker upload recomputes the digest from the exact upload with
|
|
247
|
+
`workerBuildDigest` (`@astrale-os/sdk/deployment/worker`), given the members of the Bundle's build
|
|
248
|
+
manifest its deployer declares (`format`, `version`, `origin`, `schema`, `bundler`): modules keep
|
|
249
|
+
their uploaded name and an asset `/<path>` is the file `assets/<path>`, so the digest is the
|
|
250
|
+
Bundle's own. The bundler version names this layout, not a package release.
|
|
247
251
|
|
|
248
252
|
## Material for a custom Worker entrypoint
|
|
249
253
|
|
package/dist/build/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { loadWorkerArtifact } from './worker/index.js';
|
|
|
4
4
|
export type { WorkerArtifact } from './worker/index.js';
|
|
5
5
|
export { bundleUpload, uploadBuildDigest, uploadBuildEvidence, workerUpload, type BundleUpload, } from './worker/upload.js';
|
|
6
6
|
export { isAdapterVar, resolveRouter, workerSettings } from './configuration.js';
|
|
7
|
+
export { encodeWorkerSettings, SETTINGS_BINDING } from '../worker/settings.js';
|
|
7
8
|
export { GENERATED_WORKER_RUNTIME } from './codegen/wrangler.js';
|
|
8
9
|
export { frontendFiles } from './frontend/files.js';
|
|
9
10
|
export { verifyWorkerFrontend } from './frontend/readiness.js';
|
package/dist/build/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { cloudflareWorkerBundler } from './bundler.js';
|
|
|
3
3
|
export { loadWorkerArtifact } from './worker/index.js';
|
|
4
4
|
export { bundleUpload, uploadBuildDigest, uploadBuildEvidence, workerUpload, } from './worker/upload.js';
|
|
5
5
|
export { isAdapterVar, resolveRouter, workerSettings } from './configuration.js';
|
|
6
|
+
export { encodeWorkerSettings, SETTINGS_BINDING } from '../worker/settings.js';
|
|
6
7
|
export { GENERATED_WORKER_RUNTIME } from './codegen/wrangler.js';
|
|
7
8
|
export { frontendFiles } from './frontend/files.js';
|
|
8
9
|
export { verifyWorkerFrontend } from './frontend/readiness.js';
|
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { WorkerRecord } from '@astrale-os/sdk/deployment/worker';
|
|
2
2
|
import type { RoutingKv } from './kv.js';
|
|
3
3
|
/**
|
|
4
|
-
* Activate one deployment:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Activate one deployment: write `record:<label>`, which the dispatcher reads to route the label,
|
|
5
|
+
* with the record its deployer publishes (opaque bytes and their media type, served verbatim at
|
|
6
|
+
* `/.well-known/astrale/deployment.json`), or `null` for a deployment that publishes none. A
|
|
7
|
+
* record never changes once published: one already there is kept and answered when it is the same
|
|
8
|
+
* record (same media type, same bytes, or both none), and refused (`deployment-conflict`)
|
|
9
|
+
* otherwise, without any write.
|
|
9
10
|
*/
|
|
10
|
-
export declare function activateDeployment(kv: RoutingKv, record:
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
export declare function activateDeployment(kv: RoutingKv, label: string, record: WorkerRecord | null): Promise<WorkerRecord | null>;
|
|
12
|
+
/**
|
|
13
|
+
* The record a label was activated with: the record, `null` when the deployment publishes none,
|
|
14
|
+
* or undefined when the label is not activated. A record written before records were opaque,
|
|
15
|
+
* without metadata, is the `DeploymentRecordV1` it holds, with that media type.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readRecord(kv: RoutingKv, label: string): Promise<WorkerRecord | null | undefined>;
|
|
18
|
+
/** Whether two activations publish the same record (or both none). */
|
|
19
|
+
export declare function sameRecord(left: WorkerRecord | null, right: WorkerRecord | null): boolean;
|
|
13
20
|
/**
|
|
14
21
|
* Point a name (a deployment line) at the deployment its URL `https://<name>.<routing domain>`
|
|
15
22
|
* serves: `current:<name>` = `label`, or no deployment when `label` is null (the key is deleted).
|
|
@@ -1,29 +1,66 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DEPLOYMENT_RECORD_MEDIA_TYPE, parseDeploymentLabel, } from '@astrale-os/sdk/deployment/address';
|
|
2
|
+
import { acceptWorkerRecord, sameWorkerRecord } from '@astrale-os/sdk/deployment/worker';
|
|
3
|
+
import { cronKey, currentKey, decodeCurrent, decodeSchedules, encodeSchedules, isLabelOf, recordKey, } from './kv.js';
|
|
2
4
|
import { HostingRefusal } from './refusal.js';
|
|
5
|
+
/** What `record:<label>` holds for a deployment that publishes no record: never served. */
|
|
6
|
+
const NO_RECORD = new TextEncoder().encode('-');
|
|
3
7
|
/**
|
|
4
|
-
* Activate one deployment:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Activate one deployment: write `record:<label>`, which the dispatcher reads to route the label,
|
|
9
|
+
* with the record its deployer publishes (opaque bytes and their media type, served verbatim at
|
|
10
|
+
* `/.well-known/astrale/deployment.json`), or `null` for a deployment that publishes none. A
|
|
11
|
+
* record never changes once published: one already there is kept and answered when it is the same
|
|
12
|
+
* record (same media type, same bytes, or both none), and refused (`deployment-conflict`)
|
|
13
|
+
* otherwise, without any write.
|
|
9
14
|
*/
|
|
10
|
-
export async function activateDeployment(kv, record) {
|
|
11
|
-
|
|
12
|
-
const
|
|
15
|
+
export async function activateDeployment(kv, label, record) {
|
|
16
|
+
parseDeploymentLabel(label);
|
|
17
|
+
const published = record === null ? null : acceptWorkerRecord(record);
|
|
13
18
|
const stored = await readRecord(kv, label);
|
|
14
19
|
if (stored !== undefined) {
|
|
15
|
-
if (!
|
|
20
|
+
if (!sameRecord(stored, published)) {
|
|
16
21
|
throw new HostingRefusal('deployment-conflict', `Another record is already published for ${label}; nothing was written.`);
|
|
17
22
|
}
|
|
18
23
|
return stored;
|
|
19
24
|
}
|
|
20
|
-
await kv.
|
|
21
|
-
|
|
25
|
+
await kv.putWithMetadata(recordKey(label), published?.bytes ?? NO_RECORD, {
|
|
26
|
+
contentType: published?.contentType ?? null,
|
|
27
|
+
});
|
|
28
|
+
return published;
|
|
22
29
|
}
|
|
23
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* The record a label was activated with: the record, `null` when the deployment publishes none,
|
|
32
|
+
* or undefined when the label is not activated. A record written before records were opaque,
|
|
33
|
+
* without metadata, is the `DeploymentRecordV1` it holds, with that media type.
|
|
34
|
+
*/
|
|
24
35
|
export async function readRecord(kv, label) {
|
|
25
|
-
const
|
|
26
|
-
return
|
|
36
|
+
const stored = await kv.getWithMetadata(recordKey(label));
|
|
37
|
+
return stored === undefined ? undefined : storedRecord(stored, label);
|
|
38
|
+
}
|
|
39
|
+
/** Admit what `record:<label>` holds, value and metadata, as the dispatcher serves it. */
|
|
40
|
+
function storedRecord(stored, label) {
|
|
41
|
+
const { metadata } = stored;
|
|
42
|
+
let contentType = DEPLOYMENT_RECORD_MEDIA_TYPE;
|
|
43
|
+
if (metadata !== null) {
|
|
44
|
+
if (typeof metadata !== 'object' ||
|
|
45
|
+
Array.isArray(metadata) ||
|
|
46
|
+
Reflect.ownKeys(metadata).length !== 1 ||
|
|
47
|
+
!Object.hasOwn(metadata, 'contentType')) {
|
|
48
|
+
throw new HostingRefusal('record-invalid', `The record of ${label} has invalid metadata.`);
|
|
49
|
+
}
|
|
50
|
+
contentType = metadata.contentType;
|
|
51
|
+
if (contentType === null)
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return acceptWorkerRecord({ contentType: contentType, bytes: stored.bytes });
|
|
56
|
+
}
|
|
57
|
+
catch (cause) {
|
|
58
|
+
throw new HostingRefusal('record-invalid', `The record of ${label} is not a record: ${String(cause)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Whether two activations publish the same record (or both none). */
|
|
62
|
+
export function sameRecord(left, right) {
|
|
63
|
+
return left === null || right === null ? left === right : sameWorkerRecord(left, right);
|
|
27
64
|
}
|
|
28
65
|
/**
|
|
29
66
|
* Point a name (a deployment line) at the deployment its URL `https://<name>.<routing domain>`
|
package/dist/hosting/client.d.ts
CHANGED
|
@@ -126,6 +126,11 @@ export declare class CloudflareClient {
|
|
|
126
126
|
readonly text: string;
|
|
127
127
|
readonly contentType: string;
|
|
128
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>;
|
|
129
134
|
private send;
|
|
130
135
|
private request;
|
|
131
136
|
/** The request timeout, bounded by the deadline and the caller's cancellation. */
|
package/dist/hosting/client.js
CHANGED
|
@@ -204,6 +204,30 @@ export class CloudflareClient {
|
|
|
204
204
|
throw failure(method, path, response.status, decodeEnvelope(text));
|
|
205
205
|
return text;
|
|
206
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
|
+
}
|
|
207
231
|
async send(method, path, input, allowEmptySuccess = false) {
|
|
208
232
|
const response = await this.request(method, path, input);
|
|
209
233
|
const text = await this.read(response, MAXIMUM_JSON_RESPONSE_BYTES, method, path);
|
|
@@ -389,6 +413,9 @@ function describe(path) {
|
|
|
389
413
|
return 'request';
|
|
390
414
|
}
|
|
391
415
|
async function boundedText(response, maximumBytes) {
|
|
416
|
+
return new TextDecoder().decode(await boundedBytes(response, maximumBytes));
|
|
417
|
+
}
|
|
418
|
+
async function boundedBytes(response, maximumBytes) {
|
|
392
419
|
const declared = response.headers.get('content-length');
|
|
393
420
|
if (declared !== null) {
|
|
394
421
|
const bytes = Number(declared);
|
|
@@ -398,11 +425,10 @@ async function boundedText(response, maximumBytes) {
|
|
|
398
425
|
}
|
|
399
426
|
}
|
|
400
427
|
if (response.body === null)
|
|
401
|
-
return
|
|
428
|
+
return new Uint8Array();
|
|
402
429
|
const reader = response.body.getReader();
|
|
403
|
-
const
|
|
430
|
+
const parts = [];
|
|
404
431
|
let size = 0;
|
|
405
|
-
let text = '';
|
|
406
432
|
for (;;) {
|
|
407
433
|
const part = await reader.read();
|
|
408
434
|
if (part.done)
|
|
@@ -417,9 +443,15 @@ async function boundedText(response, maximumBytes) {
|
|
|
417
443
|
}
|
|
418
444
|
throw new CloudflareApiError('upstream', 'Cloudflare response body is too large.');
|
|
419
445
|
}
|
|
420
|
-
|
|
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;
|
|
421
453
|
}
|
|
422
|
-
return
|
|
454
|
+
return bytes;
|
|
423
455
|
}
|
|
424
456
|
function allowedHost(base) {
|
|
425
457
|
try {
|
|
@@ -3,6 +3,7 @@ import { scriptMissing } from './failure.mjs'
|
|
|
3
3
|
// @ts-check
|
|
4
4
|
|
|
5
5
|
/** @typedef {import('./env.mjs').DispatcherEnvironment} DispatcherEnvironment */
|
|
6
|
+
/** @typedef {{ body?: ArrayBuffer, contentType?: string }} PublishedRecord */
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Every deployment label, as `@astrale-os/sdk/deployment/address` declares `DEPLOYMENT_LABEL`: an
|
|
@@ -13,8 +14,12 @@ const DEPLOYMENT_LABEL = /^(?:[a-z0-9]+(?:-[a-z0-9]+)*-)?[a-z2-7]{16}-[a-z2-7]{1
|
|
|
13
14
|
/** The longest label a deploy gives: a line of 40 characters, a dash and the content. */
|
|
14
15
|
const MAXIMUM_LABEL_LENGTH = 57
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Where the dispatcher serves a deployment's record, which the deployment then never sees. A
|
|
19
|
+
* deployment that publishes no record answers the path itself.
|
|
20
|
+
*/
|
|
17
21
|
const DEPLOYMENT_RECORD_PATH = '/.well-known/astrale/deployment.json'
|
|
22
|
+
/** The media type of a record written before records were opaque: a `DeploymentRecordV1`. */
|
|
18
23
|
const DEPLOYMENT_RECORD_TYPE = 'application/vnd.astrale.deployment-record+json;v=1'
|
|
19
24
|
const ENVELOPE_TYPE = 'application/vnd.astrale+json'
|
|
20
25
|
/** The retirement probe (`retirementVisible`); it is never a call of the deployment. */
|
|
@@ -31,9 +36,10 @@ export function isDeploymentLabel(label) {
|
|
|
31
36
|
* (`./services.mjs`). A deployer writes `record:<label>` to activate the label, and its owner
|
|
32
37
|
* `retirement:<label>` to retire it; the record outlives the retirement. Every call of a retired
|
|
33
38
|
* deployment answers `DEPLOYMENT_RETIRED`, whatever its path. The record endpoint is not a call:
|
|
34
|
-
* the dispatcher answers it itself
|
|
35
|
-
*
|
|
36
|
-
*
|
|
39
|
+
* the dispatcher answers it itself with the record the deployer published, its bytes and its media
|
|
40
|
+
* type as written, and the record it serves immutable stays readable after retirement. A
|
|
41
|
+
* deployment without its record is not active yet (KV is eventually consistent, so a caller
|
|
42
|
+
* retries), and only an active deployment runs, as one call point when `CALLS` is bound.
|
|
37
43
|
*
|
|
38
44
|
* @param {Request} request
|
|
39
45
|
* @param {DispatcherEnvironment} environment
|
|
@@ -44,11 +50,14 @@ export async function dispatchDeployment(request, environment, label) {
|
|
|
44
50
|
try {
|
|
45
51
|
const [retirement, record] = await Promise.all([
|
|
46
52
|
environment.SCHEDULES.get(`retirement:${label}`),
|
|
47
|
-
|
|
53
|
+
// Only the record endpoint reads the record's bytes; a call only needs its presence.
|
|
54
|
+
url.pathname === DEPLOYMENT_RECORD_PATH
|
|
55
|
+
? publishedRecord(environment, label)
|
|
56
|
+
: environment.SCHEDULES.get(`record:${label}`).then((value) =>
|
|
57
|
+
value === null ? null : /** @type {PublishedRecord} */ ({}),
|
|
58
|
+
),
|
|
48
59
|
])
|
|
49
|
-
if (
|
|
50
|
-
return deploymentRecordResponse(request, record)
|
|
51
|
-
}
|
|
60
|
+
if (record?.contentType !== undefined) return deploymentRecordResponse(request, record)
|
|
52
61
|
if (retirement !== null) return deploymentRetiredResponse(url.origin)
|
|
53
62
|
if (record === null) return inactiveDeploymentResponse(url.origin)
|
|
54
63
|
if (url.pathname !== RETIREMENT_PROBE_PATH) {
|
|
@@ -61,12 +70,34 @@ export async function dispatchDeployment(request, environment, label) {
|
|
|
61
70
|
}
|
|
62
71
|
}
|
|
63
72
|
|
|
73
|
+
/**
|
|
74
|
+
* What `record:<label>` holds, as the record endpoint serves it: `null` when the label is not
|
|
75
|
+
* activated, the record's bytes and media type, or no `contentType` when the deployment publishes
|
|
76
|
+
* no record. A value written without metadata, before records were opaque, is a
|
|
77
|
+
* `DeploymentRecordV1`.
|
|
78
|
+
*
|
|
79
|
+
* @param {DispatcherEnvironment} environment
|
|
80
|
+
* @param {string} label
|
|
81
|
+
* @returns {Promise<PublishedRecord | null>}
|
|
82
|
+
*/
|
|
83
|
+
async function publishedRecord(environment, label) {
|
|
84
|
+
const stored = await environment.SCHEDULES.getWithMetadata(`record:${label}`, 'arrayBuffer')
|
|
85
|
+
if (stored.value === null) return null
|
|
86
|
+
if (stored.metadata === null || stored.metadata === undefined) {
|
|
87
|
+
return { body: stored.value, contentType: DEPLOYMENT_RECORD_TYPE }
|
|
88
|
+
}
|
|
89
|
+
const contentType = /** @type {{ contentType?: unknown }} */ (stored.metadata).contentType
|
|
90
|
+
return typeof contentType === 'string'
|
|
91
|
+
? { body: stored.value, contentType }
|
|
92
|
+
: { body: stored.value }
|
|
93
|
+
}
|
|
94
|
+
|
|
64
95
|
/**
|
|
65
96
|
* The record a deployer published for this label. It never changes: the label is derived from the
|
|
66
|
-
* build and the
|
|
97
|
+
* build and the configuration of the deployment it describes.
|
|
67
98
|
*
|
|
68
99
|
* @param {Request} request
|
|
69
|
-
* @param {
|
|
100
|
+
* @param {PublishedRecord} record
|
|
70
101
|
*/
|
|
71
102
|
function deploymentRecordResponse(request, record) {
|
|
72
103
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
@@ -75,10 +106,10 @@ function deploymentRecordResponse(request, record) {
|
|
|
75
106
|
headers: { allow: 'GET, HEAD', 'cache-control': 'no-store' },
|
|
76
107
|
})
|
|
77
108
|
}
|
|
78
|
-
return new Response(record, {
|
|
109
|
+
return new Response(record.body, {
|
|
79
110
|
status: 200,
|
|
80
111
|
headers: {
|
|
81
|
-
'content-type':
|
|
112
|
+
'content-type': /** @type {string} */ (record.contentType),
|
|
82
113
|
'cache-control': 'public, max-age=31536000, immutable',
|
|
83
114
|
},
|
|
84
115
|
})
|
|
@@ -23,6 +23,11 @@ export type SchedulePage =
|
|
|
23
23
|
/** The routing KV binding (`SCHEDULES`): the dispatcher only reads it. */
|
|
24
24
|
export interface RoutingKvBinding {
|
|
25
25
|
get(key: string): Promise<string | null>
|
|
26
|
+
/** Read for the record endpoint only: a record's bytes and the metadata it was written with. */
|
|
27
|
+
getWithMetadata(
|
|
28
|
+
key: string,
|
|
29
|
+
type: 'arrayBuffer',
|
|
30
|
+
): Promise<{ readonly value: ArrayBuffer | null; readonly metadata: unknown }>
|
|
26
31
|
list(options: { readonly prefix: string; readonly cursor?: string }): Promise<SchedulePage>
|
|
27
32
|
}
|
|
28
33
|
|
package/dist/hosting/index.d.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* - provision once: a dispatch namespace, its routing KV, the dispatcher Worker and the wildcard
|
|
7
7
|
* ingress of the routing domain (`planHosting`, `provisionHosting`; `astrale-domain cloudflare init`);
|
|
8
8
|
* - deploy: one create-only script per deployment label, activated by its record in the routing
|
|
9
|
-
* KV
|
|
9
|
+
* KV, opaque bytes the dispatcher serves verbatim (`uploadDeploymentScript`,
|
|
10
|
+
* `activateDeployment`; `cloudflare({ namespace })` and Admin's Services do it);
|
|
10
11
|
* - operate: point a name at a deployment, schedule crons, retire in two phases, list a line.
|
|
11
12
|
*
|
|
12
13
|
* Everything but provisioning runs in Node and inside a Worker (`fetch` and WebCrypto only).
|
|
@@ -15,11 +16,11 @@
|
|
|
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';
|
|
16
17
|
export { HostingRefusal } from './refusal.js';
|
|
17
18
|
export { callsDataset, isWorkerName, routingKvTitle } from './names.js';
|
|
18
|
-
export { CRON_PREFIX, cronKey, CURRENT_PREFIX, currentKey, decodeCurrent,
|
|
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';
|
|
19
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';
|
|
20
21
|
export { uploadAssets, type ScriptAsset } from './assets.js';
|
|
21
22
|
export { createSigningIdentity, type DeploymentSigningIdentity } from './identity.js';
|
|
22
|
-
export { activateDeployment, promoteDeployment, readCurrent, readRecord, readSchedules, replaceSchedules, } from './activation.js';
|
|
23
|
+
export { activateDeployment, promoteDeployment, readCurrent, readRecord, readSchedules, replaceSchedules, sameRecord, } from './activation.js';
|
|
23
24
|
export { completeRetirement, markRetired, readRetirement, RETIREMENT_PROBE_PATH, RETIREMENT_PROBE_RETRY_MS, RETIREMENT_PROPAGATION_MS, retirementVisible, type RetirementCompletion, } from './retirement.js';
|
|
24
25
|
export { dispatchFetcher, publicFetcher, VERSION_HEADER, versionFetch, waitForReadiness, type ReadinessFetcher, } from './readiness.js';
|
|
25
26
|
export { listLineDeployments, type LineDeployment } from './listing.js';
|
package/dist/hosting/index.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* - provision once: a dispatch namespace, its routing KV, the dispatcher Worker and the wildcard
|
|
7
7
|
* ingress of the routing domain (`planHosting`, `provisionHosting`; `astrale-domain cloudflare init`);
|
|
8
8
|
* - deploy: one create-only script per deployment label, activated by its record in the routing
|
|
9
|
-
* KV
|
|
9
|
+
* KV, opaque bytes the dispatcher serves verbatim (`uploadDeploymentScript`,
|
|
10
|
+
* `activateDeployment`; `cloudflare({ namespace })` and Admin's Services do it);
|
|
10
11
|
* - operate: point a name at a deployment, schedule crons, retire in two phases, list a line.
|
|
11
12
|
*
|
|
12
13
|
* Everything but provisioning runs in Node and inside a Worker (`fetch` and WebCrypto only).
|
|
@@ -15,11 +16,11 @@
|
|
|
15
16
|
export { CLOUDFLARE_API_BASE, CloudflareApiError, CloudflareClient, DEFAULT_RATE_LIMIT, isNotFound, OPERATOR_RATE_LIMIT, } from './client.js';
|
|
16
17
|
export { HostingRefusal } from './refusal.js';
|
|
17
18
|
export { callsDataset, isWorkerName, routingKvTitle } from './names.js';
|
|
18
|
-
export { CRON_PREFIX, cronKey, CURRENT_PREFIX, currentKey, decodeCurrent,
|
|
19
|
+
export { CRON_PREFIX, cronKey, CURRENT_PREFIX, currentKey, decodeCurrent, decodeSchedules, encodeSchedules, findKvNamespace, isLabelOf, RECORD_PREFIX, recordKey, RETIREMENT_PREFIX, retirementKey, RoutingKv, WITHDRAWN, } from './kv.js';
|
|
19
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';
|
|
20
21
|
export { uploadAssets } from './assets.js';
|
|
21
22
|
export { createSigningIdentity } from './identity.js';
|
|
22
|
-
export { activateDeployment, promoteDeployment, readCurrent, readRecord, readSchedules, replaceSchedules, } from './activation.js';
|
|
23
|
+
export { activateDeployment, promoteDeployment, readCurrent, readRecord, readSchedules, replaceSchedules, sameRecord, } from './activation.js';
|
|
23
24
|
export { completeRetirement, markRetired, readRetirement, RETIREMENT_PROBE_PATH, RETIREMENT_PROBE_RETRY_MS, RETIREMENT_PROPAGATION_MS, retirementVisible, } from './retirement.js';
|
|
24
25
|
export { dispatchFetcher, publicFetcher, VERSION_HEADER, versionFetch, waitForReadiness, } from './readiness.js';
|
|
25
26
|
export { listLineDeployments } from './listing.js';
|
package/dist/hosting/kv.d.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import type { DeploymentRecordV1 } from '@astrale-os/sdk/deployment/address';
|
|
2
1
|
import type { CloudflareClient } from './client.js';
|
|
3
2
|
/**
|
|
4
3
|
* The routing KV contract every dispatcher reads (`SCHEDULES`) and every writer of the kit keeps:
|
|
5
4
|
*
|
|
6
|
-
* | key | value |
|
|
5
|
+
* | key | value | metadata |
|
|
7
6
|
* |---|---|---|
|
|
8
|
-
* | `record:<label>` |
|
|
9
|
-
* | `retirement:<label>` | `withdrawn`; any value retires the label at the dispatcher |
|
|
10
|
-
* | `current:<name>` | the label a name serves now, one of `<name>-…` |
|
|
11
|
-
* | `cron:<name>` | JSON array of distinct canonical five-field UTC crons; deleted when empty |
|
|
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 |
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
15
|
*/
|
|
16
16
|
export declare const RECORD_PREFIX = "record:";
|
|
17
17
|
export declare const RETIREMENT_PREFIX = "retirement:";
|
|
@@ -27,17 +27,6 @@ export declare function retirementKey(label: string): string;
|
|
|
27
27
|
export declare function currentKey(name: string): string;
|
|
28
28
|
/** The key of a name's UTC crons, which the dispatcher fires on its current deployment. */
|
|
29
29
|
export declare function cronKey(name: string): string;
|
|
30
|
-
/** The text `record:<label>` holds: the record admitted, as RFC 8785 canonical JSON. */
|
|
31
|
-
export declare function encodeRecord(record: DeploymentRecordV1): string;
|
|
32
|
-
/** Admit the text `record:<label>` holds as that label's record. */
|
|
33
|
-
export declare function decodeRecord(text: string, label: string): DeploymentRecordV1;
|
|
34
|
-
/**
|
|
35
|
-
* Whether two records declare the same deployment: same origin, environment, release, build,
|
|
36
|
-
* Schema revision, configuration and script. Only the commit may differ: a redeploy of identical
|
|
37
|
-
* bytes and configuration from another commit is the same deployment, whose record keeps the
|
|
38
|
-
* commit it was first activated from. This is the kit's one re-publication rule.
|
|
39
|
-
*/
|
|
40
|
-
export declare function sameRelease(left: DeploymentRecordV1, right: DeploymentRecordV1): boolean;
|
|
41
30
|
/** Admit the label `current:<name>` holds: a deployment label of that name's line. */
|
|
42
31
|
export declare function decodeCurrent(text: string, name: string): string;
|
|
43
32
|
/** Whether `label` is a deployment label whose line is `name`, as the dispatcher admits it. */
|
|
@@ -76,6 +65,16 @@ export declare class RoutingKv {
|
|
|
76
65
|
get(key: string): Promise<string | undefined>;
|
|
77
66
|
/** Write `text` at `key`, then read it back. */
|
|
78
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>;
|
|
79
78
|
/** Delete `key`, then read back its absence. */
|
|
80
79
|
delete(key: string): Promise<void>;
|
|
81
80
|
/**
|
|
@@ -84,6 +83,7 @@ export declare class RoutingKv {
|
|
|
84
83
|
*/
|
|
85
84
|
keys(prefix: string): Promise<readonly string[]>;
|
|
86
85
|
private value;
|
|
86
|
+
private path;
|
|
87
87
|
}
|
|
88
88
|
/** The id of the account's KV namespace with exactly this title, or undefined. */
|
|
89
89
|
export declare function findKvNamespace(client: CloudflareClient, title: string): Promise<string | undefined>;
|