@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.
Files changed (34) hide show
  1. package/dist/deployment/adapter/adapter.d.ts +8 -4
  2. package/dist/deployment/adapter/release/context.d.ts +3 -4
  3. package/dist/deployment/address/configuration.d.ts +12 -0
  4. package/dist/deployment/address/configuration.js +11 -0
  5. package/dist/deployment/address/index.d.ts +2 -2
  6. package/dist/deployment/address/index.js +2 -2
  7. package/dist/deployment/address/label.d.ts +5 -5
  8. package/dist/deployment/address/label.js +5 -5
  9. package/dist/deployment/address/record.d.ts +21 -0
  10. package/dist/deployment/address/record.js +34 -0
  11. package/dist/deployment/verify/index.d.ts +1 -1
  12. package/dist/deployment/verify/index.js +1 -1
  13. package/dist/deployment/verify/readiness/index.d.ts +7 -0
  14. package/dist/deployment/verify/readiness/index.js +17 -0
  15. package/dist/deployment/worker/address.d.ts +26 -0
  16. package/dist/deployment/worker/address.js +43 -0
  17. package/dist/deployment/worker/build.d.ts +60 -0
  18. package/dist/deployment/worker/build.js +172 -0
  19. package/dist/deployment/worker/configuration.d.ts +73 -0
  20. package/dist/deployment/worker/configuration.js +165 -0
  21. package/dist/deployment/worker/content.d.ts +38 -0
  22. package/dist/deployment/worker/content.js +169 -0
  23. package/dist/deployment/worker/index.d.ts +29 -0
  24. package/dist/deployment/worker/index.js +29 -0
  25. package/dist/deployment/worker/limits.d.ts +30 -0
  26. package/dist/deployment/worker/limits.js +30 -0
  27. package/dist/deployment/worker/record.d.ts +29 -0
  28. package/dist/deployment/worker/record.js +69 -0
  29. package/dist/deployment/worker/request.d.ts +118 -0
  30. package/dist/deployment/worker/request.js +280 -0
  31. package/dist/deployment/worker/response.d.ts +104 -0
  32. package/dist/deployment/worker/response.js +213 -0
  33. package/dist/tooling/cli/immutable-deployment.d.ts +2 -2
  34. package/package.json +5 -1
@@ -1,4 +1,4 @@
1
- import type { FrozenConfigurationV1 } from '../address/index.js';
1
+ import type { DeploymentConfiguration } from '../address/index.js';
2
2
  import type { Bundler } from './bundle/index.js';
3
3
  import type { ArtifactSource, DeployContext, DeployResult, PrepareContext } from './legacy/index.js';
4
4
  import type { AdapterCommand, ConfigureContext, ListContext, ListedDeployment, Placement, PlacementContext, ReleaseDeployContext, ReleaseDeployResult, RetainContext } from './release/index.js';
@@ -26,8 +26,12 @@ export interface Adapter<Parameters extends object = object> {
26
26
  readonly bundler: Bundler;
27
27
  /** Refuse parameters that cannot serve this Environment, before any effect. */
28
28
  admit?(parameters: Parameters, context: AdapterEnvironmentContext): void;
29
- /** Freeze what a deployment of this Environment binds beside its build. Pure. */
30
- configure(parameters: Parameters, context: ConfigureContext): FrozenConfigurationV1;
29
+ /**
30
+ * Freeze what a deployment of this Environment binds beside its build: a `FrozenConfigurationV1`
31
+ * for a host that binds a generated Worker's settings itself, a `WorkerConfigurationV1` for a
32
+ * Worker host that binds only what it is asked (Admin's Services). Pure.
33
+ */
34
+ configure(parameters: Parameters, context: ConfigureContext): DeploymentConfiguration;
31
35
  /** Where the host places this Environment's deployments. No effect at the host. */
32
36
  placement(parameters: Parameters, context: PlacementContext): Promise<Placement>;
33
37
  /** Create, or reuse, the immutable deployment of one sealed release. */
@@ -68,7 +72,7 @@ export interface AdapterInput<Parameters extends object> {
68
72
  readonly version: string;
69
73
  readonly bundler: Bundler;
70
74
  admit?(parameters: Parameters, context: AdapterEnvironmentContext): void;
71
- configure(parameters: Parameters, context: ConfigureContext): FrozenConfigurationV1;
75
+ configure(parameters: Parameters, context: ConfigureContext): DeploymentConfiguration;
72
76
  placement(parameters: Parameters, context: PlacementContext): Promise<Placement>;
73
77
  deployRelease(parameters: Parameters, context: ReleaseDeployContext): Promise<ReleaseDeployResult>;
74
78
  list?(parameters: Parameters, context: ListContext): Promise<readonly ListedDeployment[]>;
@@ -1,4 +1,4 @@
1
- import type { DeploymentCommitV1, FrozenConfigurationV1, LineAddressing } from '../../address/index.js';
1
+ import type { DeploymentCommitV1, DeploymentConfiguration, LineAddressing } from '../../address/index.js';
2
2
  import type { Release } from '../../release/index.js';
3
3
  import type { Bundle } from '../bundle/index.js';
4
4
  /**
@@ -10,8 +10,7 @@ export interface ConfigureContext {
10
10
  readonly environment: string;
11
11
  /**
12
12
  * The names of the Environment's current secrets (the adapter's `secretsFile`), sorted and
13
- * unique: exactly the secrets `deployRelease` receives, which the configuration declares in
14
- * `bindings.secrets`.
13
+ * unique: exactly the secrets `deployRelease` receives, which the configuration declares.
15
14
  */
16
15
  readonly secrets: readonly string[];
17
16
  }
@@ -53,7 +52,7 @@ export interface ReleaseDeployContext {
53
52
  /** The environment-free bytes of the adapter's `bundler`, identified by `bundle.digest`. */
54
53
  readonly bundle: Bundle;
55
54
  /** What `configure` froze for this Environment. */
56
- readonly configuration: FrozenConfigurationV1;
55
+ readonly configuration: DeploymentConfiguration;
57
56
  /** Where `placement` said the host places the deployment. */
58
57
  readonly placement: Placement;
59
58
  /**
@@ -1,4 +1,5 @@
1
1
  import type { Digest } from '@astrale-os/kernel-protocol/artifact';
2
+ import type { WorkerConfigurationV1 } from '../worker/configuration.js';
2
3
  export declare const FROZEN_CONFIGURATION_FORMAT = "astrale.deployment.configuration";
3
4
  export declare const FROZEN_CONFIGURATION_VERSION = 1;
4
5
  /**
@@ -70,6 +71,17 @@ export interface FrozenDurableOptions {
70
71
  };
71
72
  readonly stepTimeoutMs?: number;
72
73
  }
74
+ /**
75
+ * What one deployment freezes beside its build, as its adapter's `configure` returns it: a
76
+ * `FrozenConfigurationV1`, which a Cloudflare dispatch namespace binds (namespace mode), or a
77
+ * `WorkerConfigurationV1` (`@astrale-os/sdk/deployment/worker`), which a Worker host binds
78
+ * (Admin's Services). Its digest and the build digest make the deployment's label.
79
+ */
80
+ export type DeploymentConfiguration = FrozenConfigurationV1 | WorkerConfigurationV1;
81
+ /** The digest of one frozen configuration of either format, as the deployment's label keeps it. */
82
+ export declare function deploymentConfigurationDigest(configuration: DeploymentConfiguration): Digest;
83
+ /** Whether a configuration is a `FrozenConfigurationV1`, by its format alone (not admitted). */
84
+ export declare function isFrozenConfiguration(configuration: DeploymentConfiguration): configuration is FrozenConfigurationV1;
73
85
  /** A configuration before its canonical order: lists in any order, no format header. */
74
86
  export type FrozenConfigurationInput = Omit<FrozenConfigurationV1, 'format' | 'version'>;
75
87
  /** Freeze one deployment configuration in the one canonical order its digest is taken in. */
@@ -1,5 +1,6 @@
1
1
  import { json } from '@astrale-os/kernel-dsl/value';
2
2
  import { identify } from '@astrale-os/kernel-protocol/artifact';
3
+ import { isWorkerConfiguration, workerConfigurationDigest } from '../worker/configuration.js';
3
4
  export const FROZEN_CONFIGURATION_FORMAT = 'astrale.deployment.configuration';
4
5
  export const FROZEN_CONFIGURATION_VERSION = 1;
5
6
  /** Service binding through which an immutable deployment reaches the instance router. */
@@ -10,6 +11,16 @@ const COMPATIBILITY_DATE = /^\d{4}-\d{2}-\d{2}$/u;
10
11
  const BINDING_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/u;
11
12
  /** A Cloudflare dispatch namespace name. */
12
13
  const NAMESPACE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
14
+ /** The digest of one frozen configuration of either format, as the deployment's label keeps it. */
15
+ export function deploymentConfigurationDigest(configuration) {
16
+ return isWorkerConfiguration(configuration)
17
+ ? workerConfigurationDigest(configuration)
18
+ : configurationDigest(configuration);
19
+ }
20
+ /** Whether a configuration is a `FrozenConfigurationV1`, by its format alone (not admitted). */
21
+ export function isFrozenConfiguration(configuration) {
22
+ return configuration?.format === FROZEN_CONFIGURATION_FORMAT;
23
+ }
13
24
  /** Freeze one deployment configuration in the one canonical order its digest is taken in. */
14
25
  export function frozenConfiguration(input) {
15
26
  if (input === null || typeof input !== 'object')
@@ -1,5 +1,5 @@
1
- export { configurationDigest, frozenConfiguration, FROZEN_CONFIGURATION_FORMAT, FROZEN_CONFIGURATION_VERSION, type FrozenConfigurationInput, type FrozenDurableOptions, type FrozenConfigurationV1, } from './configuration.js';
1
+ export { configurationDigest, deploymentConfigurationDigest, frozenConfiguration, FROZEN_CONFIGURATION_FORMAT, FROZEN_CONFIGURATION_VERSION, isFrozenConfiguration, type DeploymentConfiguration, type FrozenConfigurationInput, type FrozenDurableOptions, type FrozenConfigurationV1, } from './configuration.js';
2
2
  export { DEPLOYMENT_LABEL, deploymentContent, deploymentLabel, deploymentUrl, parseDeploymentLabel, type DeploymentLabelParts, } from './label.js';
3
3
  export { deploymentLine, lineFingerprint, readablePart, type DeploymentLineInput, type LineAddressing, } from './line.js';
4
- export { acceptDeploymentRecord, DEPLOYMENT_RECORD_FORMAT, DEPLOYMENT_RECORD_VERSION, type DeploymentCommitV1, type DeploymentRecordV1, } from './record.js';
4
+ export { acceptDeploymentRecord, decodeDeploymentRecord, DEPLOYMENT_RECORD_FORMAT, DEPLOYMENT_RECORD_MEDIA_TYPE, DEPLOYMENT_RECORD_VERSION, encodeDeploymentRecord, type DeploymentCommitV1, type DeploymentRecordDocument, type DeploymentRecordV1, } from './record.js';
5
5
  export type { DeploymentSummaryV1 } from './summary.js';
@@ -1,4 +1,4 @@
1
- export { configurationDigest, frozenConfiguration, FROZEN_CONFIGURATION_FORMAT, FROZEN_CONFIGURATION_VERSION, } from './configuration.js';
1
+ export { configurationDigest, deploymentConfigurationDigest, frozenConfiguration, FROZEN_CONFIGURATION_FORMAT, FROZEN_CONFIGURATION_VERSION, isFrozenConfiguration, } from './configuration.js';
2
2
  export { DEPLOYMENT_LABEL, deploymentContent, deploymentLabel, deploymentUrl, parseDeploymentLabel, } from './label.js';
3
3
  export { deploymentLine, lineFingerprint, readablePart, } from './line.js';
4
- export { acceptDeploymentRecord, DEPLOYMENT_RECORD_FORMAT, DEPLOYMENT_RECORD_VERSION, } from './record.js';
4
+ export { acceptDeploymentRecord, decodeDeploymentRecord, DEPLOYMENT_RECORD_FORMAT, DEPLOYMENT_RECORD_MEDIA_TYPE, DEPLOYMENT_RECORD_VERSION, encodeDeploymentRecord, } from './record.js';
@@ -1,6 +1,6 @@
1
1
  import type { Digest } from '@astrale-os/kernel-protocol/artifact';
2
2
  import type { BuildDigest } from '../build/manifest.js';
3
- import type { FrozenConfigurationV1 } from './configuration.js';
3
+ import type { DeploymentConfiguration } from './configuration.js';
4
4
  /**
5
5
  * Every deployment label: an optional readable part, the 16-character line fingerprint and the
6
6
  * 16-character content, both lower-case base32. A legacy provider name (a UUID, a stable Worker
@@ -16,11 +16,11 @@ export interface DeploymentLabelParts {
16
16
  }
17
17
  /**
18
18
  * The 16-character content of one deployment: the first 80 bits of
19
- * `sha256(utf8(buildDigest) ‖ 0x00 ‖ utf8(configurationDigest(configuration)))` in lower-case
20
- * base32. It covers the build and the frozen configuration, never a secret: one address serves
21
- * one release, and a changed variable gives another address.
19
+ * `sha256(utf8(buildDigest) ‖ 0x00 ‖ utf8(deploymentConfigurationDigest(configuration)))` in
20
+ * lower-case base32. It covers the build and the frozen configuration, never a secret: one address
21
+ * serves one release, and a changed variable gives another address.
22
22
  */
23
- export declare function deploymentContent(build: BuildDigest, configuration: FrozenConfigurationV1): string;
23
+ export declare function deploymentContent(build: BuildDigest, configuration: DeploymentConfiguration): string;
24
24
  /** The content of a build and a full configuration digest, as a deployment record holds them. */
25
25
  export declare function contentOf(build: BuildDigest, configuration: Digest): string;
26
26
  /**
@@ -1,4 +1,4 @@
1
- import { configurationDigest } from './configuration.js';
1
+ import { deploymentConfigurationDigest } from './configuration.js';
2
2
  import { first80 } from './fingerprint.js';
3
3
  /**
4
4
  * Every deployment label: an optional readable part, the 16-character line fingerprint and the
@@ -18,12 +18,12 @@ const MAXIMUM_LABEL_LENGTH = MAXIMUM_LINE_LENGTH + 17;
18
18
  const MAXIMUM_HOST_LENGTH = 253;
19
19
  /**
20
20
  * The 16-character content of one deployment: the first 80 bits of
21
- * `sha256(utf8(buildDigest) ‖ 0x00 ‖ utf8(configurationDigest(configuration)))` in lower-case
22
- * base32. It covers the build and the frozen configuration, never a secret: one address serves
23
- * one release, and a changed variable gives another address.
21
+ * `sha256(utf8(buildDigest) ‖ 0x00 ‖ utf8(deploymentConfigurationDigest(configuration)))` in
22
+ * lower-case base32. It covers the build and the frozen configuration, never a secret: one address
23
+ * serves one release, and a changed variable gives another address.
24
24
  */
25
25
  export function deploymentContent(build, configuration) {
26
- return contentOf(build, configurationDigest(configuration));
26
+ return contentOf(build, deploymentConfigurationDigest(configuration));
27
27
  }
28
28
  /** The content of a build and a full configuration digest, as a deployment record holds them. */
29
29
  export function contentOf(build, configuration) {
@@ -4,6 +4,11 @@ import type { ReleaseDigest } from '@astrale-os/kernel-protocol/release';
4
4
  import type { BuildDigest } from '../build/manifest.js';
5
5
  export declare const DEPLOYMENT_RECORD_FORMAT = "astrale.deployment-record";
6
6
  export declare const DEPLOYMENT_RECORD_VERSION = 1;
7
+ /**
8
+ * The media type of a `DeploymentRecordV1` document: what a Domain deployer publishes it with, and
9
+ * what a host serves it with at `/.well-known/astrale/deployment.json`.
10
+ */
11
+ export declare const DEPLOYMENT_RECORD_MEDIA_TYPE = "application/vnd.astrale.deployment-record+json;v=1";
7
12
  /** The commit a deployment was built from, as the deployer declared it. */
8
13
  export interface DeploymentCommitV1 {
9
14
  /** The full commit SHA, 40 hexadecimal digits in either case; git prints it in lower case. */
@@ -50,3 +55,19 @@ export interface DeploymentRecordV1 {
50
55
  * does, and never fails the whole listing over it.
51
56
  */
52
57
  export declare function acceptDeploymentRecord(input: unknown): DeploymentRecordV1;
58
+ /** The document of one record: what a Domain deployer publishes and a host serves verbatim. */
59
+ export interface DeploymentRecordDocument {
60
+ readonly contentType: string;
61
+ readonly bytes: Uint8Array;
62
+ }
63
+ /**
64
+ * The document of one record, as a Domain deployer publishes it: the record admitted, as the
65
+ * UTF-8 bytes of its RFC 8785 canonical JSON, with `DEPLOYMENT_RECORD_MEDIA_TYPE`. One record has
66
+ * one document, byte for byte, whoever writes it.
67
+ */
68
+ export declare function encodeDeploymentRecord(record: DeploymentRecordV1): DeploymentRecordDocument;
69
+ /**
70
+ * Admit the document of one record: `DEPLOYMENT_RECORD_MEDIA_TYPE`, and UTF-8 JSON whose value
71
+ * `acceptDeploymentRecord` admits. Anything else throws.
72
+ */
73
+ export declare function decodeDeploymentRecord(document: DeploymentRecordDocument): DeploymentRecordV1;
@@ -1,10 +1,16 @@
1
1
  import { patterns } from '@astrale-os/kernel-dsl/v1/addressing';
2
+ import { json } from '@astrale-os/kernel-dsl/value';
2
3
  import { isVersion } from '../../platform/versioning/version.js';
3
4
  import { ENVIRONMENT_NAME } from '../environment.js';
4
5
  import { contentOf, parseDeploymentLabel } from './label.js';
5
6
  import { deploymentLine } from './line.js';
6
7
  export const DEPLOYMENT_RECORD_FORMAT = 'astrale.deployment-record';
7
8
  export const DEPLOYMENT_RECORD_VERSION = 1;
9
+ /**
10
+ * The media type of a `DeploymentRecordV1` document: what a Domain deployer publishes it with, and
11
+ * what a host serves it with at `/.well-known/astrale/deployment.json`.
12
+ */
13
+ export const DEPLOYMENT_RECORD_MEDIA_TYPE = 'application/vnd.astrale.deployment-record+json;v=1';
8
14
  const DIGEST = /^sha256:[0-9a-f]{64}$/u;
9
15
  /** The full hexadecimal SHA-1 of a commit, in either case, as a deployment name reads it. */
10
16
  const SHA = /^[0-9a-f]{40}$/iu;
@@ -64,6 +70,34 @@ export function acceptDeploymentRecord(input) {
64
70
  commit: record.commit === null ? null : commit(record.commit),
65
71
  });
66
72
  }
73
+ /**
74
+ * The document of one record, as a Domain deployer publishes it: the record admitted, as the
75
+ * UTF-8 bytes of its RFC 8785 canonical JSON, with `DEPLOYMENT_RECORD_MEDIA_TYPE`. One record has
76
+ * one document, byte for byte, whoever writes it.
77
+ */
78
+ export function encodeDeploymentRecord(record) {
79
+ return Object.freeze({
80
+ contentType: DEPLOYMENT_RECORD_MEDIA_TYPE,
81
+ bytes: new TextEncoder().encode(json.serialize(acceptDeploymentRecord(record))),
82
+ });
83
+ }
84
+ /**
85
+ * Admit the document of one record: `DEPLOYMENT_RECORD_MEDIA_TYPE`, and UTF-8 JSON whose value
86
+ * `acceptDeploymentRecord` admits. Anything else throws.
87
+ */
88
+ export function decodeDeploymentRecord(document) {
89
+ if (document?.contentType !== DEPLOYMENT_RECORD_MEDIA_TYPE) {
90
+ throw new TypeError('Deployment record document has another media type.');
91
+ }
92
+ let value;
93
+ try {
94
+ value = JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(document.bytes));
95
+ }
96
+ catch (cause) {
97
+ throw new TypeError('Deployment record document is not UTF-8 JSON.', { cause });
98
+ }
99
+ return acceptDeploymentRecord(value);
100
+ }
67
101
  function commit(input) {
68
102
  const based = input !== null && typeof input === 'object' && Object.hasOwn(input, 'base');
69
103
  const value = exact(input, based ? ['sha', 'dirty', 'base'] : ['sha', 'dirty'], 'commit');
@@ -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
+ }