@astrale-os/sdk 0.6.0-beta.21 → 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/README.md +0 -4
- package/dist/application/workflow/durable.d.ts +12 -8
- package/dist/deployment/adapter/adapter.d.ts +16 -5
- package/dist/deployment/adapter/define.d.ts +1 -1
- package/dist/deployment/adapter/define.js +40 -1
- package/dist/deployment/adapter/release/command.d.ts +33 -0
- package/dist/deployment/adapter/release/command.js +1 -0
- package/dist/deployment/adapter/release/context.d.ts +3 -4
- package/dist/deployment/adapter/release/index.d.ts +1 -0
- package/dist/deployment/adapter/release/list.d.ts +11 -10
- package/dist/deployment/address/configuration.d.ts +12 -0
- package/dist/deployment/address/configuration.js +11 -0
- package/dist/deployment/address/index.d.ts +3 -3
- package/dist/deployment/address/index.js +2 -3
- 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/address/summary.d.ts +17 -40
- package/dist/deployment/address/summary.js +1 -117
- package/dist/deployment/index.d.ts +1 -1
- 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/execution/workflows/run.js +18 -6
- package/dist/tooling/cli/adapter-command.d.ts +35 -0
- package/dist/tooling/cli/adapter-command.js +66 -0
- package/dist/tooling/cli/arguments.d.ts +7 -1
- package/dist/tooling/cli/arguments.js +53 -1
- package/dist/tooling/cli/help.js +11 -8
- package/dist/tooling/cli/immutable-deployment.d.ts +2 -2
- package/dist/tooling/cli/index.d.ts +4 -2
- package/dist/tooling/cli/index.js +3 -2
- package/dist/tooling/cli/list.d.ts +5 -5
- package/dist/tooling/cli/list.js +16 -14
- package/dist/tooling/cli/orchestrate.js +18 -0
- package/dist/tooling/cli/publish/index.d.ts +4 -4
- package/dist/tooling/cli/publish/index.js +8 -9
- package/package.json +5 -1
|
@@ -1,117 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { deploymentUrl, parseDeploymentLabel } from './label.js';
|
|
3
|
-
/** The owner every summary of a platform namespace names: the platform holds all of its lines. */
|
|
4
|
-
export const PLATFORM_OWNER = 'platform';
|
|
5
|
-
const REQUIRED = [
|
|
6
|
-
'id',
|
|
7
|
-
'url',
|
|
8
|
-
'label',
|
|
9
|
-
'line',
|
|
10
|
-
'owner',
|
|
11
|
-
'state',
|
|
12
|
-
'retention',
|
|
13
|
-
'createdAt',
|
|
14
|
-
'signingKeyId',
|
|
15
|
-
];
|
|
16
|
-
const RETIREMENT = ['retiredAt', 'retirement'];
|
|
17
|
-
const STATES = ['active', 'retiring', 'retired'];
|
|
18
|
-
const RETENTIONS = ['preview', 'published'];
|
|
19
|
-
/** An instant as `Date.prototype.toISOString` writes it, in UTC. */
|
|
20
|
-
const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
|
21
|
-
/** An RFC 7638 JWK thumbprint: the base64url SHA-256 of the public key, 43 characters. */
|
|
22
|
-
const KEY_ID = /^[A-Za-z0-9_-]{43}$/u;
|
|
23
|
-
/**
|
|
24
|
-
* Admit one platform deployment summary exactly. Any member outside its shape is refused, a
|
|
25
|
-
* provider version id or a clock among them; so is a summary whose id, line or URL is not its
|
|
26
|
-
* label's, whose owner is not the platform, or whose state and retirement disagree. A reader that
|
|
27
|
-
* meets a refused summary lists that label apart, never acting on it.
|
|
28
|
-
*/
|
|
29
|
-
export function acceptPlatformDeploymentSummary(input) {
|
|
30
|
-
if (input === null || typeof input !== 'object' || Array.isArray(input))
|
|
31
|
-
invalid('members');
|
|
32
|
-
const value = input;
|
|
33
|
-
const keys = Reflect.ownKeys(value);
|
|
34
|
-
const retired = RETIREMENT.some((key) => Object.hasOwn(value, key));
|
|
35
|
-
const expected = retired ? [...REQUIRED, ...RETIREMENT] : REQUIRED;
|
|
36
|
-
if (keys.length !== expected.length || expected.some((key) => !Object.hasOwn(value, key))) {
|
|
37
|
-
invalid('members');
|
|
38
|
-
}
|
|
39
|
-
const label = text(value.label, 'label');
|
|
40
|
-
let line;
|
|
41
|
-
try {
|
|
42
|
-
line = parseDeploymentLabel(label).line;
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
invalid('label');
|
|
46
|
-
}
|
|
47
|
-
if (value.id !== label)
|
|
48
|
-
invalid('id');
|
|
49
|
-
if (value.line !== line)
|
|
50
|
-
invalid('line');
|
|
51
|
-
const url = text(value.url, 'url');
|
|
52
|
-
if (!sameDeploymentUrl(url, label))
|
|
53
|
-
invalid('url');
|
|
54
|
-
if (value.owner !== PLATFORM_OWNER)
|
|
55
|
-
invalid('owner');
|
|
56
|
-
const state = value.state;
|
|
57
|
-
if (typeof state !== 'string' || !STATES.includes(state))
|
|
58
|
-
invalid('state');
|
|
59
|
-
if (retired !== (state !== 'active'))
|
|
60
|
-
invalid('state');
|
|
61
|
-
const retention = value.retention;
|
|
62
|
-
if (typeof retention !== 'string' || !RETENTIONS.includes(retention))
|
|
63
|
-
invalid('retention');
|
|
64
|
-
const createdAt = instant(value.createdAt, 'createdAt');
|
|
65
|
-
const signingKeyId = text(value.signingKeyId, 'signingKeyId');
|
|
66
|
-
if (!KEY_ID.test(signingKeyId))
|
|
67
|
-
invalid('signingKeyId');
|
|
68
|
-
if (retired && value.retirement !== 'withdrawn')
|
|
69
|
-
invalid('retirement');
|
|
70
|
-
return Object.freeze({
|
|
71
|
-
id: label,
|
|
72
|
-
url,
|
|
73
|
-
label,
|
|
74
|
-
line,
|
|
75
|
-
owner: PLATFORM_OWNER,
|
|
76
|
-
state: state,
|
|
77
|
-
retention: retention,
|
|
78
|
-
createdAt,
|
|
79
|
-
signingKeyId,
|
|
80
|
-
...(retired
|
|
81
|
-
? { retiredAt: instant(value.retiredAt, 'retiredAt'), retirement: 'withdrawn' }
|
|
82
|
-
: {}),
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* The canonical JSON (RFC 8785) of one admitted platform summary, as `summary:<label>` holds it:
|
|
87
|
-
* one summary has one text, so a writer that reads it back compares texts.
|
|
88
|
-
*/
|
|
89
|
-
export function encodePlatformDeploymentSummary(summary) {
|
|
90
|
-
return json.serialize(acceptPlatformDeploymentSummary(summary));
|
|
91
|
-
}
|
|
92
|
-
/** Whether `url` is exactly `https://<label>.<routing domain>` for some routing domain. */
|
|
93
|
-
function sameDeploymentUrl(url, label) {
|
|
94
|
-
const prefix = `https://${label}.`;
|
|
95
|
-
if (!url.startsWith(prefix))
|
|
96
|
-
return false;
|
|
97
|
-
try {
|
|
98
|
-
return deploymentUrl(label, url.slice(prefix.length)) === url;
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
function text(input, member) {
|
|
105
|
-
if (typeof input !== 'string' || input.length === 0)
|
|
106
|
-
invalid(member);
|
|
107
|
-
return input;
|
|
108
|
-
}
|
|
109
|
-
function instant(input, member) {
|
|
110
|
-
const value = text(input, member);
|
|
111
|
-
if (!INSTANT.test(value) || new Date(value).toISOString() !== value)
|
|
112
|
-
invalid(member);
|
|
113
|
-
return value;
|
|
114
|
-
}
|
|
115
|
-
function invalid(member) {
|
|
116
|
-
throw new TypeError(`Platform deployment summary has invalid ${member}.`);
|
|
117
|
-
}
|
|
1
|
+
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { defineAdapter, defineDeployment, isAdapter } from './adapter/index.js';
|
|
2
|
-
export type { Adapter, AdapterEnvironmentContext, AdapterInput, AddressingPlan, ArtifactFile, ArtifactSource, Bundle, BundleContext, Bundler, BundleSource, ConfigureContext, DeployContext, DeploymentCallTarget, DeploymentPhase, DeploymentProgress, DeploymentSecretsState, DeployResult, FailedDeployResult, FailedReleaseDeployResult, LegacyAdapter, LegacyAdapterInput, ListContext, ListedDeployment, MutationKnowledge, Placement, PlacementContext, PreparedArtifact, PreparedArtifactFile, PrepareContext, ReadyDeployResult, ReadyReleaseDeployResult, ReleaseDeployContext, ReleaseDeploymentPhase, ReleaseDeployResult, RetainContext, } from './adapter/index.js';
|
|
2
|
+
export type { Adapter, AdapterCommand, AdapterCommandContext, AdapterCommandResult, AdapterEnvironmentContext, AdapterInput, AddressingPlan, ArtifactFile, ArtifactSource, Bundle, BundleContext, Bundler, BundleSource, ConfigureContext, DeployContext, DeploymentCallTarget, DeploymentPhase, DeploymentProgress, DeploymentSecretsState, DeployResult, FailedDeployResult, FailedReleaseDeployResult, LegacyAdapter, LegacyAdapterInput, ListContext, ListedDeployment, MutationKnowledge, Placement, PlacementContext, PreparedArtifact, PreparedArtifactFile, PrepareContext, ReadyDeployResult, ReadyReleaseDeployResult, ReleaseDeployContext, ReleaseDeploymentPhase, ReleaseDeployResult, RetainContext, } from './adapter/index.js';
|
|
3
3
|
export { compile, isBuild } from './build/index.js';
|
|
4
4
|
export type { Build, BuildBundle, BuildDigest, BuildManifestV1, BuildSchema, } from './build/index.js';
|
|
5
5
|
export { deploy } from './deploy.js';
|
|
@@ -2,4 +2,4 @@ export { preflight as verifyPreflight } from './preflight/index.js';
|
|
|
2
2
|
export { isPreparedArtifact } from './preflight/index.js';
|
|
3
3
|
export { bundle as verifyBundle, isBundle, uploadedBuild as verifyUploadedBuild, } from './preflight/index.js';
|
|
4
4
|
export type { BundleInput, PreflightInput } from './preflight/index.js';
|
|
5
|
-
export { readiness as verifyReadiness, verifyDeployment } from './readiness/index.js';
|
|
5
|
+
export { readiness as verifyReadiness, readinessPaths, verifyDeployment, } from './readiness/index.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { preflight as verifyPreflight } from './preflight/index.js';
|
|
2
2
|
export { isPreparedArtifact } from './preflight/index.js';
|
|
3
3
|
export { bundle as verifyBundle, isBundle, uploadedBuild as verifyUploadedBuild, } from './preflight/index.js';
|
|
4
|
-
export { readiness as verifyReadiness, verifyDeployment } from './readiness/index.js';
|
|
4
|
+
export { readiness as verifyReadiness, readinessPaths, verifyDeployment, } from './readiness/index.js';
|
|
@@ -11,4 +11,11 @@ export { verifyRelease } from './release.js';
|
|
|
11
11
|
* deployment's `release.json` (release digest and build digest), or a stable target's
|
|
12
12
|
* `domain.json` (deprecated, `legacy/`).
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* The local paths `readiness` reads of one immutable deployment, each answering 200 once the
|
|
16
|
+
* deployment serves its release: its `release.json`, its Bundle and its issuer documents (the
|
|
17
|
+
* OpenID configuration and its key set). A host that probes paths before it activates a
|
|
18
|
+
* deployment probes these.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readinessPaths(deployed: Release): readonly string[];
|
|
14
21
|
export declare function readiness(release: Release | legacy.Release, options?: ReadinessOptions): Promise<void>;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { issuer } from '@astrale-os/kernel-protocol';
|
|
2
|
+
import * as release from '@astrale-os/kernel-protocol/release';
|
|
1
3
|
import { verifyIdentity } from './identity.js';
|
|
2
4
|
import { verifyPublication } from './legacy/index.js';
|
|
3
5
|
import { verifyRelease } from './release.js';
|
|
@@ -10,6 +12,21 @@ export { verifyRelease } from './release.js';
|
|
|
10
12
|
* deployment's `release.json` (release digest and build digest), or a stable target's
|
|
11
13
|
* `domain.json` (deprecated, `legacy/`).
|
|
12
14
|
*/
|
|
15
|
+
/**
|
|
16
|
+
* The local paths `readiness` reads of one immutable deployment, each answering 200 once the
|
|
17
|
+
* deployment serves its release: its `release.json`, its Bundle and its issuer documents (the
|
|
18
|
+
* OpenID configuration and its key set). A host that probes paths before it activates a
|
|
19
|
+
* deployment probes these.
|
|
20
|
+
*/
|
|
21
|
+
export function readinessPaths(deployed) {
|
|
22
|
+
const { configuration, keys } = issuer.paths(deployed.url);
|
|
23
|
+
return Object.freeze([
|
|
24
|
+
new URL(release.url(deployed.url)).pathname,
|
|
25
|
+
new URL(deployed.document.schema.bundle.href).pathname,
|
|
26
|
+
configuration,
|
|
27
|
+
keys,
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
13
30
|
export async function readiness(release, options = {}) {
|
|
14
31
|
if (release.kind === 'release') {
|
|
15
32
|
await verifyRelease(release, options);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Digest } from '@astrale-os/kernel-protocol/artifact';
|
|
2
|
+
/**
|
|
3
|
+
* The name of a project a Worker host keeps Services for: lower-case letters, digits, `.`, `_`
|
|
4
|
+
* and `-`, starting and ending with a letter or a digit, at most 253 characters. A Domain origin is
|
|
5
|
+
* one (a Domain deployer names its project by its origin).
|
|
6
|
+
*/
|
|
7
|
+
export declare const PROJECT_NAME: RegExp;
|
|
8
|
+
/**
|
|
9
|
+
* The name of the Service of one project in one environment: the first 80 bits of
|
|
10
|
+
* `sha256(utf8(project) ‖ 0x00 ‖ utf8(environment))` in lower-case base32, 16 characters. It is
|
|
11
|
+
* opaque, so a Service's URL says nothing of whose code it serves, and it is the line fingerprint
|
|
12
|
+
* of `@astrale-os/sdk/deployment/address` (`lineFingerprint`) when the project is a Domain origin:
|
|
13
|
+
* a Domain's Service keeps its name.
|
|
14
|
+
*/
|
|
15
|
+
export declare function serviceName(project: string, environment: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* The label of one Worker deployment, `<service>-<content>`: its Service's name, then the first
|
|
18
|
+
* 80 bits of `sha256(utf8(buildDigest) ‖ 0x00 ‖ utf8(configurationDigest))` in lower-case base32.
|
|
19
|
+
* The same code and configuration in the same Service always give the same label, so a redeploy
|
|
20
|
+
* of a release reuses its deployment; anything else gives another.
|
|
21
|
+
*/
|
|
22
|
+
export declare function workerDeploymentLabel(input: {
|
|
23
|
+
readonly service: string;
|
|
24
|
+
readonly buildDigest: Digest;
|
|
25
|
+
readonly configurationDigest: Digest;
|
|
26
|
+
}): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { first80 } from '../address/fingerprint.js';
|
|
2
|
+
import { deploymentLabel } from '../address/label.js';
|
|
3
|
+
import { ENVIRONMENT_NAME } from '../environment.js';
|
|
4
|
+
/**
|
|
5
|
+
* The name of a project a Worker host keeps Services for: lower-case letters, digits, `.`, `_`
|
|
6
|
+
* and `-`, starting and ending with a letter or a digit, at most 253 characters. A Domain origin is
|
|
7
|
+
* one (a Domain deployer names its project by its origin).
|
|
8
|
+
*/
|
|
9
|
+
export const PROJECT_NAME = /^[a-z0-9](?:[a-z0-9._-]{0,251}[a-z0-9])?$/u;
|
|
10
|
+
/** The longest environment name a Service is named by. */
|
|
11
|
+
const MAXIMUM_ENVIRONMENT_LENGTH = 63;
|
|
12
|
+
/**
|
|
13
|
+
* The name of the Service of one project in one environment: the first 80 bits of
|
|
14
|
+
* `sha256(utf8(project) ‖ 0x00 ‖ utf8(environment))` in lower-case base32, 16 characters. It is
|
|
15
|
+
* opaque, so a Service's URL says nothing of whose code it serves, and it is the line fingerprint
|
|
16
|
+
* of `@astrale-os/sdk/deployment/address` (`lineFingerprint`) when the project is a Domain origin:
|
|
17
|
+
* a Domain's Service keeps its name.
|
|
18
|
+
*/
|
|
19
|
+
export function serviceName(project, environment) {
|
|
20
|
+
if (typeof project !== 'string' || !PROJECT_NAME.test(project)) {
|
|
21
|
+
throw new TypeError('Worker project must be a lower-case project name.');
|
|
22
|
+
}
|
|
23
|
+
if (typeof environment !== 'string' ||
|
|
24
|
+
environment.length > MAXIMUM_ENVIRONMENT_LENGTH ||
|
|
25
|
+
!ENVIRONMENT_NAME.test(environment)) {
|
|
26
|
+
throw new TypeError('Worker environment must be a project environment name.');
|
|
27
|
+
}
|
|
28
|
+
return first80(project, environment);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The label of one Worker deployment, `<service>-<content>`: its Service's name, then the first
|
|
32
|
+
* 80 bits of `sha256(utf8(buildDigest) ‖ 0x00 ‖ utf8(configurationDigest))` in lower-case base32.
|
|
33
|
+
* The same code and configuration in the same Service always give the same label, so a redeploy
|
|
34
|
+
* of a release reuses its deployment; anything else gives another.
|
|
35
|
+
*/
|
|
36
|
+
export function workerDeploymentLabel(input) {
|
|
37
|
+
for (const digest of [input.buildDigest, input.configurationDigest]) {
|
|
38
|
+
if (typeof digest !== 'string' || !/^sha256:[0-9a-f]{64}$/u.test(digest)) {
|
|
39
|
+
throw new TypeError('Worker deployment digests must be sha256 digests.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return deploymentLabel(input.service, first80(input.buildDigest, input.configurationDigest));
|
|
43
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Value } from '@astrale-os/kernel-dsl/value';
|
|
2
|
+
import type { Digest } from '@astrale-os/kernel-protocol/artifact';
|
|
3
|
+
/** How Workers loads one module. */
|
|
4
|
+
export type WorkerModuleKind = 'esm' | 'wasm' | 'text' | 'data';
|
|
5
|
+
/** One module of a Worker, by the name the others import it by. */
|
|
6
|
+
export interface WorkerModule {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly kind: WorkerModuleKind;
|
|
9
|
+
readonly bytes: Uint8Array;
|
|
10
|
+
}
|
|
11
|
+
/** One static asset of a Worker, by the absolute URL path it is served at. */
|
|
12
|
+
export interface WorkerAsset {
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly bytes: Uint8Array;
|
|
15
|
+
/** The media type the asset is uploaded with; not part of the build digest. */
|
|
16
|
+
readonly contentType?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The members of a build manifest its deployer declares: at least its `format` and `version`,
|
|
20
|
+
* then whatever provenance the deployer identifies its builds by. A host never reads them: it only
|
|
21
|
+
* digests them with the `entrypoint` and `files` it computes from the uploaded bytes.
|
|
22
|
+
*
|
|
23
|
+
* A generic Worker declares `WORKER_BUILD_MANIFEST`. A Domain deployer declares its
|
|
24
|
+
* `BuildManifestV1` without `entrypoint` and `files` (`format`, `version`, `origin`, `schema`,
|
|
25
|
+
* `bundler`), so the digest is exactly the Domain's build digest.
|
|
26
|
+
*/
|
|
27
|
+
export type WorkerBuildManifest = Readonly<Record<string, Value>> & {
|
|
28
|
+
readonly format: string;
|
|
29
|
+
readonly version: number;
|
|
30
|
+
};
|
|
31
|
+
/** The build manifest members of a Worker that declares no provenance of its own. */
|
|
32
|
+
export declare const WORKER_BUILD_MANIFEST: WorkerBuildManifest;
|
|
33
|
+
/** The code of one Worker: its modules, its main module, its static assets and its manifest. */
|
|
34
|
+
export interface WorkerCode {
|
|
35
|
+
readonly manifest: WorkerBuildManifest;
|
|
36
|
+
readonly mainModule: string;
|
|
37
|
+
readonly modules: readonly WorkerModule[];
|
|
38
|
+
readonly assets?: readonly WorkerAsset[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The build manifest of one Worker: its declared members, its `entrypoint` (the main module) and
|
|
42
|
+
* its `files`, sorted by path (UTF-16 code units), each `{ path, digest, size }`. A module is the
|
|
43
|
+
* file of its own name, a static asset `/<path>` the file `assets/<path>`. A module whose kind is
|
|
44
|
+
* not the one its name implies (`wasm` for a `.wasm` name, `esm` otherwise) also records its
|
|
45
|
+
* `kind`, so a module loaded another way is another build. The code is admitted first.
|
|
46
|
+
*/
|
|
47
|
+
export declare function workerBuildManifest(code: WorkerCode): Readonly<Record<string, Value>>;
|
|
48
|
+
/**
|
|
49
|
+
* The build digest of one Worker: `sha256` of the canonical JSON (RFC 8785) of its build manifest.
|
|
50
|
+
* A host binds it as `ASTRALE_BUILD_DIGEST`; with a Domain's declared members it is the Domain's
|
|
51
|
+
* build digest.
|
|
52
|
+
*/
|
|
53
|
+
export declare function workerBuildDigest(code: WorkerCode): Digest;
|
|
54
|
+
/**
|
|
55
|
+
* Admit the code of one Worker: declared manifest members (a `format`, a positive `version`, no
|
|
56
|
+
* `entrypoint` nor `files`, at most `manifestSize` bytes of canonical JSON), one to `modules`
|
|
57
|
+
* modules with safe relative names, unique, the main module among them, and at most `assets`
|
|
58
|
+
* assets at unique canonical absolute paths. Sizes are bounded per file and for the whole Worker.
|
|
59
|
+
*/
|
|
60
|
+
export declare function acceptWorkerCode(code: WorkerCode): WorkerCode;
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
/** The build manifest members of a Worker that declares no provenance of its own. */
|
|
5
|
+
export const WORKER_BUILD_MANIFEST = Object.freeze({
|
|
6
|
+
format: 'astrale.worker.build',
|
|
7
|
+
version: 1,
|
|
8
|
+
});
|
|
9
|
+
const MANIFEST_MEDIA_TYPE = 'application/vnd.astrale.worker-build+json;v=1';
|
|
10
|
+
const FILE_MEDIA_TYPE = 'application/octet-stream';
|
|
11
|
+
const MAXIMUM_MODULE_NAME_LENGTH = 256;
|
|
12
|
+
const MAXIMUM_ASSET_PATH_LENGTH = 2_048;
|
|
13
|
+
const KINDS = Object.freeze(['esm', 'wasm', 'text', 'data']);
|
|
14
|
+
/**
|
|
15
|
+
* The build manifest of one Worker: its declared members, its `entrypoint` (the main module) and
|
|
16
|
+
* its `files`, sorted by path (UTF-16 code units), each `{ path, digest, size }`. A module is the
|
|
17
|
+
* file of its own name, a static asset `/<path>` the file `assets/<path>`. A module whose kind is
|
|
18
|
+
* not the one its name implies (`wasm` for a `.wasm` name, `esm` otherwise) also records its
|
|
19
|
+
* `kind`, so a module loaded another way is another build. The code is admitted first.
|
|
20
|
+
*/
|
|
21
|
+
export function workerBuildManifest(code) {
|
|
22
|
+
const admitted = acceptWorkerCode(code);
|
|
23
|
+
const files = [
|
|
24
|
+
...admitted.modules.map((module) => ({
|
|
25
|
+
path: module.name,
|
|
26
|
+
...identity(module.bytes),
|
|
27
|
+
...(module.kind === impliedKind(module.name) ? {} : { kind: module.kind }),
|
|
28
|
+
})),
|
|
29
|
+
...(admitted.assets ?? []).map((asset) => ({
|
|
30
|
+
path: `assets${asset.path}`,
|
|
31
|
+
...identity(asset.bytes),
|
|
32
|
+
})),
|
|
33
|
+
].sort((left, right) => compare(left.path, right.path));
|
|
34
|
+
for (let index = 1; index < files.length; index += 1) {
|
|
35
|
+
if (files[index - 1].path === files[index].path) {
|
|
36
|
+
invalid(`the module and the asset file ${files[index].path} collide`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return Object.freeze({ ...admitted.manifest, entrypoint: admitted.mainModule, files });
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The build digest of one Worker: `sha256` of the canonical JSON (RFC 8785) of its build manifest.
|
|
43
|
+
* A host binds it as `ASTRALE_BUILD_DIGEST`; with a Domain's declared members it is the Domain's
|
|
44
|
+
* build digest.
|
|
45
|
+
*/
|
|
46
|
+
export function workerBuildDigest(code) {
|
|
47
|
+
return identify(json.encode(workerBuildManifest(code)), MANIFEST_MEDIA_TYPE).digest;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Admit the code of one Worker: declared manifest members (a `format`, a positive `version`, no
|
|
51
|
+
* `entrypoint` nor `files`, at most `manifestSize` bytes of canonical JSON), one to `modules`
|
|
52
|
+
* modules with safe relative names, unique, the main module among them, and at most `assets`
|
|
53
|
+
* assets at unique canonical absolute paths. Sizes are bounded per file and for the whole Worker.
|
|
54
|
+
*/
|
|
55
|
+
export function acceptWorkerCode(code) {
|
|
56
|
+
if (code === null || typeof code !== 'object')
|
|
57
|
+
invalid('it is not an object');
|
|
58
|
+
const manifest = acceptManifest(code.manifest);
|
|
59
|
+
const limits = WORKER_DEPLOYMENT_LIMITS;
|
|
60
|
+
if (!Array.isArray(code.modules) || code.modules.length === 0)
|
|
61
|
+
invalid('it has no module');
|
|
62
|
+
if (code.modules.length > limits.modules)
|
|
63
|
+
invalid('it has too many modules');
|
|
64
|
+
const assets = code.assets ?? [];
|
|
65
|
+
if (!Array.isArray(assets) || assets.length > limits.assets)
|
|
66
|
+
invalid('it has too many assets');
|
|
67
|
+
let size = 0;
|
|
68
|
+
const modules = code.modules.map((module) => {
|
|
69
|
+
if (!isModuleName(module?.name))
|
|
70
|
+
invalid('a module name is not a safe relative path');
|
|
71
|
+
if (!KINDS.includes(module.kind))
|
|
72
|
+
invalid(`module ${module.name} has an unknown kind`);
|
|
73
|
+
size += bounded(module.bytes, limits.moduleSize, `module ${module.name}`);
|
|
74
|
+
return Object.freeze({ name: module.name, kind: module.kind, bytes: module.bytes });
|
|
75
|
+
});
|
|
76
|
+
unique(modules.map((module) => module.name), 'module names');
|
|
77
|
+
if (!modules.some((module) => module.name === code.mainModule)) {
|
|
78
|
+
invalid('its main module is not one of its modules');
|
|
79
|
+
}
|
|
80
|
+
const files = assets.map((asset) => {
|
|
81
|
+
if (!isAssetPath(asset?.path))
|
|
82
|
+
invalid('an asset path is not a canonical absolute URL path');
|
|
83
|
+
if (asset.contentType !== undefined && typeof asset.contentType !== 'string') {
|
|
84
|
+
invalid(`asset ${asset.path} has an invalid content type`);
|
|
85
|
+
}
|
|
86
|
+
size += bounded(asset.bytes, limits.assetSize, `asset ${asset.path}`);
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
path: asset.path,
|
|
89
|
+
bytes: asset.bytes,
|
|
90
|
+
...(asset.contentType === undefined ? {} : { contentType: asset.contentType }),
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
unique(files.map((asset) => asset.path), 'asset paths');
|
|
94
|
+
if (size > limits.workerSize)
|
|
95
|
+
invalid('its files exceed the size of one Worker');
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
manifest,
|
|
98
|
+
mainModule: code.mainModule,
|
|
99
|
+
modules: Object.freeze(modules),
|
|
100
|
+
...(files.length === 0 ? {} : { assets: Object.freeze(files) }),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** The kind a module's name implies: `wasm` for a `.wasm` module, `esm` otherwise. */
|
|
104
|
+
function impliedKind(name) {
|
|
105
|
+
return name.endsWith('.wasm') ? 'wasm' : 'esm';
|
|
106
|
+
}
|
|
107
|
+
function acceptManifest(input) {
|
|
108
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
109
|
+
invalid('its manifest is not an object');
|
|
110
|
+
}
|
|
111
|
+
const manifest = input;
|
|
112
|
+
if (typeof manifest.format !== 'string' || manifest.format.length === 0) {
|
|
113
|
+
invalid('its manifest names no format');
|
|
114
|
+
}
|
|
115
|
+
if (!Number.isSafeInteger(manifest.version) || manifest.version < 1) {
|
|
116
|
+
invalid('its manifest names no version');
|
|
117
|
+
}
|
|
118
|
+
if (Object.hasOwn(manifest, 'entrypoint') || Object.hasOwn(manifest, 'files')) {
|
|
119
|
+
invalid('its manifest declares the entrypoint or the files its host computes');
|
|
120
|
+
}
|
|
121
|
+
let text;
|
|
122
|
+
try {
|
|
123
|
+
text = json.serialize(manifest);
|
|
124
|
+
}
|
|
125
|
+
catch (cause) {
|
|
126
|
+
throw new TypeError('Worker code is invalid: its manifest is not portable JSON.', { cause });
|
|
127
|
+
}
|
|
128
|
+
if (new TextEncoder().encode(text).byteLength > WORKER_DEPLOYMENT_LIMITS.manifestSize) {
|
|
129
|
+
invalid('its manifest is too large');
|
|
130
|
+
}
|
|
131
|
+
return JSON.parse(text);
|
|
132
|
+
}
|
|
133
|
+
function identity(bytes) {
|
|
134
|
+
const { digest, size } = identify(bytes, FILE_MEDIA_TYPE);
|
|
135
|
+
return { digest, size };
|
|
136
|
+
}
|
|
137
|
+
function bounded(bytes, maximum, label) {
|
|
138
|
+
if (!(bytes instanceof Uint8Array))
|
|
139
|
+
invalid(`${label} has no bytes`);
|
|
140
|
+
if (bytes.byteLength > maximum)
|
|
141
|
+
invalid(`${label} exceeds its size limit`);
|
|
142
|
+
return bytes.byteLength;
|
|
143
|
+
}
|
|
144
|
+
/** A safe relative module path: no leading `/`, no `\`, `?` or `#`, no empty, `.` or `..` part. */
|
|
145
|
+
function isModuleName(value) {
|
|
146
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
147
|
+
return false;
|
|
148
|
+
if (value.length > MAXIMUM_MODULE_NAME_LENGTH || /^\/|[\\?#]/u.test(value))
|
|
149
|
+
return false;
|
|
150
|
+
return value.split('/').every((part) => part.length > 0 && part !== '.' && part !== '..');
|
|
151
|
+
}
|
|
152
|
+
/** An absolute canonical URL path: `/`, then non-empty parts other than `.` and `..`. */
|
|
153
|
+
function isAssetPath(value) {
|
|
154
|
+
if (typeof value !== 'string' || value.length > MAXIMUM_ASSET_PATH_LENGTH)
|
|
155
|
+
return false;
|
|
156
|
+
if (!value.startsWith('/') || /[\\?#]/u.test(value))
|
|
157
|
+
return false;
|
|
158
|
+
return value
|
|
159
|
+
.slice(1)
|
|
160
|
+
.split('/')
|
|
161
|
+
.every((part) => part.length > 0 && part !== '.' && part !== '..');
|
|
162
|
+
}
|
|
163
|
+
function unique(values, label) {
|
|
164
|
+
if (new Set(values).size !== values.length)
|
|
165
|
+
invalid(`its ${label} are not unique`);
|
|
166
|
+
}
|
|
167
|
+
function compare(left, right) {
|
|
168
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
169
|
+
}
|
|
170
|
+
function invalid(reason) {
|
|
171
|
+
throw new TypeError(`Worker code is invalid: ${reason}.`);
|
|
172
|
+
}
|
|
@@ -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;
|