@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/dist/hosting/kv.js
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { CloudflareApiError } from './client.js';
|
|
1
|
+
import { parseDeploymentLabel } from '@astrale-os/sdk/deployment/address';
|
|
2
|
+
import { CloudflareApiError, isNotFound } from './client.js';
|
|
4
3
|
import { canonicalUtcCrons } from './dispatcher/cron.mjs';
|
|
5
4
|
import { HostingRefusal } from './refusal.js';
|
|
6
5
|
/**
|
|
7
6
|
* The routing KV contract every dispatcher reads (`SCHEDULES`) and every writer of the kit keeps:
|
|
8
7
|
*
|
|
9
|
-
* | key | value |
|
|
8
|
+
* | key | value | metadata |
|
|
10
9
|
* |---|---|---|
|
|
11
|
-
* | `record:<label>` |
|
|
12
|
-
* | `retirement:<label>` | `withdrawn`; any value retires the label at the dispatcher |
|
|
13
|
-
* | `current:<name>` | the label a name serves now, one of `<name>-…` |
|
|
14
|
-
* | `cron:<name>` | JSON array of distinct canonical five-field UTC crons; deleted when empty |
|
|
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 |
|
|
15
14
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
18
|
*/
|
|
19
19
|
export const RECORD_PREFIX = 'record:';
|
|
20
20
|
export const RETIREMENT_PREFIX = 'retirement:';
|
|
@@ -38,39 +38,6 @@ export function currentKey(name) {
|
|
|
38
38
|
export function cronKey(name) {
|
|
39
39
|
return `${CRON_PREFIX}${name}`;
|
|
40
40
|
}
|
|
41
|
-
/** The text `record:<label>` holds: the record admitted, as RFC 8785 canonical JSON. */
|
|
42
|
-
export function encodeRecord(record) {
|
|
43
|
-
return json.serialize(acceptDeploymentRecord(record));
|
|
44
|
-
}
|
|
45
|
-
/** Admit the text `record:<label>` holds as that label's record. */
|
|
46
|
-
export function decodeRecord(text, label) {
|
|
47
|
-
let record;
|
|
48
|
-
try {
|
|
49
|
-
record = acceptDeploymentRecord(JSON.parse(text));
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
throw new HostingRefusal('record-invalid', `The record of ${label} is not a DeploymentRecordV1.`);
|
|
53
|
-
}
|
|
54
|
-
if (record.providerScript !== label) {
|
|
55
|
-
throw new HostingRefusal('record-invalid', `The record published under ${label} names the script ${record.providerScript}.`);
|
|
56
|
-
}
|
|
57
|
-
return record;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Whether two records declare the same deployment: same origin, environment, release, build,
|
|
61
|
-
* Schema revision, configuration and script. Only the commit may differ: a redeploy of identical
|
|
62
|
-
* bytes and configuration from another commit is the same deployment, whose record keeps the
|
|
63
|
-
* commit it was first activated from. This is the kit's one re-publication rule.
|
|
64
|
-
*/
|
|
65
|
-
export function sameRelease(left, right) {
|
|
66
|
-
return (left.origin === right.origin &&
|
|
67
|
-
left.environment === right.environment &&
|
|
68
|
-
left.releaseDigest === right.releaseDigest &&
|
|
69
|
-
left.buildDigest === right.buildDigest &&
|
|
70
|
-
left.schemaRevision === right.schemaRevision &&
|
|
71
|
-
left.configuration === right.configuration &&
|
|
72
|
-
left.providerScript === right.providerScript);
|
|
73
|
-
}
|
|
74
41
|
/** Admit the label `current:<name>` holds: a deployment label of that name's line. */
|
|
75
42
|
export function decodeCurrent(text, name) {
|
|
76
43
|
if (!isLabelOf(text, name)) {
|
|
@@ -159,6 +126,40 @@ export class RoutingKv {
|
|
|
159
126
|
if ((await this.get(key)) !== text)
|
|
160
127
|
notKept(key);
|
|
161
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
|
+
}
|
|
162
163
|
/** Delete `key`, then read back its absence. */
|
|
163
164
|
async delete(key) {
|
|
164
165
|
await this.client.text('DELETE', this.value(key));
|
|
@@ -181,7 +182,10 @@ export class RoutingKv {
|
|
|
181
182
|
}));
|
|
182
183
|
}
|
|
183
184
|
value(key) {
|
|
184
|
-
return this.
|
|
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)}`);
|
|
185
189
|
}
|
|
186
190
|
}
|
|
187
191
|
/** The id of the account's KV namespace with exactly this title, or undefined. */
|
|
@@ -1,19 +1,20 @@
|
|
|
1
|
+
import type { WorkerRecord } from '@astrale-os/sdk/deployment/worker';
|
|
1
2
|
import type { CloudflareClient } from './client.js';
|
|
2
3
|
import type { RoutingKv } from './kv.js';
|
|
3
4
|
import type { NamespaceScript } from './script.js';
|
|
4
5
|
/**
|
|
5
6
|
* One deployment of a line as the routing KV and the namespace hold it:
|
|
6
7
|
*
|
|
7
|
-
* - `record`: `record:<label>`
|
|
8
|
-
*
|
|
8
|
+
* - `record`: the record `record:<label>` publishes (its media type and bytes, never read), `null`
|
|
9
|
+
* when the deployment publishes none; absent when the label is not activated;
|
|
9
10
|
* - `retired`: whether `retirement:<label>` withdraws it;
|
|
10
11
|
* - `script`: the script under the label, absent once deleted (a completed retirement).
|
|
11
12
|
*
|
|
12
|
-
* The dispatcher serves it (`active`) exactly when it
|
|
13
|
+
* The dispatcher serves it (`active`) exactly when it is activated, has no tombstone and a script.
|
|
13
14
|
*/
|
|
14
15
|
export interface LineDeployment {
|
|
15
16
|
readonly label: string;
|
|
16
|
-
readonly record?:
|
|
17
|
+
readonly record?: WorkerRecord | null;
|
|
17
18
|
readonly retired: boolean;
|
|
18
19
|
readonly script?: NamespaceScript;
|
|
19
20
|
}
|
|
@@ -22,8 +23,8 @@ export interface LineDeployment {
|
|
|
22
23
|
* the routing KV, each with its script, in label order. A key there that names no deployment
|
|
23
24
|
* label is refused (`record-invalid`, `retirement-invalid`); one whose label is on another line,
|
|
24
25
|
* whose readable part happens to extend this one, is not this line's. A label that only has an
|
|
25
|
-
* uploaded script (never activated nor retired) is not listed: nothing in the KV names it.
|
|
26
|
-
* Nothing is written.
|
|
26
|
+
* uploaded script (never activated nor retired) is not listed: nothing in the KV names it. A stored
|
|
27
|
+
* record the kit refuses is refused (`record-invalid`). Nothing is written.
|
|
27
28
|
*/
|
|
28
29
|
export declare function listLineDeployments(input: {
|
|
29
30
|
readonly client: CloudflareClient;
|
package/dist/hosting/listing.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseDeploymentLabel } from '@astrale-os/sdk/deployment/address';
|
|
2
|
+
import { readRecord } from './activation.js';
|
|
2
3
|
import { CLOUDFLARE_FAN_OUT, mapBounded } from './bounded.js';
|
|
3
|
-
import { RECORD_PREFIX,
|
|
4
|
+
import { RECORD_PREFIX, RETIREMENT_PREFIX } from './kv.js';
|
|
4
5
|
import { HostingRefusal } from './refusal.js';
|
|
5
6
|
import { readRetirement } from './retirement.js';
|
|
6
7
|
import { observeScript } from './script.js';
|
|
@@ -9,8 +10,8 @@ import { observeScript } from './script.js';
|
|
|
9
10
|
* the routing KV, each with its script, in label order. A key there that names no deployment
|
|
10
11
|
* label is refused (`record-invalid`, `retirement-invalid`); one whose label is on another line,
|
|
11
12
|
* whose readable part happens to extend this one, is not this line's. A label that only has an
|
|
12
|
-
* uploaded script (never activated nor retired) is not listed: nothing in the KV names it.
|
|
13
|
-
* Nothing is written.
|
|
13
|
+
* uploaded script (never activated nor retired) is not listed: nothing in the KV names it. A stored
|
|
14
|
+
* record the kit refuses is refused (`record-invalid`). Nothing is written.
|
|
14
15
|
*/
|
|
15
16
|
export async function listLineDeployments(input) {
|
|
16
17
|
const { client, kv, namespace, line } = input;
|
|
@@ -19,13 +20,13 @@ export async function listLineDeployments(input) {
|
|
|
19
20
|
const activated = new Set(await labelsOf(kv, RECORD_PREFIX, line));
|
|
20
21
|
const labels = [...new Set([...activated, ...retired])].sort();
|
|
21
22
|
return Object.freeze(await mapBounded(labels, CLOUDFLARE_FAN_OUT, async (label) => {
|
|
22
|
-
const stored = activated.has(label) ? await kv
|
|
23
|
+
const stored = activated.has(label) ? await readRecord(kv, label) : undefined;
|
|
23
24
|
if (retired.has(label))
|
|
24
25
|
await readRetirement(kv, label);
|
|
25
26
|
const script = await observeScript(client, namespace, label);
|
|
26
27
|
return Object.freeze({
|
|
27
28
|
label,
|
|
28
|
-
...(stored === undefined ? {} : { record:
|
|
29
|
+
...(stored === undefined ? {} : { record: stored }),
|
|
29
30
|
retired: retired.has(label),
|
|
30
31
|
...(script === undefined ? {} : { script }),
|
|
31
32
|
});
|
|
@@ -47,11 +48,3 @@ async function labelsOf(kv, prefix, line) {
|
|
|
47
48
|
}
|
|
48
49
|
return labels;
|
|
49
50
|
}
|
|
50
|
-
function parseStored(text) {
|
|
51
|
-
try {
|
|
52
|
-
return JSON.parse(text);
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
return text;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
@@ -12,7 +12,7 @@ export declare class HostingRefusal extends Error {
|
|
|
12
12
|
| 'deployment-conflict'
|
|
13
13
|
/** The label is withdrawn and is never served again. */
|
|
14
14
|
| 'deployment-retired'
|
|
15
|
-
/** A stored `record:` is not
|
|
15
|
+
/** A stored `record:` is not a record the kit publishes (media type, bytes, metadata). */
|
|
16
16
|
| 'record-invalid'
|
|
17
17
|
/** A stored `retirement:` holds another value than `withdrawn`. */
|
|
18
18
|
| 'retirement-invalid'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { WorkerRecord } from '@astrale-os/sdk/deployment/worker';
|
|
2
2
|
import type { CloudflareClient } from './client.js';
|
|
3
3
|
import type { RoutingKv } from './kv.js';
|
|
4
4
|
import type { NamespaceScript } from './script.js';
|
|
@@ -49,10 +49,11 @@ export type RetirementCompletion = {
|
|
|
49
49
|
* it the key bound to it; until then delete nothing and answer `propagating`. The record stays at
|
|
50
50
|
* `record:<label>`.
|
|
51
51
|
*
|
|
52
|
-
* The script is deleted only when it is the caller's deployment: a stored record must
|
|
53
|
-
* caller
|
|
54
|
-
* must satisfy `owns` (its tags, its etag) when the caller passes
|
|
55
|
-
* (`conflict`). An absent script is already removed. Rerunning
|
|
52
|
+
* The script is deleted only when it is the caller's deployment: a stored record must be the one
|
|
53
|
+
* the caller published (`record`: the same media type and bytes, or `null` for none) when the
|
|
54
|
+
* caller passes it, and the script must satisfy `owns` (its tags, its etag) when the caller passes
|
|
55
|
+
* it; otherwise nothing is deleted (`conflict`). An absent script is already removed. Rerunning
|
|
56
|
+
* converges.
|
|
56
57
|
*/
|
|
57
58
|
export declare function completeRetirement(input: {
|
|
58
59
|
readonly client: CloudflareClient;
|
|
@@ -60,7 +61,7 @@ export declare function completeRetirement(input: {
|
|
|
60
61
|
readonly namespace: string;
|
|
61
62
|
readonly label: string;
|
|
62
63
|
readonly probe: () => Promise<boolean>;
|
|
63
|
-
readonly record?:
|
|
64
|
+
readonly record?: WorkerRecord | null;
|
|
64
65
|
readonly owns?: (script: NamespaceScript) => boolean;
|
|
65
66
|
/** When the tombstone was written (epoch ms), if the caller keeps it. */
|
|
66
67
|
readonly retiredAt?: number;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readRecord, sameRecord } from './activation.js';
|
|
2
|
+
import { retirementKey, WITHDRAWN } from './kv.js';
|
|
2
3
|
import { HostingRefusal } from './refusal.js';
|
|
3
4
|
import { deleteScript, observeScript } from './script.js';
|
|
4
5
|
/** The dispatcher's retirement probe path; it is never a call of the deployment. */
|
|
@@ -67,17 +68,18 @@ export async function retirementVisible(input) {
|
|
|
67
68
|
* it the key bound to it; until then delete nothing and answer `propagating`. The record stays at
|
|
68
69
|
* `record:<label>`.
|
|
69
70
|
*
|
|
70
|
-
* The script is deleted only when it is the caller's deployment: a stored record must
|
|
71
|
-
* caller
|
|
72
|
-
* must satisfy `owns` (its tags, its etag) when the caller passes
|
|
73
|
-
* (`conflict`). An absent script is already removed. Rerunning
|
|
71
|
+
* The script is deleted only when it is the caller's deployment: a stored record must be the one
|
|
72
|
+
* the caller published (`record`: the same media type and bytes, or `null` for none) when the
|
|
73
|
+
* caller passes it, and the script must satisfy `owns` (its tags, its etag) when the caller passes
|
|
74
|
+
* it; otherwise nothing is deleted (`conflict`). An absent script is already removed. Rerunning
|
|
75
|
+
* converges.
|
|
74
76
|
*/
|
|
75
77
|
export async function completeRetirement(input) {
|
|
76
78
|
const { client, label } = input;
|
|
77
79
|
if (input.record !== undefined) {
|
|
78
|
-
const published = await input.kv
|
|
79
|
-
if (published !== undefined && !
|
|
80
|
-
return conflict(`The record published for ${label}
|
|
80
|
+
const published = await readRecord(input.kv, label);
|
|
81
|
+
if (published !== undefined && !sameRecord(published, input.record)) {
|
|
82
|
+
return conflict(`The record published for ${label} is not this deployment's.`);
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
const script = await observeScript(client, input.namespace, label);
|
|
@@ -96,21 +98,6 @@ export async function completeRetirement(input) {
|
|
|
96
98
|
await deleteScript(client, input.namespace, label);
|
|
97
99
|
return Object.freeze({ kind: 'removed' });
|
|
98
100
|
}
|
|
99
|
-
function namesRelease(published, record) {
|
|
100
|
-
let value;
|
|
101
|
-
try {
|
|
102
|
-
value = JSON.parse(published);
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
const read = value;
|
|
108
|
-
return (read !== null &&
|
|
109
|
-
typeof read === 'object' &&
|
|
110
|
-
read.buildDigest === record.buildDigest &&
|
|
111
|
-
read.configuration === record.configuration &&
|
|
112
|
-
read.providerScript === record.providerScript);
|
|
113
|
-
}
|
|
114
101
|
function conflict(message) {
|
|
115
102
|
return Object.freeze({
|
|
116
103
|
kind: 'conflict',
|
package/dist/hosting/script.d.ts
CHANGED
|
@@ -77,8 +77,11 @@ export interface DeploymentBindingsInput {
|
|
|
77
77
|
readonly url: string;
|
|
78
78
|
/** The build digest of the bytes uploaded (`ASTRALE_BUILD_DIGEST`). */
|
|
79
79
|
readonly buildDigest: string;
|
|
80
|
-
/**
|
|
81
|
-
|
|
80
|
+
/**
|
|
81
|
+
* A generated Worker's execution settings, already encoded (`ASTRALE_SETTINGS`), bound after the
|
|
82
|
+
* build digest; none when absent (a Worker host binds a deployer's settings among its variables).
|
|
83
|
+
*/
|
|
84
|
+
readonly settings?: string;
|
|
82
85
|
/** The author's plain variables, bound in name order. */
|
|
83
86
|
readonly vars: Readonly<Record<string, string>>;
|
|
84
87
|
/** Service bindings, in the order given. */
|
|
@@ -93,27 +96,29 @@ export interface DeploymentBindingsInput {
|
|
|
93
96
|
}[];
|
|
94
97
|
/** The Worker the `ROUTER` service binding reaches; none when absent. */
|
|
95
98
|
readonly router?: string;
|
|
96
|
-
/** The private JWK text of its key (`ASTRALE_SIGNING_IDENTITY`). */
|
|
97
|
-
readonly signingIdentity
|
|
99
|
+
/** The private JWK text of its key (`ASTRALE_SIGNING_IDENTITY`); none when absent. */
|
|
100
|
+
readonly signingIdentity?: string;
|
|
98
101
|
/** Its secrets, bound in name order. */
|
|
99
102
|
readonly secrets: Readonly<Record<string, string>>;
|
|
100
103
|
}
|
|
101
104
|
/**
|
|
102
105
|
* The bindings of one new deployment, in their order: `WORKER_URL`, `ASTRALE_BUILD_DIGEST`,
|
|
103
|
-
* `ASTRALE_SETTINGS
|
|
104
|
-
* version metadata `ASTRALE_VERSION_METADATA`, the key
|
|
105
|
-
* secrets. A script upload with assets adds
|
|
106
|
-
* only a secret's value is rewritten in
|
|
106
|
+
* `ASTRALE_SETTINGS` (when given), the variables, the service and dispatch namespace bindings,
|
|
107
|
+
* `ROUTER` (when given), the version metadata `ASTRALE_VERSION_METADATA`, the key
|
|
108
|
+
* `ASTRALE_SIGNING_IDENTITY` (when given), then the secrets. A script upload with assets adds
|
|
109
|
+
* `ASSETS` last. Nothing ever adds a binding afterwards: only a secret's value is rewritten in
|
|
110
|
+
* place. A name bound twice is refused.
|
|
107
111
|
*/
|
|
108
112
|
export declare function deploymentBindings(input: DeploymentBindingsInput): readonly ScriptBinding[];
|
|
109
113
|
/**
|
|
110
114
|
* The tags of one deployment script: the caller's `extra` tags first (a host's own, such as
|
|
111
|
-
* Services' `astrale-service` and `astrale-install:`), then its artifact (build), line and
|
|
115
|
+
* Services' `astrale-service` and `astrale-install:`), then its artifact (build), line and, when
|
|
116
|
+
* it binds one, key.
|
|
112
117
|
*/
|
|
113
118
|
export declare function deploymentTags(input: {
|
|
114
119
|
readonly buildDigest: string;
|
|
115
120
|
readonly line: string;
|
|
116
|
-
readonly keyId
|
|
121
|
+
readonly keyId?: string;
|
|
117
122
|
readonly extra?: readonly string[];
|
|
118
123
|
}): readonly string[];
|
|
119
124
|
/** The artifact tag value of one build: the hexadecimal digest of its manifest. */
|
package/dist/hosting/script.js
CHANGED
|
@@ -28,16 +28,19 @@ const MODULE_PART_TYPES = Object.freeze({
|
|
|
28
28
|
});
|
|
29
29
|
/**
|
|
30
30
|
* The bindings of one new deployment, in their order: `WORKER_URL`, `ASTRALE_BUILD_DIGEST`,
|
|
31
|
-
* `ASTRALE_SETTINGS
|
|
32
|
-
* version metadata `ASTRALE_VERSION_METADATA`, the key
|
|
33
|
-
* secrets. A script upload with assets adds
|
|
34
|
-
* only a secret's value is rewritten in
|
|
31
|
+
* `ASTRALE_SETTINGS` (when given), the variables, the service and dispatch namespace bindings,
|
|
32
|
+
* `ROUTER` (when given), the version metadata `ASTRALE_VERSION_METADATA`, the key
|
|
33
|
+
* `ASTRALE_SIGNING_IDENTITY` (when given), then the secrets. A script upload with assets adds
|
|
34
|
+
* `ASSETS` last. Nothing ever adds a binding afterwards: only a secret's value is rewritten in
|
|
35
|
+
* place. A name bound twice is refused.
|
|
35
36
|
*/
|
|
36
37
|
export function deploymentBindings(input) {
|
|
37
38
|
const bindings = [
|
|
38
39
|
{ type: 'plain_text', name: WORKER_URL_BINDING, text: input.url },
|
|
39
40
|
{ type: 'plain_text', name: BUILD_DIGEST_BINDING, text: input.buildDigest },
|
|
40
|
-
|
|
41
|
+
...(input.settings === undefined
|
|
42
|
+
? []
|
|
43
|
+
: [{ type: 'plain_text', name: SETTINGS_BINDING, text: input.settings }]),
|
|
41
44
|
...Object.keys(input.vars)
|
|
42
45
|
.sort()
|
|
43
46
|
.map((name) => ({ type: 'plain_text', name, text: input.vars[name] })),
|
|
@@ -55,7 +58,15 @@ export function deploymentBindings(input) {
|
|
|
55
58
|
? []
|
|
56
59
|
: [{ type: 'service', name: ROUTER_BINDING, service: input.router }]),
|
|
57
60
|
{ type: 'version_metadata', name: VERSION_METADATA_BINDING },
|
|
58
|
-
|
|
61
|
+
...(input.signingIdentity === undefined
|
|
62
|
+
? []
|
|
63
|
+
: [
|
|
64
|
+
{
|
|
65
|
+
type: 'secret_text',
|
|
66
|
+
name: SIGNING_IDENTITY_BINDING,
|
|
67
|
+
text: input.signingIdentity,
|
|
68
|
+
},
|
|
69
|
+
]),
|
|
59
70
|
...Object.keys(input.secrets)
|
|
60
71
|
.sort()
|
|
61
72
|
.map((name) => ({ type: 'secret_text', name, text: input.secrets[name] })),
|
|
@@ -69,14 +80,15 @@ export function deploymentBindings(input) {
|
|
|
69
80
|
}
|
|
70
81
|
/**
|
|
71
82
|
* The tags of one deployment script: the caller's `extra` tags first (a host's own, such as
|
|
72
|
-
* Services' `astrale-service` and `astrale-install:`), then its artifact (build), line and
|
|
83
|
+
* Services' `astrale-service` and `astrale-install:`), then its artifact (build), line and, when
|
|
84
|
+
* it binds one, key.
|
|
73
85
|
*/
|
|
74
86
|
export function deploymentTags(input) {
|
|
75
87
|
return Object.freeze([
|
|
76
88
|
...(input.extra ?? []),
|
|
77
89
|
`${ARTIFACT_TAG_PREFIX}${artifactOf(input.buildDigest)}`,
|
|
78
90
|
`${LINE_TAG_PREFIX}${input.line}`,
|
|
79
|
-
`${KEY_TAG_PREFIX}${input.keyId}
|
|
91
|
+
...(input.keyId === undefined ? [] : [`${KEY_TAG_PREFIX}${input.keyId}`]),
|
|
80
92
|
]);
|
|
81
93
|
}
|
|
82
94
|
/** The artifact tag value of one build: the hexadecimal digest of its manifest. */
|
|
@@ -95,6 +95,8 @@ interface KvNamespace {
|
|
|
95
95
|
readonly id: string;
|
|
96
96
|
readonly title: string;
|
|
97
97
|
readonly values: Map<string, string>;
|
|
98
|
+
/** The metadata a value was written with; a value written without has none. */
|
|
99
|
+
readonly metadata: Map<string, unknown>;
|
|
98
100
|
}
|
|
99
101
|
interface Zone {
|
|
100
102
|
readonly id: string;
|
|
@@ -172,8 +174,15 @@ export declare class FakeCloudflare {
|
|
|
172
174
|
addKv(title: string): KvNamespace;
|
|
173
175
|
/** Add one zone named `name` (the fake's first zone is `FAKE_ZONE`). */
|
|
174
176
|
addZone(name: string): Zone;
|
|
175
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* The values of the KV namespace titled `title` (or with that id), as UTF-8 text: the fake keeps
|
|
179
|
+
* UTF-8 values only. A value set here has no metadata: a deployment record set here is one
|
|
180
|
+
* written before records were opaque.
|
|
181
|
+
*/
|
|
176
182
|
kv(titleOrId: string): Map<string, string>;
|
|
183
|
+
/** The metadata of the values of the KV namespace titled `title` (or with that id), by key. */
|
|
184
|
+
kvMetadata(titleOrId: string): Map<string, unknown>;
|
|
185
|
+
private kvNamespace;
|
|
177
186
|
/** The scripts of one dispatch namespace. */
|
|
178
187
|
scripts(namespace: string): Map<string, FakeScript>;
|
|
179
188
|
/** A provider version in the UUID form a Worker's version metadata reports. */
|
|
@@ -91,7 +91,12 @@ export class FakeCloudflare {
|
|
|
91
91
|
}
|
|
92
92
|
/** Add one KV namespace; its id is derived from its title. */
|
|
93
93
|
addKv(title) {
|
|
94
|
-
const kv = {
|
|
94
|
+
const kv = {
|
|
95
|
+
id: `kv${this.kvs.length}-${title}`.slice(0, 32),
|
|
96
|
+
title,
|
|
97
|
+
values: new Map(),
|
|
98
|
+
metadata: new Map(),
|
|
99
|
+
};
|
|
95
100
|
this.kvs.push(kv);
|
|
96
101
|
return kv;
|
|
97
102
|
}
|
|
@@ -102,12 +107,23 @@ export class FakeCloudflare {
|
|
|
102
107
|
this.zones.set(id, zone);
|
|
103
108
|
return zone;
|
|
104
109
|
}
|
|
105
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* The values of the KV namespace titled `title` (or with that id), as UTF-8 text: the fake keeps
|
|
112
|
+
* UTF-8 values only. A value set here has no metadata: a deployment record set here is one
|
|
113
|
+
* written before records were opaque.
|
|
114
|
+
*/
|
|
106
115
|
kv(titleOrId) {
|
|
116
|
+
return this.kvNamespace(titleOrId).values;
|
|
117
|
+
}
|
|
118
|
+
/** The metadata of the values of the KV namespace titled `title` (or with that id), by key. */
|
|
119
|
+
kvMetadata(titleOrId) {
|
|
120
|
+
return this.kvNamespace(titleOrId).metadata;
|
|
121
|
+
}
|
|
122
|
+
kvNamespace(titleOrId) {
|
|
107
123
|
const kv = this.kvs.find((candidate) => candidate.title === titleOrId || candidate.id === titleOrId);
|
|
108
124
|
if (kv === undefined)
|
|
109
125
|
throw new TypeError(`The fake account has no KV ${titleOrId}.`);
|
|
110
|
-
return kv
|
|
126
|
+
return kv;
|
|
111
127
|
}
|
|
112
128
|
/** The scripts of one dispatch namespace. */
|
|
113
129
|
scripts(namespace) {
|
|
@@ -189,14 +205,27 @@ export class FakeCloudflare {
|
|
|
189
205
|
};
|
|
190
206
|
}
|
|
191
207
|
kvBinding(id) {
|
|
192
|
-
const
|
|
208
|
+
const namespace = () => {
|
|
193
209
|
const kv = this.kvs.find((candidate) => candidate.id === id);
|
|
194
210
|
if (kv === undefined)
|
|
195
211
|
throw new Error('KV namespace not found.');
|
|
196
|
-
return kv
|
|
212
|
+
return kv;
|
|
197
213
|
};
|
|
214
|
+
const values = () => namespace().values;
|
|
198
215
|
return {
|
|
199
216
|
get: async (key) => values().get(key) ?? null,
|
|
217
|
+
getWithMetadata: async (key, type) => {
|
|
218
|
+
if (type !== 'arrayBuffer')
|
|
219
|
+
throw new TypeError('The fake reads metadata as bytes only.');
|
|
220
|
+
const value = values().get(key);
|
|
221
|
+
if (value === undefined)
|
|
222
|
+
return { value: null, metadata: null };
|
|
223
|
+
const bytes = new TextEncoder().encode(value);
|
|
224
|
+
return {
|
|
225
|
+
value: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
|
226
|
+
metadata: namespace().metadata.get(key) ?? null,
|
|
227
|
+
};
|
|
228
|
+
},
|
|
200
229
|
list: async ({ prefix, cursor }) => {
|
|
201
230
|
const names = [...values().keys()].filter((key) => key.startsWith(prefix)).sort();
|
|
202
231
|
const size = this.options.kvKeysPageSize ?? 1000;
|
|
@@ -317,6 +346,18 @@ export class FakeCloudflare {
|
|
|
317
346
|
response: envelope(page.map((name) => ({ name })), { count: page.length, cursor: next }),
|
|
318
347
|
};
|
|
319
348
|
}
|
|
349
|
+
const metadata = /^\/storage\/kv\/namespaces\/([^/]+)\/metadata\/(.+)$/u.exec(path);
|
|
350
|
+
if (metadata !== null && method === 'GET') {
|
|
351
|
+
const kv = this.kvs.find((candidate) => candidate.id === decodeURIComponent(metadata[1]));
|
|
352
|
+
if (kv === undefined)
|
|
353
|
+
return { response: failure(404, 10013, 'namespace not found') };
|
|
354
|
+
const key = decodeURIComponent(metadata[2]);
|
|
355
|
+
return {
|
|
356
|
+
response: kv.values.has(key)
|
|
357
|
+
? envelope(kv.metadata.get(key) ?? null)
|
|
358
|
+
: failure(404, 10009, 'get: key not found'),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
320
361
|
const value = /^\/storage\/kv\/namespaces\/([^/]+)\/values\/(.+)$/u.exec(path);
|
|
321
362
|
if (value !== null) {
|
|
322
363
|
const kv = this.kvs.find((candidate) => candidate.id === decodeURIComponent(value[1]));
|
|
@@ -332,15 +373,25 @@ export class FakeCloudflare {
|
|
|
332
373
|
};
|
|
333
374
|
}
|
|
334
375
|
if (method === 'PUT') {
|
|
376
|
+
const contentType = request.headers.get('content-type');
|
|
377
|
+
// A value written with metadata is a multipart form: its `value` and its JSON `metadata`.
|
|
378
|
+
if (contentType?.startsWith('multipart/form-data') === true) {
|
|
379
|
+
const form = await request.formData();
|
|
380
|
+
const part = form.get('value');
|
|
381
|
+
const written = typeof part === 'string' ? part : await utf8(part);
|
|
382
|
+
const declared = JSON.parse(String(form.get('metadata')));
|
|
383
|
+
kv.values.set(key, written);
|
|
384
|
+
kv.metadata.set(key, declared);
|
|
385
|
+
return { response: envelope(null), body: { key, value: written, metadata: declared } };
|
|
386
|
+
}
|
|
335
387
|
const text = await request.text();
|
|
336
388
|
kv.values.set(key, text);
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
body: { key, value: text, contentType: request.headers.get('content-type') },
|
|
340
|
-
};
|
|
389
|
+
kv.metadata.delete(key);
|
|
390
|
+
return { response: envelope(null), body: { key, value: text, contentType } };
|
|
341
391
|
}
|
|
342
392
|
if (method === 'DELETE') {
|
|
343
393
|
kv.values.delete(key);
|
|
394
|
+
kv.metadata.delete(key);
|
|
344
395
|
return { response: envelope(null), body: { key } };
|
|
345
396
|
}
|
|
346
397
|
}
|
|
@@ -697,3 +748,9 @@ function redact(metadata) {
|
|
|
697
748
|
: binding),
|
|
698
749
|
};
|
|
699
750
|
}
|
|
751
|
+
/** A value part as UTF-8 text: the fake keeps UTF-8 values only, and refuses any other bytes. */
|
|
752
|
+
async function utf8(part) {
|
|
753
|
+
if (part === null)
|
|
754
|
+
throw new TypeError('The fake KV write has no value.');
|
|
755
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(await part.arrayBuffer());
|
|
756
|
+
}
|