@astrale-os/adapter-cloudflare 0.5.0-beta.175 → 0.5.0-beta.177

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 (96) hide show
  1. package/README.md +101 -64
  2. package/dist/adapter.d.ts +6 -5
  3. package/dist/adapter.js +8 -8
  4. package/dist/hosting/activation.d.ts +30 -0
  5. package/dist/hosting/activation.js +71 -0
  6. package/dist/hosting/assets.d.ts +14 -0
  7. package/dist/hosting/assets.js +53 -0
  8. package/dist/hosting/bounded.d.ts +10 -0
  9. package/dist/hosting/bounded.js +32 -0
  10. package/dist/hosting/bytes.d.ts +6 -0
  11. package/dist/hosting/bytes.js +25 -0
  12. package/dist/hosting/client.d.ts +139 -0
  13. package/dist/hosting/client.js +459 -0
  14. package/dist/hosting/dispatcher/cron.d.mts +10 -0
  15. package/dist/hosting/dispatcher/cron.mjs +135 -0
  16. package/dist/hosting/dispatcher/deployments.d.mts +12 -0
  17. package/dist/hosting/dispatcher/deployments.mjs +133 -0
  18. package/dist/hosting/dispatcher/dispatcher.d.mts +43 -0
  19. package/dist/hosting/dispatcher/dispatcher.mjs +96 -0
  20. package/dist/hosting/dispatcher/env.d.mts +46 -0
  21. package/dist/hosting/dispatcher/failure.d.mts +4 -0
  22. package/dist/hosting/dispatcher/failure.mjs +15 -0
  23. package/dist/hosting/dispatcher/index.d.mts +4 -0
  24. package/dist/hosting/dispatcher/index.mjs +10 -0
  25. package/dist/hosting/dispatcher/modules.d.ts +25 -0
  26. package/dist/hosting/dispatcher/modules.js +56 -0
  27. package/dist/hosting/dispatcher/services.d.mts +28 -0
  28. package/dist/hosting/dispatcher/services.mjs +231 -0
  29. package/dist/hosting/identity.d.ts +15 -0
  30. package/dist/hosting/identity.js +36 -0
  31. package/dist/hosting/index.d.ts +33 -0
  32. package/dist/hosting/index.js +31 -0
  33. package/dist/hosting/kv.d.ts +89 -0
  34. package/dist/hosting/kv.js +202 -0
  35. package/dist/hosting/listing.d.ts +33 -0
  36. package/dist/hosting/listing.js +57 -0
  37. package/dist/hosting/names.d.ts +16 -0
  38. package/dist/hosting/names.js +28 -0
  39. package/dist/hosting/provision/account.d.ts +11 -0
  40. package/dist/hosting/provision/account.js +86 -0
  41. package/dist/hosting/provision/dispatcher.d.ts +44 -0
  42. package/dist/hosting/provision/dispatcher.js +101 -0
  43. package/dist/hosting/provision/ingress.d.ts +12 -0
  44. package/dist/hosting/provision/ingress.js +31 -0
  45. package/dist/hosting/provision/plan.d.ts +143 -0
  46. package/dist/hosting/provision/plan.js +246 -0
  47. package/dist/hosting/provision/routing.d.ts +82 -0
  48. package/dist/hosting/provision/routing.js +226 -0
  49. package/dist/hosting/readiness.d.ts +53 -0
  50. package/dist/hosting/readiness.js +82 -0
  51. package/dist/hosting/refusal.d.ts +28 -0
  52. package/dist/hosting/refusal.js +13 -0
  53. package/dist/hosting/retirement.d.ts +68 -0
  54. package/dist/hosting/retirement.js +119 -0
  55. package/dist/hosting/retry-after.d.ts +6 -0
  56. package/dist/hosting/retry-after.js +55 -0
  57. package/dist/hosting/script.d.ts +181 -0
  58. package/dist/hosting/script.js +312 -0
  59. package/dist/hosting/testing/fake-cloudflare.d.ts +202 -0
  60. package/dist/hosting/testing/fake-cloudflare.js +699 -0
  61. package/dist/hosting/testing/index.d.ts +6 -0
  62. package/dist/hosting/testing/index.js +6 -0
  63. package/dist/hosting/time.d.ts +4 -0
  64. package/dist/hosting/time.js +20 -0
  65. package/dist/identity/material.d.ts +0 -1
  66. package/dist/identity/material.js +1 -10
  67. package/dist/legacy/direct/index.d.ts +5 -9
  68. package/dist/legacy/direct/index.js +5 -9
  69. package/dist/namespace/configure.d.ts +2 -6
  70. package/dist/namespace/configure.js +15 -12
  71. package/dist/namespace/coordinates.d.ts +3 -26
  72. package/dist/namespace/coordinates.js +13 -45
  73. package/dist/namespace/deploy-release.d.ts +23 -30
  74. package/dist/namespace/deploy-release.js +88 -155
  75. package/dist/namespace/hosting.d.ts +29 -0
  76. package/dist/namespace/hosting.js +120 -0
  77. package/dist/namespace/index.d.ts +7 -10
  78. package/dist/namespace/index.js +7 -10
  79. package/dist/namespace/list.d.ts +11 -26
  80. package/dist/namespace/list.js +48 -125
  81. package/dist/namespace/readiness.d.ts +10 -20
  82. package/dist/namespace/readiness.js +15 -65
  83. package/dist/release/index.d.ts +0 -1
  84. package/dist/release/index.js +0 -1
  85. package/package.json +12 -4
  86. package/template/astrale.config.ts +10 -7
  87. package/dist/namespace/api.d.ts +0 -71
  88. package/dist/namespace/api.js +0 -265
  89. package/dist/namespace/calls.d.ts +0 -29
  90. package/dist/namespace/calls.js +0 -104
  91. package/dist/namespace/routing.d.ts +0 -55
  92. package/dist/namespace/routing.js +0 -165
  93. package/dist/namespace/script.d.ts +0 -106
  94. package/dist/namespace/script.js +0 -278
  95. package/dist/release/staged-worker-name.d.ts +0 -3
  96. package/dist/release/staged-worker-name.js +0 -10
package/README.md CHANGED
@@ -11,85 +11,136 @@ npm install @astrale-os/sdk@beta @astrale-os/adapter-cloudflare@beta
11
11
  Bare installs return to stable releases when the package set is promoted and npm `latest` advances.
12
12
 
13
13
  One `cloudflare()` value configures one Project Environment, in one of two modes. With
14
- `namespace`, each release becomes one immutable deployment in a platform dispatch namespace. Without
15
- it, the Environment is on the legacy direct mode: one stable Worker, replaced in place by each
16
- deploy. A deploy never installs: install the URL it prints with
17
- `astrale domain install <url> --direct -i <instance>`.
14
+ `namespace`, each release becomes one immutable deployment in a Workers for Platforms dispatch
15
+ namespace, on your own Cloudflare account or on a platform's. Without it, the Environment is on the
16
+ legacy direct mode: one stable Worker, replaced in place by each deploy. A deploy never installs:
17
+ install the URL it prints with `astrale domain install <url> --direct -i <instance>`.
18
18
 
19
19
  Generated Worker modules are minified by default, including separately loaded runtime modules.
20
20
  The Environment's `wrangler` overlay cannot change minification: every Environment of one source
21
21
  must emit the same JavaScript bytes. `cloudflare({ wrangler: { minify: false } })` is rejected.
22
22
 
23
- ## Namespace mode
23
+ ## Host it yourself
24
+
25
+ Anyone with a Cloudflare account, Workers for Platforms and a domain of their own can host
26
+ immutable deployments, with no Astrale infrastructure:
27
+
28
+ 1. **A domain.** Add a zone to Cloudflare and pick a routing domain under it dedicated to the
29
+ deployments, such as `deploy.example.dev`: every deployment answers at
30
+ `https://<label>.deploy.example.dev`. Nothing else may serve a host at, under or above it, and
31
+ its wildcard name needs an edge certificate (Advanced Certificate Manager, or Total TLS).
32
+ 2. **A token.** Create an API token with Workers Scripts Edit, Workers KV Storage Edit, Workers
33
+ Routes Edit and Zone DNS Edit on that zone. Export it as `CLOUDFLARE_API_TOKEN`, with
34
+ `CLOUDFLARE_ACCOUNT_ID` (and `CLOUDFLARE_ZONE_ID` when the zone cannot be found from the domain).
35
+ 3. **The Environment.**
36
+
37
+ ```ts
38
+ production: {
39
+ deployment: cloudflare({
40
+ namespace: { name: 'my-deployments', routingDomain: 'deploy.example.dev' },
41
+ secrets: '.env.production',
42
+ }),
43
+ },
44
+ ```
45
+
46
+ 4. **Provision once.** `astrale-domain cloudflare plan production` reads the account and prints
47
+ what provisioning does and what blocks it; `astrale-domain cloudflare init production`
48
+ provisions it (both are commands this adapter declares; `--json` prints the result):
49
+ the dispatch namespace, its routing KV, the dispatcher Worker (`<name>-dispatcher`, or
50
+ `--dispatcher <worker>`), a proxied `AAAA 100::` record `*.<domain>` and the route
51
+ `*.<domain>/*` to the dispatcher, then checks that a never-deployed host reaches it over HTTPS.
52
+ It is idempotent: a rerun adopts what exists and uploads the dispatcher again.
53
+ 5. **Deploy.** `astrale-domain deploy production` prints the deployment URL, which
54
+ `astrale domain install` takes.
24
55
 
25
- ```ts
26
- production: {
27
- deployment: cloudflare({
28
- namespace: { name: 'astrale-platform', routingDomain: 'platform.astrale.ai' },
29
- secrets: '.env.production',
30
- }),
31
- },
32
- ```
56
+ ## Namespace mode
33
57
 
34
- Only the platform deploys there (Shell, Services and the other platform Domains), so every line is
35
- readable. `astrale-domain deploy <environment>` uploads one script named by the deployment label,
36
- create-only (`If-None-Match: *`), which answers at `https://<label>.<routingDomain>` through the
37
- platform dispatcher. Nothing is ever replaced in place: another build or configuration is another
38
- label.
58
+ `astrale-domain deploy <environment>` uploads one script named by the deployment label, create-only
59
+ (`If-None-Match: *`), which answers at `https://<label>.<routingDomain>` through the namespace's
60
+ dispatcher. Nothing is ever replaced in place: another build or configuration is another label.
61
+ Every line is readable. Namespace mode is a thin layer over the hosting kit below.
39
62
 
40
63
  - **Credentials.** `CLOUDFLARE_API_TOKEN` must edit the namespace's scripts and the values of its
41
64
  routing KV; the account is `namespace.account`, else `CLOUDFLARE_ACCOUNT_ID`. Neither is ever
42
65
  written to the configuration.
43
66
  - **Routing KV.** The deploy finds the namespace's routing KV by its title,
44
- `astrale-deployment-routing-<first 16 bytes of sha256(name), in hexadecimal>`, which the platform
45
- provisioning creates. A namespace without it is refused before any effect.
67
+ `astrale-deployment-routing-<first 16 bytes of sha256(name), in hexadecimal>`, which
68
+ provisioning creates. A namespace without it is refused before any effect. The names of
69
+ Services' deployments namespaces (`astrale-deployments-<hex>`) are refused: Services manages
70
+ their scripts.
46
71
  - **Bindings.** Each new deployment binds `WORKER_URL`, `ASTRALE_BUILD_DIGEST`, `ASTRALE_SETTINGS`
47
- (the router as `ROUTER`), the author's `vars`, the service bindings of `wrangler.services`, the
48
- router, its version metadata, and as `secret_text` exactly the Environment's secrets and
49
- `ASTRALE_SIGNING_IDENTITY`, a key generated for that deployment and kept nowhere else. Its tags
50
- name its artifact (build digest), line and key; never `astrale-service`, which is Services'.
51
- - **Activation.** The deploy writes `summary:<label>` (owner `platform`, state `active`, retention
52
- `preview`, the activation instant), then `record:<label>`, whose presence makes the dispatcher
53
- route the label. Neither ever holds a provider version or a secret digest. Readiness then waits,
54
- bounded, until the URL serves the sealed release from the provider version the upload created.
72
+ (the router as `ROUTER`), the author's `vars`, the service and dispatch namespace bindings of
73
+ `wrangler`, the router, its version metadata, and as `secret_text` exactly the Environment's
74
+ secrets and `ASTRALE_SIGNING_IDENTITY`, a key generated for that deployment and kept nowhere
75
+ else. Its tags name its artifact (build digest), line and key; never `astrale-service`, which is
76
+ Services'.
77
+ - **Activation.** The deploy writes `record:<label>`, whose presence makes the dispatcher route the
78
+ label; it never holds a provider version or a secret digest. Readiness then waits, bounded,
79
+ until the URL serves the sealed release from the provider version the upload created.
55
80
  - **Reuse.** A redeploy of the same release finds the script under its label. It is reused only
56
81
  when its artifact and line tags and its record name this build and configuration (or, when an
57
82
  interrupted deploy left it unactivated, when it binds exactly this configuration). Then only that
58
83
  script's declared secrets are rewritten in place, and readiness waits for the version the
59
84
  rewrite minted. Anything else is a `deployment-conflict`, refused with nothing written; no other
60
- deployment is ever touched.
61
- - **Retention.** `retain` (called once a Publication commits) marks the summary `published`, which
62
- the platform's preview expiry never retires. A label the platform withdrew (`retirement:<label>`)
63
- answers `DEPLOYMENT_RETIRED` at the dispatcher and is refused by every later deploy.
64
- - **Listing.** `astrale-domain list` lists the Environment's deployments from the routing KV, and
65
- writes nothing: one row per `summary:<label>` of the Environment's line, with its record as
66
- `record:<label>` stores it (a record the SDK refuses is listed with `record: null` and the
67
- refusal), and the call target `{ kind: 'namespace', namespace, script }` that the platform's
68
- per-script tooling takes, such as its in-place secret rotation. A summary without its record is
69
- `deploying` (not activated), and an active one whose label carries the tombstone is `retiring`.
70
- Nothing on the namespace records last calls: they come from its calls dataset,
71
- `astrale_deployment_calls_<the same hexadecimal>`, through the Analytics Engine SQL API, and an
72
- active preview expires 30 days after the later of its activation and its last call. The token
73
- then also needs Account Analytics Read; without it, the deployments are listed without last calls
74
- or expiries.
85
+ deployment is ever touched. A `summary:<label>` an older adapter wrote is ignored.
86
+ - **Retirement.** A label whose `retirement:<label>` the kit's `markRetired` wrote answers
87
+ `DEPLOYMENT_RETIRED` at the dispatcher and is refused by every later deploy. The namespace keeps
88
+ every deployment until then: there is no retention, preview expiry or `retain`.
89
+ - **Listing.** `astrale-domain list` lists the Environment's deployments from the routing KV and
90
+ the namespace, and writes nothing: one row per label of the Environment's line that
91
+ `record:<label>` activated or `retirement:<label>` withdrew, with its record as stored (a record
92
+ the SDK refuses is listed with `record: null` and the refusal), its script's creation instant and
93
+ key while the script exists, and the call target `{ kind: 'namespace', namespace, script }`. Its
94
+ state is `active`, `retiring` (tombstoned, script not deleted yet), `retired` (script deleted) or
95
+ `failed` (activated, script gone).
75
96
  - **Refused.** A direct-mode parameter beside `namespace` (`route`, `workerName`, `identityIssuer`,
76
97
  `addressing`, `signingIdentity`), a `router.binding` other than `ROUTER`, and any `wrangler` key
77
98
  but `services`, `dispatch_namespaces` and `compatibility_flags`, `triggers` first: an immutable
78
99
  deployment runs no cron of its own. A `dispatch_namespaces` entry is exactly
79
100
  `{ binding, namespace }`; it is frozen into the configuration (`bindings.dispatchNamespaces`), so
80
- into the label, bound at the create-only upload, and never names the platform namespace the
81
- deployment lives in (AM-174). Which namespaces a deployment may bind is its host's to decide.
101
+ into the label, bound at the create-only upload, and never names the namespace the deployment
102
+ lives in (AM-174). Which namespaces a deployment may bind is its host's to decide.
82
103
  `durable` is frozen into the configuration (`settings.durableOptions`, AM-175), so into the
83
104
  label, and bound in `ASTRALE_SETTINGS`: a durable run stays on the deployment that started it.
84
105
 
106
+ ## Hosting kit
107
+
108
+ `@astrale-os/adapter-cloudflare/hosting` is the one implementation of this hosting, for namespace
109
+ mode and for any other host that keeps its own routing beside it. Everything but provisioning runs in
110
+ Node and inside a Worker (`fetch` and WebCrypto only).
111
+
112
+ - **Client.** `CloudflareClient`: bounded bodies, `Retry-After`/429 waits by policy
113
+ (`DEFAULT_RATE_LIMIT`, `OPERATOR_RATE_LIMIT` or none), timeouts and a deadline, typed
114
+ `CloudflareApiError`s (`kind`, `status`, `codes`), cursor and numbered pagination, multipart
115
+ forms and raw KV text.
116
+ - **Routing KV.** `RoutingKv` (by title or id) and the contract: `record:<label>` (canonical
117
+ `DeploymentRecordV1`, written once; re-publication only for the same release, `sameRelease`),
118
+ `retirement:<label>` (`withdrawn`), `current:<name>` (one of the name's labels), `cron:<name>`
119
+ (canonical UTC crons). Every write is read back before it counts.
120
+ - **Scripts.** `deploymentBindings`, `deploymentTags` (with a host's extra tags),
121
+ `uploadDeploymentScript` (create-only, every module kind, assets in parallel),
122
+ `observeScript`, `scriptSettings`, `scriptSecretNames`, `putScriptSecret`, `deleteScript`, and
123
+ `createSigningIdentity` (WebCrypto Ed25519).
124
+ - **Lifecycle.** `activateDeployment`, `promoteDeployment`, `replaceSchedules`, two-phase retirement
125
+ (`markRetired`, then `completeRetirement` once `retirementVisible` sees the dispatcher answer
126
+ `DEPLOYMENT_RETIRED`), `waitForReadiness` through the public URL or a dispatch binding, and
127
+ `listLineDeployments`.
128
+ - **Dispatcher.** Plain ES modules shipped with the package (`dispatcherModules()`), routing a label
129
+ to its script, a name to `current:<name>`, and firing `cron:<name>` on schedule. A host prepends
130
+ its own routing and schedule targets with `createDispatcher(extension)` in its own `index.mjs`,
131
+ without forking it. The public never reaches `/__scheduled`; nothing in the generated Worker
132
+ serves it yet.
133
+ - **Provisioning.** `planHosting` reads; `applyHosting` refuses any blocker before its first write;
134
+ `provisionHosting` does both.
135
+ - **Testing.** `@astrale-os/adapter-cloudflare/hosting/testing` exports `FakeCloudflare`, one fake
136
+ account that serves public requests with the real dispatcher.
137
+
85
138
  ## Legacy direct mode
86
139
 
87
140
  Deprecated: replaced by namespace mode, and deleted with the legacy deploy path (D10) once no
88
- configuration on any repository's main selects direct mode. Its consumers until they move: the
89
- domains configurations of ai-gateway, closing, grc, integrations and issues; domains organization,
90
- whose `prod` binds an EU-jurisdiction R2 bucket that namespace mode does not admit yet; the Services
91
- `beta`, `prod` and `development` environments and its frozen `admin-beta`; the Shell and its frozen
92
- `legacy-v3`; and ui:domain.
141
+ configuration on any repository's main selects direct mode. Its consumers until they move: domains
142
+ organization, whose `prod` binds an EU-jurisdiction R2 bucket that namespace mode does not admit
143
+ yet, and the SDK's own e2e fixture (`__e2e__/flagship/fixtures/v1`).
93
144
 
94
145
  `astrale-domain deploy <environment>` builds locally and deploys the Worker and frontend
95
146
  remotely. Omitting `route` uses the observed stable `*.workers.dev` issuer.
@@ -134,20 +185,6 @@ publish them. Unlike the names above, the match is case-sensitive, as Wrangler's
134
185
  and a nested `docs/_worker.js` remain ordinary files. Remove such a file from the public directory,
135
186
  or rename it if it is a browser script.
136
187
 
137
- ## Provider-local staging
138
-
139
- Managed Cloudflare providers may stage immutable implementation candidates without exposing their
140
- identity to Publications or installed Kernels:
141
-
142
- ```ts
143
- import { stagedWorkerName } from '@astrale-os/adapter-cloudflare/release'
144
- ```
145
-
146
- This subpath is runtime-safe. `stagedWorkerName(providerDeployment)` addresses one private staged
147
- candidate. The public Worker name and `/invoke` endpoint remain stable across compatible
148
- implementation and Schema updates. Provider staging identity never enters Release, Publication,
149
- Kernel routing, or graph identity.
150
-
151
188
  ## Deployment bindings
152
189
 
153
190
  The bundled Worker carries no Environment value, so every Environment of one source bundles the
package/dist/adapter.d.ts CHANGED
@@ -9,16 +9,17 @@ export type { NamespaceDependencies } from './namespace/index.js';
9
9
  export interface CloudflareDependencies extends CloudflareDeployDependencies {
10
10
  readonly artifact: typeof artifact;
11
11
  readonly signingIdentity: typeof signingIdentity;
12
- /** The effects of namespace mode. Default: `fetch`, `process.env`, a new key, the clock. */
12
+ /** The effects of namespace mode. Default: `fetch`, `process.env` and a new WebCrypto key. */
13
13
  readonly namespace?: NamespaceDependencies;
14
14
  }
15
15
  /**
16
16
  * Deploy one Project Environment through Cloudflare Workers.
17
17
  *
18
- * With `namespace`, each release becomes one immutable deployment in that platform dispatch
19
- * namespace (adapter interface v2), and `astrale-domain list` lists them. Without it, the
20
- * Environment stays on the legacy direct mode: one stable Worker replaced in place by each deploy
21
- * (CT33).
18
+ * With `namespace`, each release becomes one immutable deployment in that dispatch namespace
19
+ * (adapter interface v2), `astrale-domain list` lists them, and the adapter's commands
20
+ * `astrale-domain cloudflare plan|init <environment>` provision the namespace on your own account.
21
+ * Without it, the Environment stays on the legacy direct mode: one stable Worker replaced in place
22
+ * by each deploy (CT33).
22
23
  */
23
24
  export declare function cloudflare(parameters?: CloudflareParams): Deployment<CloudflareParams, Adapter<CloudflareParams>>;
24
25
  export declare function cloudflareWithDependencies(parameters: CloudflareParams, dependencies: CloudflareDependencies): Deployment<CloudflareParams, Adapter<CloudflareParams>>;
package/dist/adapter.js CHANGED
@@ -1,19 +1,20 @@
1
1
  import { defineAdapter, defineDeployment } from '@astrale-os/sdk/deployment/adapter';
2
2
  import { createRequire } from 'node:module';
3
3
  import { cloudflareWorkerBundler } from './build/bundler.js';
4
- import { createSigningIdentity } from './identity/material.js';
4
+ import { createSigningIdentity } from './hosting/identity.js';
5
5
  import { artifact, deployCloudflare, directReadiness, materialize, runWranglerDeploymentStatus, runWranglerRollout, signingIdentity, } from './legacy/direct/index.js';
6
- import { admitNamespace, configureNamespace, deployNamespaceRelease, listNamespaceDeployments, placeNamespace, retainNamespaceDeployment, } from './namespace/index.js';
6
+ import { admitNamespace, configureNamespace, deployNamespaceRelease, listNamespaceDeployments, hostingCommands, placeNamespace, } from './namespace/index.js';
7
7
  export { waitForReadiness } from './legacy/direct/index.js';
8
8
  const packageVersion = createRequire(import.meta.url)('../package.json')
9
9
  .version;
10
10
  /**
11
11
  * Deploy one Project Environment through Cloudflare Workers.
12
12
  *
13
- * With `namespace`, each release becomes one immutable deployment in that platform dispatch
14
- * namespace (adapter interface v2), and `astrale-domain list` lists them. Without it, the
15
- * Environment stays on the legacy direct mode: one stable Worker replaced in place by each deploy
16
- * (CT33).
13
+ * With `namespace`, each release becomes one immutable deployment in that dispatch namespace
14
+ * (adapter interface v2), `astrale-domain list` lists them, and the adapter's commands
15
+ * `astrale-domain cloudflare plan|init <environment>` provision the namespace on your own account.
16
+ * Without it, the Environment stays on the legacy direct mode: one stable Worker replaced in place
17
+ * by each deploy (CT33).
17
18
  */
18
19
  export function cloudflare(parameters = {}) {
19
20
  return cloudflareWithDependencies(parameters, {
@@ -36,7 +37,7 @@ export function cloudflareWithDependencies(parameters, dependencies) {
36
37
  placement: async (parameters) => placeNamespace(parameters),
37
38
  deployRelease: (parameters, context) => deployNamespaceRelease(parameters, context, namespace),
38
39
  list: (parameters, context) => listNamespaceDeployments(parameters, context, namespace),
39
- retain: (parameters, context) => retainNamespaceDeployment(parameters, context, namespace),
40
+ commands: hostingCommands(namespace),
40
41
  // Legacy direct mode (CT33 `legacy-direct`), deleted with it (D10).
41
42
  prepare: dependencies.artifact,
42
43
  deploy: (parameters, context) => deployCloudflare(parameters, context, dependencies),
@@ -54,6 +55,5 @@ function platformNamespaceDependencies() {
54
55
  fetch: (input, init) => fetch(input, init),
55
56
  environment: process.env,
56
57
  createSigningIdentity,
57
- now: () => new Date(),
58
58
  });
59
59
  }
@@ -0,0 +1,30 @@
1
+ import type { DeploymentRecordV1 } from '@astrale-os/sdk/deployment/address';
2
+ import type { RoutingKv } from './kv.js';
3
+ /**
4
+ * Activate one deployment: publish its record at `record:<label>`, which the dispatcher reads to
5
+ * route the label and serves at `/.well-known/astrale/deployment.json`. A record never changes once
6
+ * published: a record already there is kept and answered when it declares the same release
7
+ * (`sameRelease`, so only its commit may differ), and refused (`deployment-conflict`) otherwise,
8
+ * without any write.
9
+ */
10
+ export declare function activateDeployment(kv: RoutingKv, record: DeploymentRecordV1): Promise<DeploymentRecordV1>;
11
+ /** The record a label was activated with, admitted, or undefined when it is not activated. */
12
+ export declare function readRecord(kv: RoutingKv, label: string): Promise<DeploymentRecordV1 | undefined>;
13
+ /**
14
+ * Point a name (a deployment line) at the deployment its URL `https://<name>.<routing domain>`
15
+ * serves: `current:<name>` = `label`, or no deployment when `label` is null (the key is deleted).
16
+ * The label must be one of the name's own. Writing the same value again writes nothing, so a rerun
17
+ * converges.
18
+ */
19
+ export declare function promoteDeployment(kv: RoutingKv, name: string, label: string | null): Promise<void>;
20
+ /** The deployment a name serves now, or null. */
21
+ export declare function readCurrent(kv: RoutingKv, name: string): Promise<string | null>;
22
+ /**
23
+ * Replace a name's crons with canonical UTC crons (`cron:<name>`), or delete them when the set is
24
+ * empty. A set that is not canonical is refused (`schedules-invalid`) before anything is written.
25
+ * The dispatcher fires a due cron on the name's current deployment, once its upload bound a
26
+ * `cron` trigger.
27
+ */
28
+ export declare function replaceSchedules(kv: RoutingKv, name: string, crons: readonly string[]): Promise<readonly string[]>;
29
+ /** A name's crons: empty when it has none; a stored set that is not canonical is refused. */
30
+ export declare function readSchedules(kv: RoutingKv, name: string): Promise<readonly string[]>;
@@ -0,0 +1,71 @@
1
+ import { cronKey, currentKey, decodeCurrent, decodeRecord, decodeSchedules, encodeRecord, encodeSchedules, isLabelOf, recordKey, sameRelease, } from './kv.js';
2
+ import { HostingRefusal } from './refusal.js';
3
+ /**
4
+ * Activate one deployment: publish its record at `record:<label>`, which the dispatcher reads to
5
+ * route the label and serves at `/.well-known/astrale/deployment.json`. A record never changes once
6
+ * published: a record already there is kept and answered when it declares the same release
7
+ * (`sameRelease`, so only its commit may differ), and refused (`deployment-conflict`) otherwise,
8
+ * without any write.
9
+ */
10
+ export async function activateDeployment(kv, record) {
11
+ const label = record.providerScript;
12
+ const text = encodeRecord(record);
13
+ const stored = await readRecord(kv, label);
14
+ if (stored !== undefined) {
15
+ if (!sameRelease(stored, record)) {
16
+ throw new HostingRefusal('deployment-conflict', `Another record is already published for ${label}; nothing was written.`);
17
+ }
18
+ return stored;
19
+ }
20
+ await kv.put(recordKey(label), text, 'application/json');
21
+ return record;
22
+ }
23
+ /** The record a label was activated with, admitted, or undefined when it is not activated. */
24
+ export async function readRecord(kv, label) {
25
+ const text = await kv.get(recordKey(label));
26
+ return text === undefined ? undefined : decodeRecord(text, label);
27
+ }
28
+ /**
29
+ * Point a name (a deployment line) at the deployment its URL `https://<name>.<routing domain>`
30
+ * serves: `current:<name>` = `label`, or no deployment when `label` is null (the key is deleted).
31
+ * The label must be one of the name's own. Writing the same value again writes nothing, so a rerun
32
+ * converges.
33
+ */
34
+ export async function promoteDeployment(kv, name, label) {
35
+ if (label !== null && !isLabelOf(label, name)) {
36
+ throw new HostingRefusal('route-invalid', `${label} is not a deployment of ${name}.`);
37
+ }
38
+ if ((await kv.get(currentKey(name))) === (label ?? undefined))
39
+ return;
40
+ if (label === null)
41
+ await kv.delete(currentKey(name));
42
+ else
43
+ await kv.put(currentKey(name), label, 'text/plain');
44
+ }
45
+ /** The deployment a name serves now, or null. */
46
+ export async function readCurrent(kv, name) {
47
+ const text = await kv.get(currentKey(name));
48
+ return text === undefined ? null : decodeCurrent(text, name);
49
+ }
50
+ /**
51
+ * Replace a name's crons with canonical UTC crons (`cron:<name>`), or delete them when the set is
52
+ * empty. A set that is not canonical is refused (`schedules-invalid`) before anything is written.
53
+ * The dispatcher fires a due cron on the name's current deployment, once its upload bound a
54
+ * `cron` trigger.
55
+ */
56
+ export async function replaceSchedules(kv, name, crons) {
57
+ const encoded = encodeSchedules(crons);
58
+ if (encoded.text === undefined) {
59
+ if ((await kv.get(cronKey(name))) !== undefined)
60
+ await kv.delete(cronKey(name));
61
+ }
62
+ else {
63
+ await kv.put(cronKey(name), encoded.text, 'application/json');
64
+ }
65
+ return encoded.crons;
66
+ }
67
+ /** A name's crons: empty when it has none; a stored set that is not canonical is refused. */
68
+ export async function readSchedules(kv, name) {
69
+ const text = await kv.get(cronKey(name));
70
+ return text === undefined ? Object.freeze([]) : decodeSchedules(text, name);
71
+ }
@@ -0,0 +1,14 @@
1
+ import type { CloudflareClient } from './client.js';
2
+ /** One static asset of a deployment, served by its Worker through the `ASSETS` binding. */
3
+ export interface ScriptAsset {
4
+ /** The URL path it is served at, `/`-prefixed. */
5
+ readonly path: string;
6
+ readonly bytes: Uint8Array;
7
+ readonly contentType?: string;
8
+ }
9
+ /**
10
+ * Upload one script's static assets through its namespace's asset upload session, the buckets
11
+ * Cloudflare asks for in parallel (at most `CLOUDFLARE_FAN_OUT` at once), and answer the completion
12
+ * token the script upload binds them with.
13
+ */
14
+ export declare function uploadAssets(client: CloudflareClient, namespace: string, script: string, assets: readonly ScriptAsset[]): Promise<string>;
@@ -0,0 +1,53 @@
1
+ import { CLOUDFLARE_FAN_OUT, mapBounded } from './bounded.js';
2
+ import { base64, sha256Hex } from './bytes.js';
3
+ import { CloudflareApiError } from './client.js';
4
+ /**
5
+ * Upload one script's static assets through its namespace's asset upload session, the buckets
6
+ * Cloudflare asks for in parallel (at most `CLOUDFLARE_FAN_OUT` at once), and answer the completion
7
+ * token the script upload binds them with.
8
+ */
9
+ export async function uploadAssets(client, namespace, script, assets) {
10
+ const byHash = new Map();
11
+ const manifest = {};
12
+ for (const asset of assets) {
13
+ const hash = (await sha256Hex(asset.bytes)).slice(0, 32);
14
+ byHash.set(hash, asset);
15
+ manifest[asset.path] = { hash, size: asset.bytes.byteLength };
16
+ }
17
+ const session = decodeSession(await client.json('POST', client.account(`/workers/dispatch/namespaces/${encodeURIComponent(namespace)}/scripts/${encodeURIComponent(script)}/assets-upload-session`), { manifest }));
18
+ if (session.buckets.length === 0)
19
+ return session.jwt;
20
+ const tokens = await mapBounded(session.buckets, CLOUDFLARE_FAN_OUT, async (bucket) => {
21
+ const form = new FormData();
22
+ for (const hash of bucket) {
23
+ const asset = byHash.get(hash);
24
+ if (asset === undefined)
25
+ invalid('Cloudflare asked for an unknown asset.');
26
+ form.append(hash, new File([base64(asset.bytes)], hash, {
27
+ type: asset.contentType ?? 'application/octet-stream',
28
+ }));
29
+ }
30
+ const result = (await client.form('POST', client.account('/workers/assets/upload?base64=true'), form, { bearer: session.jwt }));
31
+ return typeof result?.jwt === 'string' && result.jwt.length > 0 ? result.jwt : undefined;
32
+ });
33
+ const completion = tokens.find((token) => token !== undefined);
34
+ if (completion === undefined)
35
+ invalid('Cloudflare returned no asset completion token.');
36
+ return completion;
37
+ }
38
+ function decodeSession(value) {
39
+ const session = value;
40
+ if (typeof session?.jwt !== 'string' || session.jwt.length === 0) {
41
+ invalid('Cloudflare returned an invalid asset upload session.');
42
+ }
43
+ const buckets = session.buckets ?? [];
44
+ if (!Array.isArray(buckets) ||
45
+ buckets.some((bucket) => !Array.isArray(bucket) ||
46
+ bucket.some((hash) => typeof hash !== 'string' || hash.length === 0))) {
47
+ invalid('Cloudflare returned invalid asset upload buckets.');
48
+ }
49
+ return { jwt: session.jwt, buckets: buckets };
50
+ }
51
+ function invalid(message) {
52
+ throw new CloudflareApiError('upstream', message);
53
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Cloudflare API requests one fan-out keeps in flight. A Worker invocation holds at most six
3
+ * simultaneous connections, and Cloudflare's API rate limit is per user (the token owner).
4
+ */
5
+ export declare const CLOUDFLARE_FAN_OUT = 6;
6
+ /**
7
+ * Map items through a sliding window of at most `limit` operations, preserving item order. The
8
+ * first failure stops new work and rejects once the work already in flight settles.
9
+ */
10
+ export declare function mapBounded<Item, Result>(items: readonly Item[], limit: number, operation: (item: Item) => Promise<Result>): Promise<Result[]>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Cloudflare API requests one fan-out keeps in flight. A Worker invocation holds at most six
3
+ * simultaneous connections, and Cloudflare's API rate limit is per user (the token owner).
4
+ */
5
+ export const CLOUDFLARE_FAN_OUT = 6;
6
+ /**
7
+ * Map items through a sliding window of at most `limit` operations, preserving item order. The
8
+ * first failure stops new work and rejects once the work already in flight settles.
9
+ */
10
+ export async function mapBounded(items, limit, operation) {
11
+ if (!Number.isSafeInteger(limit) || limit < 1) {
12
+ throw new RangeError('Fan-out limit must be a positive safe integer.');
13
+ }
14
+ const results = [];
15
+ let failure;
16
+ let next = 0;
17
+ const worker = async () => {
18
+ while (failure === undefined && next < items.length) {
19
+ const index = next++;
20
+ try {
21
+ results[index] = await operation(items[index]);
22
+ }
23
+ catch (cause) {
24
+ failure ??= { cause };
25
+ }
26
+ }
27
+ };
28
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
29
+ if (failure !== undefined)
30
+ throw failure.cause;
31
+ return results;
32
+ }
@@ -0,0 +1,6 @@
1
+ /** The lower-case hexadecimal SHA-256 of text (UTF-8) or bytes, with WebCrypto. */
2
+ export declare function sha256Hex(input: string | Uint8Array): Promise<string>;
3
+ /** Bytes as base64, in chunks small enough for `String.fromCharCode`. */
4
+ export declare function base64(bytes: Uint8Array): string;
5
+ /** Bytes as unpadded base64url. */
6
+ export declare function base64url(bytes: Uint8Array): string;
@@ -0,0 +1,25 @@
1
+ const encoder = new TextEncoder();
2
+ /** The lower-case hexadecimal SHA-256 of text (UTF-8) or bytes, with WebCrypto. */
3
+ export async function sha256Hex(input) {
4
+ const bytes = typeof input === 'string' ? encoder.encode(input) : input;
5
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
6
+ return hex(new Uint8Array(digest));
7
+ }
8
+ /** Bytes as base64, in chunks small enough for `String.fromCharCode`. */
9
+ export function base64(bytes) {
10
+ let binary = '';
11
+ for (let offset = 0; offset < bytes.byteLength; offset += 32_768) {
12
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 32_768));
13
+ }
14
+ return btoa(binary);
15
+ }
16
+ /** Bytes as unpadded base64url. */
17
+ export function base64url(bytes) {
18
+ return base64(bytes).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
19
+ }
20
+ function hex(bytes) {
21
+ let text = '';
22
+ for (const byte of bytes)
23
+ text += byte.toString(16).padStart(2, '0');
24
+ return text;
25
+ }