@astrale-os/sdk 0.6.0-beta.22 → 0.6.0-beta.23
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/dist/deployment/adapter/adapter.d.ts +8 -4
- package/dist/deployment/adapter/release/context.d.ts +3 -4
- package/dist/deployment/address/configuration.d.ts +12 -0
- package/dist/deployment/address/configuration.js +11 -0
- package/dist/deployment/address/index.d.ts +2 -2
- package/dist/deployment/address/index.js +2 -2
- package/dist/deployment/address/label.d.ts +5 -5
- package/dist/deployment/address/label.js +5 -5
- package/dist/deployment/address/record.d.ts +21 -0
- package/dist/deployment/address/record.js +34 -0
- package/dist/deployment/verify/index.d.ts +1 -1
- package/dist/deployment/verify/index.js +1 -1
- package/dist/deployment/verify/readiness/index.d.ts +7 -0
- package/dist/deployment/verify/readiness/index.js +17 -0
- package/dist/deployment/worker/address.d.ts +26 -0
- package/dist/deployment/worker/address.js +43 -0
- package/dist/deployment/worker/build.d.ts +60 -0
- package/dist/deployment/worker/build.js +172 -0
- package/dist/deployment/worker/configuration.d.ts +73 -0
- package/dist/deployment/worker/configuration.js +165 -0
- package/dist/deployment/worker/content.d.ts +38 -0
- package/dist/deployment/worker/content.js +169 -0
- package/dist/deployment/worker/index.d.ts +29 -0
- package/dist/deployment/worker/index.js +29 -0
- package/dist/deployment/worker/limits.d.ts +30 -0
- package/dist/deployment/worker/limits.js +30 -0
- package/dist/deployment/worker/record.d.ts +29 -0
- package/dist/deployment/worker/record.js +69 -0
- package/dist/deployment/worker/request.d.ts +118 -0
- package/dist/deployment/worker/request.js +280 -0
- package/dist/deployment/worker/response.d.ts +104 -0
- package/dist/deployment/worker/response.js +213 -0
- package/dist/tooling/cli/immutable-deployment.d.ts +2 -2
- package/package.json +5 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Digest } from '@astrale-os/kernel-protocol/artifact';
|
|
2
|
+
export declare const WORKER_CONFIGURATION_FORMAT = "astrale.worker.configuration";
|
|
3
|
+
export declare const WORKER_CONFIGURATION_VERSION = 1;
|
|
4
|
+
/** A Cloudflare Worker binding name. */
|
|
5
|
+
export declare const WORKER_BINDING_NAME: RegExp;
|
|
6
|
+
/**
|
|
7
|
+
* The bindings a Worker host binds itself on every deployment, whatever it asks: never the name
|
|
8
|
+
* of a variable or a secret.
|
|
9
|
+
*
|
|
10
|
+
* - `WORKER_URL`: the deployment URL, `https://<label>.<routing domain>`;
|
|
11
|
+
* - `ASTRALE_BUILD_DIGEST`: the build digest of the uploaded Worker (`workerBuildDigest`);
|
|
12
|
+
* - `ASTRALE_VERSION_METADATA`: the provider version metadata; a Worker that asks for readiness
|
|
13
|
+
* answers its `id` as `x-astrale-worker-version`;
|
|
14
|
+
* - `ASTRALE_SIGNING_IDENTITY`: the private Ed25519 JWK of the deployment, with `signingIdentity`;
|
|
15
|
+
* - `ROUTER`: the host's router service, with `router`;
|
|
16
|
+
* - `ASSETS`: the deployment's static assets, when it uploads some.
|
|
17
|
+
*/
|
|
18
|
+
export declare const WORKER_HOST_BINDINGS: readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* What one Worker deployment freezes beside its code: the Workers runtime it runs on, its plain
|
|
21
|
+
* variables, the names of its secrets (never their values) and what it asks of its host. Its
|
|
22
|
+
* digest and the build digest make the deployment's label, so a changed variable, flag, secret
|
|
23
|
+
* name or option is another deployment, and a rotated secret value is not.
|
|
24
|
+
*
|
|
25
|
+
* - `record`: the deployment serves the record its deployer publishes at
|
|
26
|
+
* `/.well-known/astrale/deployment.json` (the record's bytes are not part of the configuration:
|
|
27
|
+
* a deployment keeps the record it was created with);
|
|
28
|
+
* - `signingIdentity`: the host generates an Ed25519 key for the deployment alone and binds it as
|
|
29
|
+
* `ASTRALE_SIGNING_IDENTITY`;
|
|
30
|
+
* - `router`: the host binds its own router service as `ROUTER`. A deployment never names a
|
|
31
|
+
* service or a dispatch namespace itself.
|
|
32
|
+
*
|
|
33
|
+
* How its host probes readiness is not part of it: a probe changes nothing the deployment serves.
|
|
34
|
+
*/
|
|
35
|
+
export interface WorkerConfigurationV1 {
|
|
36
|
+
readonly format: typeof WORKER_CONFIGURATION_FORMAT;
|
|
37
|
+
readonly version: typeof WORKER_CONFIGURATION_VERSION;
|
|
38
|
+
readonly compatibilityDate: string;
|
|
39
|
+
/** Sorted, unique. */
|
|
40
|
+
readonly compatibilityFlags: readonly string[];
|
|
41
|
+
/** Plain variables, never secrets. */
|
|
42
|
+
readonly vars: Readonly<Record<string, string>>;
|
|
43
|
+
/** The names of the secrets the deployment binds, sorted and unique. */
|
|
44
|
+
readonly secrets: readonly string[];
|
|
45
|
+
readonly record: boolean;
|
|
46
|
+
readonly signingIdentity: boolean;
|
|
47
|
+
readonly router: boolean;
|
|
48
|
+
}
|
|
49
|
+
/** A configuration before its canonical order: lists in any order, options false when absent. */
|
|
50
|
+
export interface WorkerConfigurationInput {
|
|
51
|
+
readonly compatibilityDate: string;
|
|
52
|
+
readonly compatibilityFlags?: readonly string[];
|
|
53
|
+
readonly vars?: Readonly<Record<string, string>>;
|
|
54
|
+
readonly secrets?: readonly string[];
|
|
55
|
+
readonly record?: boolean;
|
|
56
|
+
readonly signingIdentity?: boolean;
|
|
57
|
+
readonly router?: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** Freeze one Worker configuration in the one canonical order its digest is taken in. */
|
|
60
|
+
export declare function workerConfiguration(input: WorkerConfigurationInput): WorkerConfigurationV1;
|
|
61
|
+
/**
|
|
62
|
+
* Admit one exact, canonical Worker configuration: every member, lists sorted and unique, binding
|
|
63
|
+
* names Cloudflare admits, distinct across variables and secrets, and none a host binds itself.
|
|
64
|
+
* Anything else throws.
|
|
65
|
+
*/
|
|
66
|
+
export declare function acceptWorkerConfiguration(input: unknown): WorkerConfigurationV1;
|
|
67
|
+
/**
|
|
68
|
+
* `sha256` of the canonical JSON (RFC 8785) of one admitted Worker configuration. A deployment's
|
|
69
|
+
* label keeps 80 bits of it with the build digest.
|
|
70
|
+
*/
|
|
71
|
+
export declare function workerConfigurationDigest(configuration: WorkerConfigurationV1): Digest;
|
|
72
|
+
/** Whether a configuration is a Worker configuration, by its format alone (not admitted). */
|
|
73
|
+
export declare function isWorkerConfiguration(input: unknown): input is WorkerConfigurationV1;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { json } from '@astrale-os/kernel-dsl/value';
|
|
2
|
+
import { identify } from '@astrale-os/kernel-protocol/artifact';
|
|
3
|
+
import { WORKER_DEPLOYMENT_LIMITS } from './limits.js';
|
|
4
|
+
export const WORKER_CONFIGURATION_FORMAT = 'astrale.worker.configuration';
|
|
5
|
+
export const WORKER_CONFIGURATION_VERSION = 1;
|
|
6
|
+
const MEDIA_TYPE = 'application/vnd.astrale.worker-configuration+json;v=1';
|
|
7
|
+
const COMPATIBILITY_DATE = /^\d{4}-\d{2}-\d{2}$/u;
|
|
8
|
+
const COMPATIBILITY_FLAG = /^[a-z0-9][a-z0-9_]{0,127}$/u;
|
|
9
|
+
/** A Cloudflare Worker binding name. */
|
|
10
|
+
export const WORKER_BINDING_NAME = /^[A-Za-z_$][A-Za-z0-9_$]{0,127}$/u;
|
|
11
|
+
/**
|
|
12
|
+
* The bindings a Worker host binds itself on every deployment, whatever it asks: never the name
|
|
13
|
+
* of a variable or a secret.
|
|
14
|
+
*
|
|
15
|
+
* - `WORKER_URL`: the deployment URL, `https://<label>.<routing domain>`;
|
|
16
|
+
* - `ASTRALE_BUILD_DIGEST`: the build digest of the uploaded Worker (`workerBuildDigest`);
|
|
17
|
+
* - `ASTRALE_VERSION_METADATA`: the provider version metadata; a Worker that asks for readiness
|
|
18
|
+
* answers its `id` as `x-astrale-worker-version`;
|
|
19
|
+
* - `ASTRALE_SIGNING_IDENTITY`: the private Ed25519 JWK of the deployment, with `signingIdentity`;
|
|
20
|
+
* - `ROUTER`: the host's router service, with `router`;
|
|
21
|
+
* - `ASSETS`: the deployment's static assets, when it uploads some.
|
|
22
|
+
*/
|
|
23
|
+
export const WORKER_HOST_BINDINGS = Object.freeze([
|
|
24
|
+
'WORKER_URL',
|
|
25
|
+
'ASTRALE_BUILD_DIGEST',
|
|
26
|
+
'ASTRALE_VERSION_METADATA',
|
|
27
|
+
'ASTRALE_SIGNING_IDENTITY',
|
|
28
|
+
'ROUTER',
|
|
29
|
+
'ASSETS',
|
|
30
|
+
]);
|
|
31
|
+
/** Freeze one Worker configuration in the one canonical order its digest is taken in. */
|
|
32
|
+
export function workerConfiguration(input) {
|
|
33
|
+
if (input === null || typeof input !== 'object')
|
|
34
|
+
invalid('it is not an object');
|
|
35
|
+
return acceptWorkerConfiguration({
|
|
36
|
+
format: WORKER_CONFIGURATION_FORMAT,
|
|
37
|
+
version: WORKER_CONFIGURATION_VERSION,
|
|
38
|
+
compatibilityDate: input.compatibilityDate,
|
|
39
|
+
compatibilityFlags: [...(input.compatibilityFlags ?? [])].sort(compare),
|
|
40
|
+
vars: sortedRecord(input.vars ?? {}),
|
|
41
|
+
secrets: [...(input.secrets ?? [])].sort(compare),
|
|
42
|
+
record: input.record ?? false,
|
|
43
|
+
signingIdentity: input.signingIdentity ?? false,
|
|
44
|
+
router: input.router ?? false,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Admit one exact, canonical Worker configuration: every member, lists sorted and unique, binding
|
|
49
|
+
* names Cloudflare admits, distinct across variables and secrets, and none a host binds itself.
|
|
50
|
+
* Anything else throws.
|
|
51
|
+
*/
|
|
52
|
+
export function acceptWorkerConfiguration(input) {
|
|
53
|
+
const value = exact(input, [
|
|
54
|
+
'format',
|
|
55
|
+
'version',
|
|
56
|
+
'compatibilityDate',
|
|
57
|
+
'compatibilityFlags',
|
|
58
|
+
'vars',
|
|
59
|
+
'secrets',
|
|
60
|
+
'record',
|
|
61
|
+
'signingIdentity',
|
|
62
|
+
'router',
|
|
63
|
+
]);
|
|
64
|
+
if (value.format !== WORKER_CONFIGURATION_FORMAT ||
|
|
65
|
+
value.version !== WORKER_CONFIGURATION_VERSION) {
|
|
66
|
+
invalid('its format is not astrale.worker.configuration v1');
|
|
67
|
+
}
|
|
68
|
+
if (!isDate(value.compatibilityDate))
|
|
69
|
+
invalid('its compatibility date is not YYYY-MM-DD');
|
|
70
|
+
const flags = sortedNames(value.compatibilityFlags, 'compatibility flags', COMPATIBILITY_FLAG, WORKER_DEPLOYMENT_LIMITS.compatibilityFlags);
|
|
71
|
+
const vars = variables(value.vars);
|
|
72
|
+
const secrets = sortedNames(value.secrets, 'secret names', WORKER_BINDING_NAME, WORKER_DEPLOYMENT_LIMITS.secrets);
|
|
73
|
+
const names = [...Object.keys(vars), ...secrets];
|
|
74
|
+
if (new Set(names).size !== names.length)
|
|
75
|
+
invalid('a variable and a secret share a name');
|
|
76
|
+
const reserved = names.filter((name) => WORKER_HOST_BINDINGS.includes(name));
|
|
77
|
+
if (reserved.length > 0)
|
|
78
|
+
invalid(`${reserved.join(', ')} is bound by the host`);
|
|
79
|
+
for (const option of ['record', 'signingIdentity', 'router']) {
|
|
80
|
+
if (typeof value[option] !== 'boolean')
|
|
81
|
+
invalid(`its ${option} option is not a boolean`);
|
|
82
|
+
}
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
format: WORKER_CONFIGURATION_FORMAT,
|
|
85
|
+
version: WORKER_CONFIGURATION_VERSION,
|
|
86
|
+
compatibilityDate: value.compatibilityDate,
|
|
87
|
+
compatibilityFlags: flags,
|
|
88
|
+
vars,
|
|
89
|
+
secrets,
|
|
90
|
+
record: value.record,
|
|
91
|
+
signingIdentity: value.signingIdentity,
|
|
92
|
+
router: value.router,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* `sha256` of the canonical JSON (RFC 8785) of one admitted Worker configuration. A deployment's
|
|
97
|
+
* label keeps 80 bits of it with the build digest.
|
|
98
|
+
*/
|
|
99
|
+
export function workerConfigurationDigest(configuration) {
|
|
100
|
+
return identify(json.encode(acceptWorkerConfiguration(configuration)), MEDIA_TYPE).digest;
|
|
101
|
+
}
|
|
102
|
+
/** Whether a configuration is a Worker configuration, by its format alone (not admitted). */
|
|
103
|
+
export function isWorkerConfiguration(input) {
|
|
104
|
+
return (input !== null &&
|
|
105
|
+
typeof input === 'object' &&
|
|
106
|
+
input.format === WORKER_CONFIGURATION_FORMAT);
|
|
107
|
+
}
|
|
108
|
+
function variables(input) {
|
|
109
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
110
|
+
invalid('its variables are not an object');
|
|
111
|
+
}
|
|
112
|
+
const keys = Reflect.ownKeys(input);
|
|
113
|
+
if (keys.length > WORKER_DEPLOYMENT_LIMITS.vars)
|
|
114
|
+
invalid('it has too many variables');
|
|
115
|
+
const entries = [];
|
|
116
|
+
for (const name of keys) {
|
|
117
|
+
const text = input[name];
|
|
118
|
+
if (typeof name !== 'string' || !WORKER_BINDING_NAME.test(name)) {
|
|
119
|
+
invalid('a variable name is not a Worker binding name');
|
|
120
|
+
}
|
|
121
|
+
if (typeof text !== 'string' || text.length > WORKER_DEPLOYMENT_LIMITS.valueLength) {
|
|
122
|
+
invalid(`variable ${name} is not text within its size limit`);
|
|
123
|
+
}
|
|
124
|
+
entries.push([name, text]);
|
|
125
|
+
}
|
|
126
|
+
return sortedRecord(Object.fromEntries(entries));
|
|
127
|
+
}
|
|
128
|
+
function sortedRecord(input) {
|
|
129
|
+
return Object.freeze(Object.fromEntries(Object.entries(input).sort(([left], [right]) => compare(left, right))));
|
|
130
|
+
}
|
|
131
|
+
function sortedNames(input, label, pattern, maximum) {
|
|
132
|
+
if (!Array.isArray(input) || input.length > maximum)
|
|
133
|
+
invalid(`its ${label} are invalid`);
|
|
134
|
+
for (const [index, name] of input.entries()) {
|
|
135
|
+
if (typeof name !== 'string' || !pattern.test(name))
|
|
136
|
+
invalid(`its ${label} are invalid`);
|
|
137
|
+
if (index > 0 && compare(input[index - 1], name) >= 0) {
|
|
138
|
+
invalid(`its ${label} are not sorted and unique`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return Object.freeze([...input]);
|
|
142
|
+
}
|
|
143
|
+
/** A calendar date written `YYYY-MM-DD`, as a Worker compatibility date is. */
|
|
144
|
+
function isDate(input) {
|
|
145
|
+
if (typeof input !== 'string' || !COMPATIBILITY_DATE.test(input))
|
|
146
|
+
return false;
|
|
147
|
+
const time = Date.parse(`${input}T00:00:00Z`);
|
|
148
|
+
return !Number.isNaN(time) && new Date(time).toISOString().slice(0, 10) === input;
|
|
149
|
+
}
|
|
150
|
+
function exact(input, keys) {
|
|
151
|
+
if (input === null ||
|
|
152
|
+
typeof input !== 'object' ||
|
|
153
|
+
Array.isArray(input) ||
|
|
154
|
+
Reflect.ownKeys(input).length !== keys.length ||
|
|
155
|
+
keys.some((key) => !Object.hasOwn(input, key))) {
|
|
156
|
+
invalid(`its members are not exactly ${keys.join(', ')}`);
|
|
157
|
+
}
|
|
158
|
+
return input;
|
|
159
|
+
}
|
|
160
|
+
function compare(left, right) {
|
|
161
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
162
|
+
}
|
|
163
|
+
function invalid(reason) {
|
|
164
|
+
throw new TypeError(`Worker configuration is invalid: ${reason}.`);
|
|
165
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One file of a Worker deployment on the wire: its bytes as portable Base64, gzip-compressed when
|
|
3
|
+
* that is smaller, and their decoded size. The encoding is transport only: every digest and every
|
|
4
|
+
* upload describes the decoded bytes. Base64 longer than one portable chunk travels in chunks.
|
|
5
|
+
*/
|
|
6
|
+
export type WorkerContentV1 = {
|
|
7
|
+
readonly encoding: 'identity' | 'gzip';
|
|
8
|
+
readonly decodedSize: number;
|
|
9
|
+
} & ({
|
|
10
|
+
readonly contentBase64: string;
|
|
11
|
+
} | {
|
|
12
|
+
readonly contentBase64Chunks: readonly string[];
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* Encode one file for the wire: gzip when that is smaller, identity otherwise. Compression runs
|
|
16
|
+
* on `CompressionStream`, in Node and in a Worker alike.
|
|
17
|
+
*/
|
|
18
|
+
export declare function encodeWorkerContent(bytes: Uint8Array): Promise<WorkerContentV1>;
|
|
19
|
+
/**
|
|
20
|
+
* Decode one file from the wire, refusing anything that is not exactly this envelope: canonical
|
|
21
|
+
* Base64 within `base64Size` characters (chunks of at most one portable chunk each), a declared
|
|
22
|
+
* size within `limit`, and a gzip stream that inflates to exactly that size. Inflation is bounded
|
|
23
|
+
* by the declared size as it runs, so a high-ratio stream is stopped before it allocates more.
|
|
24
|
+
*/
|
|
25
|
+
export declare function decodeWorkerContent(input: unknown, limits: {
|
|
26
|
+
readonly size: number;
|
|
27
|
+
readonly base64Size: number;
|
|
28
|
+
}): Promise<Uint8Array>;
|
|
29
|
+
/** Keep every wire string below Core's portable Value string limit. */
|
|
30
|
+
export declare function portableBase64(bytes: Uint8Array): {
|
|
31
|
+
readonly contentBase64: string;
|
|
32
|
+
} | {
|
|
33
|
+
readonly contentBase64Chunks: readonly string[];
|
|
34
|
+
};
|
|
35
|
+
/** Standard Base64 with padding, without Node's `Buffer`. */
|
|
36
|
+
export declare function toBase64(bytes: Uint8Array): string;
|
|
37
|
+
/** Canonical standard Base64 only: the encoding `toBase64` writes for the same bytes. */
|
|
38
|
+
export declare function fromBase64(encoded: string): Uint8Array<ArrayBuffer>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { WORKER_DEPLOYMENT_LIMITS } from './limits.js';
|
|
2
|
+
/** Gzip's header and trailer cost twenty bytes: smaller files are never worth compressing. */
|
|
3
|
+
const GZIP_THRESHOLD = 64;
|
|
4
|
+
/** The compressed bytes fed to the inflater at a time, so an overflow is caught early. */
|
|
5
|
+
const INFLATE_CHUNK = 4 * 1024;
|
|
6
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/u;
|
|
7
|
+
/**
|
|
8
|
+
* Encode one file for the wire: gzip when that is smaller, identity otherwise. Compression runs
|
|
9
|
+
* on `CompressionStream`, in Node and in a Worker alike.
|
|
10
|
+
*/
|
|
11
|
+
export async function encodeWorkerContent(bytes) {
|
|
12
|
+
const compressed = bytes.byteLength < GZIP_THRESHOLD ? undefined : await gzip(bytes);
|
|
13
|
+
const smaller = compressed !== undefined && compressed.byteLength < bytes.byteLength;
|
|
14
|
+
return Object.freeze({
|
|
15
|
+
encoding: smaller ? 'gzip' : 'identity',
|
|
16
|
+
decodedSize: bytes.byteLength,
|
|
17
|
+
...portableBase64(smaller ? compressed : bytes),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Decode one file from the wire, refusing anything that is not exactly this envelope: canonical
|
|
22
|
+
* Base64 within `base64Size` characters (chunks of at most one portable chunk each), a declared
|
|
23
|
+
* size within `limit`, and a gzip stream that inflates to exactly that size. Inflation is bounded
|
|
24
|
+
* by the declared size as it runs, so a high-ratio stream is stopped before it allocates more.
|
|
25
|
+
*/
|
|
26
|
+
export async function decodeWorkerContent(input, limits) {
|
|
27
|
+
const value = record(input);
|
|
28
|
+
const chunked = Object.hasOwn(value, 'contentBase64Chunks');
|
|
29
|
+
exact(value, ['encoding', 'decodedSize', chunked ? 'contentBase64Chunks' : 'contentBase64']);
|
|
30
|
+
const { encoding, decodedSize } = value;
|
|
31
|
+
if (encoding !== 'identity' && encoding !== 'gzip')
|
|
32
|
+
invalid('its encoding is unknown');
|
|
33
|
+
if (!Number.isSafeInteger(decodedSize) || decodedSize < 0) {
|
|
34
|
+
invalid('its decoded size is invalid');
|
|
35
|
+
}
|
|
36
|
+
if (decodedSize > limits.size)
|
|
37
|
+
invalid('it exceeds its size limit');
|
|
38
|
+
const encoded = joined(value, chunked, limits.base64Size);
|
|
39
|
+
const bytes = fromBase64(encoded);
|
|
40
|
+
if (encoding === 'gzip')
|
|
41
|
+
return inflate(bytes, decodedSize);
|
|
42
|
+
if (bytes.byteLength !== decodedSize)
|
|
43
|
+
invalid('it differs from its declared size');
|
|
44
|
+
return bytes;
|
|
45
|
+
}
|
|
46
|
+
/** Keep every wire string below Core's portable Value string limit. */
|
|
47
|
+
export function portableBase64(bytes) {
|
|
48
|
+
const encoded = toBase64(bytes);
|
|
49
|
+
const size = WORKER_DEPLOYMENT_LIMITS.base64ChunkSize;
|
|
50
|
+
if (encoded.length <= size)
|
|
51
|
+
return Object.freeze({ contentBase64: encoded });
|
|
52
|
+
const chunks = [];
|
|
53
|
+
for (let offset = 0; offset < encoded.length; offset += size) {
|
|
54
|
+
chunks.push(encoded.slice(offset, offset + size));
|
|
55
|
+
}
|
|
56
|
+
return Object.freeze({ contentBase64Chunks: Object.freeze(chunks) });
|
|
57
|
+
}
|
|
58
|
+
/** Standard Base64 with padding, without Node's `Buffer`. */
|
|
59
|
+
export function toBase64(bytes) {
|
|
60
|
+
let binary = '';
|
|
61
|
+
for (let offset = 0; offset < bytes.byteLength; offset += 0x8000) {
|
|
62
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
|
63
|
+
}
|
|
64
|
+
return btoa(binary);
|
|
65
|
+
}
|
|
66
|
+
/** Canonical standard Base64 only: the encoding `toBase64` writes for the same bytes. */
|
|
67
|
+
export function fromBase64(encoded) {
|
|
68
|
+
if (encoded.length % 4 !== 0 || !BASE64.test(encoded))
|
|
69
|
+
invalid('it is not canonical Base64');
|
|
70
|
+
let binary;
|
|
71
|
+
try {
|
|
72
|
+
binary = atob(encoded);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
invalid('it is not canonical Base64');
|
|
76
|
+
}
|
|
77
|
+
const bytes = new Uint8Array(binary.length);
|
|
78
|
+
for (let index = 0; index < binary.length; index += 1)
|
|
79
|
+
bytes[index] = binary.charCodeAt(index);
|
|
80
|
+
if (btoa(binary) !== encoded)
|
|
81
|
+
invalid('it is not canonical Base64');
|
|
82
|
+
return bytes;
|
|
83
|
+
}
|
|
84
|
+
function joined(value, chunked, maximum) {
|
|
85
|
+
if (!chunked) {
|
|
86
|
+
const text = value.contentBase64;
|
|
87
|
+
if (typeof text !== 'string' || text.length > maximum)
|
|
88
|
+
invalid('its Base64 is too long');
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
const chunks = value.contentBase64Chunks;
|
|
92
|
+
const size = WORKER_DEPLOYMENT_LIMITS.base64ChunkSize;
|
|
93
|
+
if (!Array.isArray(chunks) || chunks.length === 0 || chunks.length > Math.ceil(maximum / size)) {
|
|
94
|
+
invalid('its Base64 chunks are invalid');
|
|
95
|
+
}
|
|
96
|
+
let length = 0;
|
|
97
|
+
for (const chunk of chunks) {
|
|
98
|
+
if (typeof chunk !== 'string' || chunk.length === 0 || chunk.length > size) {
|
|
99
|
+
invalid('its Base64 chunks are invalid');
|
|
100
|
+
}
|
|
101
|
+
length += chunk.length;
|
|
102
|
+
}
|
|
103
|
+
if (length > maximum)
|
|
104
|
+
invalid('its Base64 is too long');
|
|
105
|
+
return chunks.join('');
|
|
106
|
+
}
|
|
107
|
+
async function gzip(bytes) {
|
|
108
|
+
const stream = new Blob([bytes])
|
|
109
|
+
.stream()
|
|
110
|
+
.pipeThrough(new CompressionStream('gzip'));
|
|
111
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
112
|
+
}
|
|
113
|
+
async function inflate(bytes, decodedSize) {
|
|
114
|
+
// Preallocate only the admitted size and copy as bytes arrive: never collect an unbounded inflate.
|
|
115
|
+
const decoded = new Uint8Array(decodedSize);
|
|
116
|
+
let offset = 0;
|
|
117
|
+
const source = new ReadableStream({
|
|
118
|
+
pull(controller) {
|
|
119
|
+
if (offset === bytes.byteLength) {
|
|
120
|
+
controller.close();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const end = Math.min(offset + INFLATE_CHUNK, bytes.byteLength);
|
|
124
|
+
controller.enqueue(bytes.subarray(offset, end));
|
|
125
|
+
offset = end;
|
|
126
|
+
},
|
|
127
|
+
}, { highWaterMark: 0 });
|
|
128
|
+
const reader = source.pipeThrough(new DecompressionStream('gzip')).getReader();
|
|
129
|
+
let written = 0;
|
|
130
|
+
try {
|
|
131
|
+
for (;;) {
|
|
132
|
+
const { value, done } = await reader.read();
|
|
133
|
+
if (done)
|
|
134
|
+
break;
|
|
135
|
+
if (value.byteLength > decodedSize - written)
|
|
136
|
+
invalid('it exceeds its declared size');
|
|
137
|
+
decoded.set(value, written);
|
|
138
|
+
written += value.byteLength;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (cause) {
|
|
142
|
+
await reader.cancel(cause).catch(() => undefined);
|
|
143
|
+
if (cause instanceof TypeError && cause.message.startsWith(PREFIX))
|
|
144
|
+
throw cause;
|
|
145
|
+
throw new TypeError(`${PREFIX} its gzip stream is invalid.`, { cause });
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
reader.releaseLock();
|
|
149
|
+
}
|
|
150
|
+
if (written !== decodedSize)
|
|
151
|
+
invalid('it differs from its declared size');
|
|
152
|
+
return decoded;
|
|
153
|
+
}
|
|
154
|
+
const PREFIX = 'Worker deployment content is invalid:';
|
|
155
|
+
function record(input) {
|
|
156
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
157
|
+
invalid('it is not an object');
|
|
158
|
+
}
|
|
159
|
+
return input;
|
|
160
|
+
}
|
|
161
|
+
function exact(value, keys) {
|
|
162
|
+
const own = Reflect.ownKeys(value);
|
|
163
|
+
if (own.length !== keys.length || keys.some((key) => !Object.hasOwn(value, key))) {
|
|
164
|
+
invalid('its members are not exactly encoding, decodedSize and its Base64');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function invalid(reason) {
|
|
168
|
+
throw new TypeError(`${PREFIX} ${reason}.`);
|
|
169
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@astrale-os/sdk/deployment/worker` — the contract of a Worker host (Admin's Services), agnostic
|
|
3
|
+
* of what the Worker serves: an Astrale Domain release, or any Cloudflare Worker (a cron job, an
|
|
4
|
+
* API). A deployment is one Worker release in the Service of a project and an environment:
|
|
5
|
+
*
|
|
6
|
+
* - its code (`WorkerCode`): modules, a main module, static assets, and the build manifest members
|
|
7
|
+
* its deployer declares; `workerBuildDigest` identifies it, and its host binds that digest as
|
|
8
|
+
* `ASTRALE_BUILD_DIGEST`;
|
|
9
|
+
* - its configuration (`WorkerConfigurationV1`): the Workers runtime, plain variables, the names of
|
|
10
|
+
* its secrets, and what it asks of its host (`record`, `signingIdentity`, `router`);
|
|
11
|
+
* - its label, `<service>-<content>` (`workerDeploymentAddress`): the Service name derives from the
|
|
12
|
+
* project and environment, the content from the two digests, so the same release reuses its
|
|
13
|
+
* deployment;
|
|
14
|
+
* - its record (`WorkerRecord`): opaque bytes and a media type its host serves verbatim at
|
|
15
|
+
* `/.well-known/astrale/deployment.json`;
|
|
16
|
+
* - readiness paths its host probes before activation, and secret values it never stores.
|
|
17
|
+
*
|
|
18
|
+
* `encodeWorkerDeploymentRequest` builds the request a deployer sends, and
|
|
19
|
+
* `decodeWorkerDeploymentRequest` admits it as a host does; the response decoders admit what a
|
|
20
|
+
* host answers.
|
|
21
|
+
*/
|
|
22
|
+
export { PROJECT_NAME, serviceName, workerDeploymentLabel } from './address.js';
|
|
23
|
+
export { acceptWorkerCode, WORKER_BUILD_MANIFEST, workerBuildDigest, workerBuildManifest, type WorkerAsset, type WorkerBuildManifest, type WorkerCode, type WorkerModule, type WorkerModuleKind, } from './build.js';
|
|
24
|
+
export { acceptWorkerConfiguration, isWorkerConfiguration, WORKER_BINDING_NAME, WORKER_CONFIGURATION_FORMAT, WORKER_CONFIGURATION_VERSION, WORKER_HOST_BINDINGS, workerConfiguration, workerConfigurationDigest, type WorkerConfigurationInput, type WorkerConfigurationV1, } from './configuration.js';
|
|
25
|
+
export { decodeWorkerContent, encodeWorkerContent, type WorkerContentV1 } from './content.js';
|
|
26
|
+
export { WORKER_DEPLOYMENT_LIMITS } from './limits.js';
|
|
27
|
+
export { acceptWorkerRecord, decodeWorkerRecord, encodeWorkerRecord, sameWorkerRecord, WORKER_RECORD_PATH, type WorkerRecord, type WorkerRecordWireV1, } from './record.js';
|
|
28
|
+
export { decodeWorkerDeploymentRequest, encodeWorkerDeploymentRequest, WORKER_DEPLOYMENT_REQUEST_FORMAT, WORKER_DEPLOYMENT_REQUEST_VERSION, workerDeploymentAddress, WorkerDeploymentRefusal, type WorkerCodeWireV1, type WorkerDeployment, type WorkerDeploymentAddress, type WorkerDeploymentInput, type WorkerDeploymentRequestV1, } from './request.js';
|
|
29
|
+
export { decodeWorkerDeploymentList, decodeWorkerDeploymentResponse, decodeWorkerDeploymentSummary, decodeWorkerHosting, WORKER_DEPLOYMENT_REJECTIONS, type WorkerDeploymentListRequestV1, type WorkerDeploymentRejection, type WorkerDeploymentRejectionReason, type WorkerDeploymentResponseV1, type WorkerDeploymentResult, type WorkerDeploymentState, type WorkerDeploymentSummary, type WorkerDeploymentSummaryV1, type WorkerHostingV1, } from './response.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@astrale-os/sdk/deployment/worker` — the contract of a Worker host (Admin's Services), agnostic
|
|
3
|
+
* of what the Worker serves: an Astrale Domain release, or any Cloudflare Worker (a cron job, an
|
|
4
|
+
* API). A deployment is one Worker release in the Service of a project and an environment:
|
|
5
|
+
*
|
|
6
|
+
* - its code (`WorkerCode`): modules, a main module, static assets, and the build manifest members
|
|
7
|
+
* its deployer declares; `workerBuildDigest` identifies it, and its host binds that digest as
|
|
8
|
+
* `ASTRALE_BUILD_DIGEST`;
|
|
9
|
+
* - its configuration (`WorkerConfigurationV1`): the Workers runtime, plain variables, the names of
|
|
10
|
+
* its secrets, and what it asks of its host (`record`, `signingIdentity`, `router`);
|
|
11
|
+
* - its label, `<service>-<content>` (`workerDeploymentAddress`): the Service name derives from the
|
|
12
|
+
* project and environment, the content from the two digests, so the same release reuses its
|
|
13
|
+
* deployment;
|
|
14
|
+
* - its record (`WorkerRecord`): opaque bytes and a media type its host serves verbatim at
|
|
15
|
+
* `/.well-known/astrale/deployment.json`;
|
|
16
|
+
* - readiness paths its host probes before activation, and secret values it never stores.
|
|
17
|
+
*
|
|
18
|
+
* `encodeWorkerDeploymentRequest` builds the request a deployer sends, and
|
|
19
|
+
* `decodeWorkerDeploymentRequest` admits it as a host does; the response decoders admit what a
|
|
20
|
+
* host answers.
|
|
21
|
+
*/
|
|
22
|
+
export { PROJECT_NAME, serviceName, workerDeploymentLabel } from './address.js';
|
|
23
|
+
export { acceptWorkerCode, WORKER_BUILD_MANIFEST, workerBuildDigest, workerBuildManifest, } from './build.js';
|
|
24
|
+
export { acceptWorkerConfiguration, isWorkerConfiguration, WORKER_BINDING_NAME, WORKER_CONFIGURATION_FORMAT, WORKER_CONFIGURATION_VERSION, WORKER_HOST_BINDINGS, workerConfiguration, workerConfigurationDigest, } from './configuration.js';
|
|
25
|
+
export { decodeWorkerContent, encodeWorkerContent } from './content.js';
|
|
26
|
+
export { WORKER_DEPLOYMENT_LIMITS } from './limits.js';
|
|
27
|
+
export { acceptWorkerRecord, decodeWorkerRecord, encodeWorkerRecord, sameWorkerRecord, WORKER_RECORD_PATH, } from './record.js';
|
|
28
|
+
export { decodeWorkerDeploymentRequest, encodeWorkerDeploymentRequest, WORKER_DEPLOYMENT_REQUEST_FORMAT, WORKER_DEPLOYMENT_REQUEST_VERSION, workerDeploymentAddress, WorkerDeploymentRefusal, } from './request.js';
|
|
29
|
+
export { decodeWorkerDeploymentList, decodeWorkerDeploymentResponse, decodeWorkerDeploymentSummary, decodeWorkerHosting, WORKER_DEPLOYMENT_REJECTIONS, } from './response.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bounds of one Worker deployment on the wire and once decoded. A host refuses a request past
|
|
3
|
+
* any of them before it reads or writes anything; a deployer refuses one before it sends it.
|
|
4
|
+
*/
|
|
5
|
+
export declare const WORKER_DEPLOYMENT_LIMITS: Readonly<{
|
|
6
|
+
/** Every wire string stays below Core's portable Value string limit. */
|
|
7
|
+
base64ChunkSize: number;
|
|
8
|
+
/** The Base64 of one module, chunks joined. */
|
|
9
|
+
moduleBase64Size: number;
|
|
10
|
+
/** The Base64 of one asset, chunks joined. */
|
|
11
|
+
assetBase64Size: number;
|
|
12
|
+
/** The decoded bytes of one module. */
|
|
13
|
+
moduleSize: number;
|
|
14
|
+
/** The decoded bytes of one asset. */
|
|
15
|
+
assetSize: number;
|
|
16
|
+
/** The decoded bytes of every module and asset of one Worker together. */
|
|
17
|
+
workerSize: number;
|
|
18
|
+
modules: 40;
|
|
19
|
+
assets: 2000;
|
|
20
|
+
/** The bytes of one deployment record. */
|
|
21
|
+
recordSize: number;
|
|
22
|
+
/** The canonical JSON bytes of the build manifest members a deployer declares. */
|
|
23
|
+
manifestSize: number;
|
|
24
|
+
vars: 128;
|
|
25
|
+
/** The UTF-16 length of one variable or secret value. */
|
|
26
|
+
valueLength: number;
|
|
27
|
+
secrets: 64;
|
|
28
|
+
compatibilityFlags: 64;
|
|
29
|
+
readinessPaths: 8;
|
|
30
|
+
}>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bounds of one Worker deployment on the wire and once decoded. A host refuses a request past
|
|
3
|
+
* any of them before it reads or writes anything; a deployer refuses one before it sends it.
|
|
4
|
+
*/
|
|
5
|
+
export const WORKER_DEPLOYMENT_LIMITS = Object.freeze({
|
|
6
|
+
/** Every wire string stays below Core's portable Value string limit. */
|
|
7
|
+
base64ChunkSize: 512 * 1024,
|
|
8
|
+
/** The Base64 of one module, chunks joined. */
|
|
9
|
+
moduleBase64Size: 10 * 1024 * 1024,
|
|
10
|
+
/** The Base64 of one asset, chunks joined. */
|
|
11
|
+
assetBase64Size: 16 * 1024 * 1024,
|
|
12
|
+
/** The decoded bytes of one module. */
|
|
13
|
+
moduleSize: ((10 * 1024 * 1024) / 4) * 3,
|
|
14
|
+
/** The decoded bytes of one asset. */
|
|
15
|
+
assetSize: ((16 * 1024 * 1024) / 4) * 3,
|
|
16
|
+
/** The decoded bytes of every module and asset of one Worker together. */
|
|
17
|
+
workerSize: 32 * 1024 * 1024,
|
|
18
|
+
modules: 40,
|
|
19
|
+
assets: 2_000,
|
|
20
|
+
/** The bytes of one deployment record. */
|
|
21
|
+
recordSize: 64 * 1024,
|
|
22
|
+
/** The canonical JSON bytes of the build manifest members a deployer declares. */
|
|
23
|
+
manifestSize: 4 * 1024,
|
|
24
|
+
vars: 128,
|
|
25
|
+
/** The UTF-16 length of one variable or secret value. */
|
|
26
|
+
valueLength: 64 * 1024,
|
|
27
|
+
secrets: 64,
|
|
28
|
+
compatibilityFlags: 64,
|
|
29
|
+
readinessPaths: 8,
|
|
30
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Where a Worker host serves a deployment's record, which the deployment itself never answers. */
|
|
2
|
+
export declare const WORKER_RECORD_PATH = "/.well-known/astrale/deployment.json";
|
|
3
|
+
/**
|
|
4
|
+
* The record a deployer publishes for one deployment: opaque bytes and their media type. Its host
|
|
5
|
+
* serves it verbatim at `WORKER_RECORD_PATH`, immutable, and never reads it: what it says is the
|
|
6
|
+
* deployer's (a Domain deployer publishes its `DeploymentRecordV1`). A deployment keeps the record
|
|
7
|
+
* it was created with.
|
|
8
|
+
*/
|
|
9
|
+
export interface WorkerRecord {
|
|
10
|
+
/** A media type, served verbatim as the record's `content-type`. */
|
|
11
|
+
readonly contentType: string;
|
|
12
|
+
/** One to `WORKER_DEPLOYMENT_LIMITS.recordSize` bytes. */
|
|
13
|
+
readonly bytes: Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
/** One record on the wire: its media type and its bytes as canonical Base64. */
|
|
16
|
+
export interface WorkerRecordWireV1 {
|
|
17
|
+
readonly contentType: string;
|
|
18
|
+
readonly contentBase64: string;
|
|
19
|
+
}
|
|
20
|
+
/** Admit one record: a media type a response can carry as written, and bounded bytes. */
|
|
21
|
+
export declare function acceptWorkerRecord(input: WorkerRecord): WorkerRecord;
|
|
22
|
+
/** Whether two records are the same publication: the same media type and the same bytes. */
|
|
23
|
+
export declare function sameWorkerRecord(left: WorkerRecord, right: WorkerRecord): boolean;
|
|
24
|
+
/** Whether `input` is a media type a record may carry, exactly as it will be served. */
|
|
25
|
+
export declare function isMediaType(input: unknown): input is string;
|
|
26
|
+
/** One record on the wire. */
|
|
27
|
+
export declare function encodeWorkerRecord(record: WorkerRecord): WorkerRecordWireV1;
|
|
28
|
+
/** Decode one record from the wire: exactly a media type and canonical Base64 of bounded bytes. */
|
|
29
|
+
export declare function decodeWorkerRecord(input: unknown): WorkerRecord;
|