@astrale-os/adapter-cloudflare 0.5.0-beta.176 → 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.
- package/README.md +101 -64
- package/dist/adapter.d.ts +6 -5
- package/dist/adapter.js +8 -8
- package/dist/hosting/activation.d.ts +30 -0
- package/dist/hosting/activation.js +71 -0
- package/dist/hosting/assets.d.ts +14 -0
- package/dist/hosting/assets.js +53 -0
- package/dist/hosting/bounded.d.ts +10 -0
- package/dist/hosting/bounded.js +32 -0
- package/dist/hosting/bytes.d.ts +6 -0
- package/dist/hosting/bytes.js +25 -0
- package/dist/hosting/client.d.ts +139 -0
- package/dist/hosting/client.js +459 -0
- package/dist/hosting/dispatcher/cron.d.mts +10 -0
- package/dist/hosting/dispatcher/cron.mjs +135 -0
- package/dist/hosting/dispatcher/deployments.d.mts +12 -0
- package/dist/hosting/dispatcher/deployments.mjs +133 -0
- package/dist/hosting/dispatcher/dispatcher.d.mts +43 -0
- package/dist/hosting/dispatcher/dispatcher.mjs +96 -0
- package/dist/hosting/dispatcher/env.d.mts +46 -0
- package/dist/hosting/dispatcher/failure.d.mts +4 -0
- package/dist/hosting/dispatcher/failure.mjs +15 -0
- package/dist/hosting/dispatcher/index.d.mts +4 -0
- package/dist/hosting/dispatcher/index.mjs +10 -0
- package/dist/hosting/dispatcher/modules.d.ts +25 -0
- package/dist/hosting/dispatcher/modules.js +56 -0
- package/dist/hosting/dispatcher/services.d.mts +28 -0
- package/dist/hosting/dispatcher/services.mjs +231 -0
- package/dist/hosting/identity.d.ts +15 -0
- package/dist/hosting/identity.js +36 -0
- package/dist/hosting/index.d.ts +33 -0
- package/dist/hosting/index.js +31 -0
- package/dist/hosting/kv.d.ts +89 -0
- package/dist/hosting/kv.js +202 -0
- package/dist/hosting/listing.d.ts +33 -0
- package/dist/hosting/listing.js +57 -0
- package/dist/hosting/names.d.ts +16 -0
- package/dist/hosting/names.js +28 -0
- package/dist/hosting/provision/account.d.ts +11 -0
- package/dist/hosting/provision/account.js +86 -0
- package/dist/hosting/provision/dispatcher.d.ts +44 -0
- package/dist/hosting/provision/dispatcher.js +101 -0
- package/dist/hosting/provision/ingress.d.ts +12 -0
- package/dist/hosting/provision/ingress.js +31 -0
- package/dist/hosting/provision/plan.d.ts +143 -0
- package/dist/hosting/provision/plan.js +246 -0
- package/dist/hosting/provision/routing.d.ts +82 -0
- package/dist/hosting/provision/routing.js +226 -0
- package/dist/hosting/readiness.d.ts +53 -0
- package/dist/hosting/readiness.js +82 -0
- package/dist/hosting/refusal.d.ts +28 -0
- package/dist/hosting/refusal.js +13 -0
- package/dist/hosting/retirement.d.ts +68 -0
- package/dist/hosting/retirement.js +119 -0
- package/dist/hosting/retry-after.d.ts +6 -0
- package/dist/hosting/retry-after.js +55 -0
- package/dist/hosting/script.d.ts +181 -0
- package/dist/hosting/script.js +312 -0
- package/dist/hosting/testing/fake-cloudflare.d.ts +202 -0
- package/dist/hosting/testing/fake-cloudflare.js +699 -0
- package/dist/hosting/testing/index.d.ts +6 -0
- package/dist/hosting/testing/index.js +6 -0
- package/dist/hosting/time.d.ts +4 -0
- package/dist/hosting/time.js +20 -0
- package/dist/identity/material.d.ts +0 -1
- package/dist/identity/material.js +1 -10
- package/dist/legacy/direct/index.d.ts +5 -9
- package/dist/legacy/direct/index.js +5 -9
- package/dist/namespace/configure.d.ts +2 -6
- package/dist/namespace/configure.js +15 -12
- package/dist/namespace/coordinates.d.ts +3 -26
- package/dist/namespace/coordinates.js +13 -45
- package/dist/namespace/deploy-release.d.ts +23 -30
- package/dist/namespace/deploy-release.js +88 -155
- package/dist/namespace/hosting.d.ts +29 -0
- package/dist/namespace/hosting.js +120 -0
- package/dist/namespace/index.d.ts +7 -10
- package/dist/namespace/index.js +7 -10
- package/dist/namespace/list.d.ts +11 -26
- package/dist/namespace/list.js +48 -125
- package/dist/namespace/readiness.d.ts +10 -20
- package/dist/namespace/readiness.js +15 -65
- package/dist/release/index.d.ts +0 -1
- package/dist/release/index.js +0 -1
- package/package.json +12 -4
- package/template/astrale.config.ts +10 -7
- package/dist/namespace/api.d.ts +0 -71
- package/dist/namespace/api.js +0 -265
- package/dist/namespace/calls.d.ts +0 -29
- package/dist/namespace/calls.js +0 -104
- package/dist/namespace/routing.d.ts +0 -55
- package/dist/namespace/routing.js +0 -165
- package/dist/namespace/script.d.ts +0 -106
- package/dist/namespace/script.js +0 -278
- package/dist/release/staged-worker-name.d.ts +0 -3
- 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
|
|
15
|
-
|
|
16
|
-
deploy. A deploy never installs:
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
|
48
|
-
router, its version metadata, and as `secret_text` exactly the Environment's
|
|
49
|
-
`ASTRALE_SIGNING_IDENTITY`, a key generated for that deployment and kept nowhere
|
|
50
|
-
name its artifact (build digest), line and key; never `astrale-service`, which is
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
- **
|
|
62
|
-
the
|
|
63
|
-
|
|
64
|
-
- **Listing.** `astrale-domain list` lists the Environment's deployments from the routing KV
|
|
65
|
-
writes nothing: one row per
|
|
66
|
-
`record:<label>`
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
`
|
|
70
|
-
|
|
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
|
|
81
|
-
|
|
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:
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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,
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|