@astrale-os/adapter-astrale 0.4.1 → 0.5.0-beta.2

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 ADDED
@@ -0,0 +1,11 @@
1
+ # `@astrale-os/adapter-astrale`
2
+
3
+ Managed Astrale platform deployment adapter for Astrale Domains.
4
+
5
+ During the SDK beta program, install the beta cohort explicitly:
6
+
7
+ ```bash
8
+ npm install @astrale-os/sdk@beta @astrale-os/adapter-astrale@beta
9
+ ```
10
+
11
+ Bare installs return to stable releases when the cohort is promoted and npm `latest` advances.
package/dist/astrale.d.ts CHANGED
@@ -3,15 +3,15 @@
3
3
  *
4
4
  * Ships the domain THROUGH the target instance's `services` domain instead of to
5
5
  * the author's own cloud account. `deploy` builds the same single-module workerd
6
- * bundle the Cloudflare adapter ships (shared codegen + `wrangler deploy
7
- * --dry-run`), then:
6
+ * bundle and frontend artifact the Cloudflare adapter ships (shared codegen +
7
+ * `wrangler deploy --dry-run`), then:
8
8
  *
9
9
  * 1. hosts it on the instance's services domain — `CloudflareWorker.deploy
10
10
  * { name, entry, modules, assets, vars }` → a public worker `url`;
11
11
  * 2. (optional) pushes runtime secrets via the service's `setSecret`;
12
12
  * 3. installs the domain on the instance from that `url`
13
13
  * (`astrale domain install <url>` → the child kernel's `Domain.install`,
14
- * which fetches + verifies the signed bundle and runs its `postInstall`).
14
+ * which fetches, verifies, and reconciles the signed Publication).
15
15
  *
16
16
  * The admin control plane is NOT involved: hosting moved out of admin into the
17
17
  * per-instance `services` domain (a separate bounded context). No Cloudflare
@@ -24,9 +24,60 @@
24
24
  * `watch` is identical to the Cloudflare adapter's local dev (wrangler dev on
25
25
  * localhost) — managed deploys change WHERE the worker runs, not how you iterate.
26
26
  */
27
- import type { DomainAdapter } from '@astrale-os/sdk';
28
- import type { AstraleParams } from './params';
27
+ import type { CompiledFrontendArtifact, DomainAdapter } from '@astrale-os/sdk';
28
+ import { buildCloudflareWorker, buildFrontend, logTo, prepare, runWranglerBundle, runWranglerDev, runWranglerTypes, watchFrontend } from '@astrale-os/adapter-cloudflare/build';
29
+ import { loadDeclaredSecrets } from '@astrale-os/sdk/cli';
30
+ import type { AstraleParams } from './params.js';
31
+ import { ensureServicesDomainInstalled, waitForManagedServiceReady } from './managed-service.js';
29
32
  export declare function astrale(envs: Record<string, AstraleParams>): DomainAdapter<AstraleParams>;
33
+ export interface AstraleAdapterDependencies {
34
+ readonly astraleBin: typeof astraleBin;
35
+ readonly astraleCall: typeof astraleCall;
36
+ readonly buildCloudflareWorker: typeof buildCloudflareWorker;
37
+ readonly buildFrontend: typeof buildFrontend;
38
+ readonly ensureServicesDomainInstalled: typeof ensureServicesDomainInstalled;
39
+ readonly loadDeclaredSecrets: typeof loadDeclaredSecrets;
40
+ readonly logTo: typeof logTo;
41
+ readonly prepare: typeof prepare;
42
+ readonly readBytes: (path: string) => Buffer;
43
+ readonly readText: (path: string) => string;
44
+ readonly runAstrale: typeof runAstrale;
45
+ readonly runWranglerBundle: typeof runWranglerBundle;
46
+ readonly runWranglerDev: typeof runWranglerDev;
47
+ readonly runWranglerTypes: typeof runWranglerTypes;
48
+ readonly watchFrontend: typeof watchFrontend;
49
+ readonly waitForManagedServiceReady: typeof waitForManagedServiceReady;
50
+ }
51
+ /** Exact orchestration seam used by adapter-level managed deployment qualification. */
52
+ export declare function astraleWithDependencies(envs: Record<string, AstraleParams>, dependencies: AstraleAdapterDependencies): DomainAdapter<AstraleParams>;
53
+ export interface ManagedDeploymentVersionInput {
54
+ readonly bundle: Uint8Array;
55
+ readonly frontend?: CompiledFrontendArtifact;
56
+ readonly compatibilityDate?: string;
57
+ readonly compatibilityFlags?: readonly string[];
58
+ readonly serviceBindings?: readonly {
59
+ readonly name: string;
60
+ readonly service: string;
61
+ }[];
62
+ readonly vars?: Readonly<Record<string, string>>;
63
+ }
64
+ /**
65
+ * Stable generation for the whole managed serving contract. The Worker bytes
66
+ * alone are insufficient: frontend files/routes, compatibility settings,
67
+ * bindings, and vars can all change without producing a different bundle.
68
+ */
69
+ export declare function managedDeploymentVersion(input: ManagedDeploymentVersionInput): string;
70
+ /** Preserve author vars while reserving the generation value owned by the adapter. */
71
+ export declare function managedDeploymentVars(vars: Readonly<Record<string, string>> | undefined, deploymentVersion: string): Readonly<Record<string, string>>;
72
+ type AssetFile = {
73
+ path: string;
74
+ contentBase64: string;
75
+ contentType?: string;
76
+ };
77
+ /** Convert the materialized frontend into the services provider's immutable
78
+ * asset envelope. External frontends deliberately retain routes but no local
79
+ * files, so the callable worker and browser host remain independently swappable. */
80
+ export declare function managedFrontendAssets(frontend: CompiledFrontendArtifact | undefined): readonly AssetFile[];
30
81
  /** Semantic method address; independent of the installed domain's tree placement. */
31
82
  export declare function servicesDeployMethodFor(origin: string): string;
32
83
  /** Service name from the origin: first DNS label (e.g. `my-notes.example.dev` → `my-notes`). */
@@ -36,4 +87,21 @@ export declare function serviceNameFor(origin: string): string;
36
87
  * (serviceNameFor) can collide across domains (`app.foo.dev` / `app.bar.dev`),
37
88
  * so the service NODE is placed by this; the display name stays the short label. */
38
89
  export declare function serviceSlugFor(origin: string): string;
90
+ /**
91
+ * Invoke the user's `astrale` CLI for one platform call (against the target
92
+ * INSTANCE) and parse its JSON output. The CLI owns identity (IdP session,
93
+ * audience scoping, delegation minting) — reimplementing that here would fork
94
+ * the trust path.
95
+ */
96
+ declare function astraleCall(projectDir: string, params: AstraleParams, call: {
97
+ instance: string;
98
+ path: string;
99
+ data: unknown;
100
+ viaStdin?: boolean;
101
+ }): Promise<unknown>;
102
+ /** Spawn the astrale CLI, feed optional stdin, and return stdout. Throws with bounded diagnostics. */
103
+ declare function runAstrale(bin: string, cwd: string, args: string[], stdin?: string): Promise<string>;
104
+ /** The user's astrale CLI: ~/.astrale/bin/astrale when present, else PATH. */
105
+ declare function astraleBin(_projectDir: string): string;
106
+ export {};
39
107
  //# sourceMappingURL=astrale.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"astrale.d.ts","sourceRoot":"","sources":["../src/astrale.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAa,aAAa,EAAY,MAAM,iBAAiB,CAAA;AAoBzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAW7C,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,GAAG,aAAa,CAAC,aAAa,CAAC,CAuOzF;AAkGD,qFAAqF;AACrF,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,gGAAgG;AAChG,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED;;;oFAGoF;AACpF,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAKrD"}
1
+ {"version":3,"file":"astrale.d.ts","sourceRoot":"","sources":["../src/astrale.ts"],"names":[],"mappings":"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"}
package/dist/astrale.js CHANGED
@@ -3,15 +3,15 @@
3
3
  *
4
4
  * Ships the domain THROUGH the target instance's `services` domain instead of to
5
5
  * the author's own cloud account. `deploy` builds the same single-module workerd
6
- * bundle the Cloudflare adapter ships (shared codegen + `wrangler deploy
7
- * --dry-run`), then:
6
+ * bundle and frontend artifact the Cloudflare adapter ships (shared codegen +
7
+ * `wrangler deploy --dry-run`), then:
8
8
  *
9
9
  * 1. hosts it on the instance's services domain — `CloudflareWorker.deploy
10
10
  * { name, entry, modules, assets, vars }` → a public worker `url`;
11
11
  * 2. (optional) pushes runtime secrets via the service's `setSecret`;
12
12
  * 3. installs the domain on the instance from that `url`
13
13
  * (`astrale domain install <url>` → the child kernel's `Domain.install`,
14
- * which fetches + verifies the signed bundle and runs its `postInstall`).
14
+ * which fetches, verifies, and reconciles the signed Publication).
15
15
  *
16
16
  * The admin control plane is NOT involved: hosting moved out of admin into the
17
17
  * per-instance `services` domain (a separate bounded context). No Cloudflare
@@ -27,31 +27,64 @@
27
27
  // The shared Cloudflare-Workers build toolkit — the managed adapter ships the
28
28
  // SAME bundle the direct `cloudflare` adapter does, then routes it through the
29
29
  // platform instead of `wrangler deploy`.
30
- import { buildClient, CLIENT_DIST_DIR, logTo, prepare, resolveClientDir, runWranglerBundle, runWranglerDev, } from '@astrale-os/adapter-cloudflare/build';
30
+ import { buildCloudflareWorker, buildFrontend, logTo, prepare, runWranglerBundle, runWranglerDev, runWranglerTypes, watchFrontend, } from '@astrale-os/adapter-cloudflare/build';
31
31
  import { defineAdapter } from '@astrale-os/sdk';
32
32
  import { loadDeclaredSecrets } from '@astrale-os/sdk/cli';
33
33
  import { spawn } from 'node:child_process';
34
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
35
- import { join, relative } from 'node:path';
36
- import { ensureServicesDomainInstalled, runManagedInstallWithRetry, waitForManagedServiceReady, } from './managed-service';
34
+ import { createHash } from 'node:crypto';
35
+ import { existsSync, readFileSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { ensureServicesDomainInstalled, waitForManagedServiceReady } from './managed-service.js';
37
38
  const DEFAULT_PORT = 8787;
38
39
  const DEFAULT_SERVICES_ORIGIN = 'services.astrale.ai';
40
+ const DEPLOYMENT_VERSION_BINDING = 'ASTRALE_DEPLOYMENT_VERSION';
39
41
  export function astrale(envs) {
42
+ return astraleWithDependencies(envs, {
43
+ astraleBin,
44
+ astraleCall,
45
+ buildCloudflareWorker,
46
+ buildFrontend,
47
+ ensureServicesDomainInstalled,
48
+ loadDeclaredSecrets,
49
+ logTo,
50
+ prepare,
51
+ readBytes: (path) => readFileSync(path),
52
+ readText: (path) => readFileSync(path, 'utf8'),
53
+ runAstrale,
54
+ runWranglerBundle,
55
+ runWranglerDev,
56
+ runWranglerTypes,
57
+ watchFrontend,
58
+ waitForManagedServiceReady,
59
+ });
60
+ }
61
+ /** Exact orchestration seam used by adapter-level managed deployment qualification. */
62
+ export function astraleWithDependencies(envs, dependencies) {
40
63
  return defineAdapter({
41
64
  name: 'astrale',
42
65
  envs,
66
+ async build(params, ctx) {
67
+ await dependencies.buildCloudflareWorker({
68
+ params,
69
+ context: ctx,
70
+ onLog: dependencies.logTo(),
71
+ });
72
+ },
43
73
  // Local dev is unchanged: wrangler dev on localhost. Managed deploys change
44
74
  // WHERE the worker ships, not the inner loop.
45
75
  async watch(params, ctx) {
46
- const clientDir = resolveAstraleClientDir(params, ctx);
47
- const { configPath } = await prepare({
76
+ const frontend = await dependencies.buildFrontend({
77
+ definition: ctx.definition,
78
+ projectDir: ctx.projectDir,
79
+ onLog: dependencies.logTo(),
80
+ });
81
+ const { configPath } = await dependencies.prepare({
48
82
  ...(params.vars ? { vars: params.vars } : {}),
49
83
  ...(params.host ? { host: params.host } : {}),
50
84
  port: params.port ?? DEFAULT_PORT,
51
- }, ctx, 'dev', { servesClient: Boolean(clientDir), bundleAssets: Boolean(clientDir) });
52
- if (clientDir)
53
- await buildClient(clientDir, ctx.projectDir, logTo());
54
- const handle = await runWranglerDev({
85
+ }, ctx, 'dev', frontend);
86
+ const onLog = dependencies.logTo();
87
+ const handle = await dependencies.runWranglerDev({
55
88
  projectDir: ctx.projectDir,
56
89
  configPath,
57
90
  port: params.port ?? DEFAULT_PORT,
@@ -59,61 +92,104 @@ export function astrale(envs) {
59
92
  autoPickPort: params.port === undefined,
60
93
  ...(params.host ? { ip: '0.0.0.0' } : {}),
61
94
  onReload: ctx.onReload,
62
- onLog: logTo(),
95
+ onLog,
63
96
  });
64
- return { url: params.host ?? handle.url, stop: handle.stop };
97
+ let frontendWatch;
98
+ try {
99
+ frontendWatch = dependencies.watchFrontend({
100
+ definition: ctx.definition,
101
+ projectDir: ctx.projectDir,
102
+ onLog,
103
+ onRebuild: ctx.onReload,
104
+ });
105
+ }
106
+ catch (error) {
107
+ try {
108
+ await handle.stop();
109
+ }
110
+ catch (stopError) {
111
+ throw new AggregateError([error, stopError], 'frontend watcher failed to start and the local worker failed to stop');
112
+ }
113
+ throw error;
114
+ }
115
+ return {
116
+ url: params.host ?? handle.url,
117
+ async stop() {
118
+ const results = await Promise.allSettled([
119
+ frontendWatch?.stop() ?? Promise.resolve(),
120
+ handle.stop(),
121
+ ]);
122
+ const failures = results
123
+ .filter((result) => result.status === 'rejected')
124
+ .map((result) => result.reason);
125
+ if (failures.length === 1)
126
+ throw failures[0];
127
+ if (failures.length > 1)
128
+ throw new AggregateError(failures, 'local development failed to stop');
129
+ },
130
+ };
65
131
  },
66
132
  // Config hot-regen for the local dev loop: same codegen as `watch` (the
67
133
  // running `wrangler dev` reloads its generated config + entry itself) —
68
134
  // the params mapping must stay identical to `watch`'s prepare call.
69
135
  async regenerate(params, ctx) {
70
- const clientDir = resolveAstraleClientDir(params, ctx);
71
- await prepare({
136
+ const frontend = await dependencies.buildFrontend({
137
+ definition: ctx.definition,
138
+ projectDir: ctx.projectDir,
139
+ onLog: dependencies.logTo(),
140
+ });
141
+ const { configPath } = await dependencies.prepare({
72
142
  ...(params.vars ? { vars: params.vars } : {}),
73
143
  ...(params.host ? { host: params.host } : {}),
74
144
  port: params.port ?? DEFAULT_PORT,
75
- }, ctx, 'dev', { servesClient: Boolean(clientDir), bundleAssets: Boolean(clientDir) });
76
- if (clientDir)
77
- await buildClient(clientDir, ctx.projectDir, logTo());
145
+ }, ctx, 'dev', frontend);
146
+ await dependencies.runWranglerTypes({
147
+ projectDir: ctx.projectDir,
148
+ configPath,
149
+ onLog: dependencies.logTo(),
150
+ });
78
151
  },
79
152
  async deploy(params, ctx) {
80
- const log = logTo();
153
+ const log = dependencies.logTo();
81
154
  const instance = params.instance;
82
155
  if (!instance) {
83
156
  throw new Error(`astrale adapter: this env has no "instance" — managed deploys need the target ` +
84
157
  `instance slug (e.g. prod: { instance: '<your-instance-slug>' }).`);
85
158
  }
86
159
  const servicesOrigin = params.servicesOrigin ?? DEFAULT_SERVICES_ORIGIN;
87
- const name = params.name ?? serviceNameFor(ctx.domain.origin);
88
- const clientDir = resolveAstraleClientDir(params, ctx);
89
- const runProjectAstrale = (args) => runAstrale(astraleBin(ctx.projectDir), ctx.projectDir, args);
90
- await ensureServicesDomainInstalled({
160
+ const origin = ctx.definition.schema.origin;
161
+ const schemaRevision = ctx.definition.domain.$.revision;
162
+ const name = params.name ?? serviceNameFor(origin);
163
+ const runProjectAstrale = (args) => dependencies.runAstrale(dependencies.astraleBin(ctx.projectDir), ctx.projectDir, args);
164
+ await dependencies.ensureServicesDomainInstalled({
91
165
  runAstrale: runProjectAstrale,
92
166
  instance,
93
167
  servicesOrigin,
94
168
  identityArgs: identityArgs(params),
95
169
  log,
96
170
  });
97
- // Build + pack the client SPA: the services worker serves it under /ui.
98
- let assetFiles = [];
99
- if (clientDir) {
100
- await buildClient(clientDir, ctx.projectDir, log);
101
- assetFiles = readAssetFiles(join(ctx.projectDir, CLIENT_DIST_DIR));
102
- if (assetFiles.length)
103
- log(`packed client SPA (${assetFiles.length} files) — /ui ships managed`);
171
+ // Materialize the exact Domain-owned frontend recipe through the same
172
+ // build boundary used by direct Cloudflare deployments. The managed
173
+ // provider receives those immutable files separately from the Worker
174
+ // module while the Worker publication retains the same route metadata.
175
+ const frontend = await dependencies.buildFrontend({
176
+ definition: ctx.definition,
177
+ projectDir: ctx.projectDir,
178
+ onLog: log,
179
+ });
180
+ const assetFiles = managedFrontendAssets(frontend);
181
+ if (assetFiles.length) {
182
+ log(`packed frontend (${assetFiles.length} files) — declared routes ship managed`);
104
183
  }
105
184
  // 1. Codegen + bundle: the exact worker module a Cloudflare deploy would
106
185
  // ship, produced locally with `wrangler deploy --dry-run` (no auth).
107
- const { configPath } = await prepare({}, ctx, 'dev', {
108
- servesClient: Boolean(clientDir),
109
- bundleAssets: false,
110
- });
186
+ const { configPath } = await dependencies.prepare({}, ctx, 'dev', frontend);
111
187
  // The generated wrangler config is the single source of truth for the
112
188
  // runtime contract the bundle is validated against (compat date + flags,
113
189
  // incl. any `params.wrangler` overrides). Carry it to the managed deploy so
114
190
  // the live script matches the local dry-run exactly — notably `nodejs_compat`
115
191
  // for the `node:*` imports the worker runtime pulls in.
116
- const wranglerConfig = JSON.parse(readFileSync(configPath, 'utf8'));
192
+ const wranglerConfig = JSON.parse(dependencies.readText(configPath));
117
193
  // Carry the worker's outbound service bindings (e.g. ROUTER → admin-router)
118
194
  // to the managed deploy — without them the deployed worker can't ride the
119
195
  // router to reach the kernel and its first credential verification 522s on
@@ -123,19 +199,28 @@ export function astrale(envs) {
123
199
  .filter((s) => s.binding !== 'SELF')
124
200
  .map((s) => ({ name: s.binding, service: s.service }));
125
201
  const outDir = join(ctx.projectDir, '.astrale', 'dist-managed');
126
- const bundlePath = await runWranglerBundle({
202
+ const bundlePath = await dependencies.runWranglerBundle({
127
203
  projectDir: ctx.projectDir,
128
204
  configPath,
129
205
  outDir,
130
206
  onLog: log,
131
207
  });
132
- const bundle = readFileSync(bundlePath);
208
+ const bundle = dependencies.readBytes(bundlePath);
209
+ const deploymentVersion = managedDeploymentVersion({
210
+ bundle,
211
+ frontend,
212
+ compatibilityDate: wranglerConfig.compatibility_date,
213
+ compatibilityFlags: wranglerConfig.compatibility_flags,
214
+ serviceBindings,
215
+ vars: params.vars,
216
+ });
217
+ const deploymentVars = managedDeploymentVars(params.vars, deploymentVersion);
133
218
  // 2. Host it on the instance's services domain. `CloudflareWorker.deploy`
134
219
  // upserts a worker by name on the platform's CF Workers-for-Platforms
135
220
  // backend and returns its public URL. Payload rides STDIN — a bundle is
136
221
  // megabytes of base64, far past argv limits.
137
222
  log(`deploying "${name}" to the services domain on "${instance}"…`);
138
- const deployed = (await astraleCall(ctx.projectDir, params, {
223
+ const deployed = (await dependencies.astraleCall(ctx.projectDir, params, {
139
224
  instance,
140
225
  path: servicesDeployMethodFor(servicesOrigin),
141
226
  data: {
@@ -144,7 +229,7 @@ export function astrale(envs) {
144
229
  // the services domain provisions no `/services` folder — so the service
145
230
  // is a root child keyed by the origin slug (unique per instance); `name`
146
231
  // stays the short display label.
147
- path: `/${serviceSlugFor(ctx.domain.origin)}`,
232
+ path: `/${serviceSlugFor(origin)}`,
148
233
  name,
149
234
  entry: 'index.mjs',
150
235
  ...(wranglerConfig.compatibility_date
@@ -159,12 +244,12 @@ export function astrale(envs) {
159
244
  ? {
160
245
  assets: {
161
246
  files: assetFiles,
162
- notFound: 'single-page-application',
247
+ notFound: 'none',
163
248
  runWorkerFirst: true,
164
249
  },
165
250
  }
166
251
  : {}),
167
- ...(params.vars ? { vars: params.vars } : {}),
252
+ vars: deploymentVars,
168
253
  },
169
254
  viaStdin: true,
170
255
  }));
@@ -176,18 +261,18 @@ export function astrale(envs) {
176
261
  // 3. Author runtime secrets: read the env's dotenv locally and push each
177
262
  // via the service's write-only `setSecret` (values ride stdin, never argv).
178
263
  if (params.secrets) {
179
- const secrets = loadDeclaredSecrets(join(ctx.projectDir, params.secrets), params.secrets);
264
+ const secrets = dependencies.loadDeclaredSecrets(join(ctx.projectDir, params.secrets), params.secrets);
180
265
  const keys = Object.keys(secrets);
181
266
  if (keys.length)
182
267
  log(`setting ${keys.length} secret(s) from ${params.secrets}…`);
183
268
  for (const [key, value] of Object.entries(secrets)) {
184
- await astraleCall(ctx.projectDir, params, {
269
+ await dependencies.astraleCall(ctx.projectDir, params, {
185
270
  instance,
186
271
  // setSecret is an instance method on the deployed service node, which
187
272
  // step 2 placed at `/${serviceSlugFor(origin)}` (a root child — the
188
273
  // services domain provisions no `/services` folder). Address it there,
189
274
  // NOT under a `/services/<name>` path that doesn't exist.
190
- path: `/${serviceSlugFor(ctx.domain.origin)}::setSecret`,
275
+ path: `/${serviceSlugFor(origin)}::setSecret`,
191
276
  data: { name: key, value },
192
277
  viaStdin: true,
193
278
  });
@@ -196,17 +281,23 @@ export function astrale(envs) {
196
281
  // Cloudflare's dispatch namespace can acknowledge a script before the
197
282
  // public tenant host is consistently serving the new worker. Installing
198
283
  // before `/meta` + JWKS are reachable turns that propagation window into a
199
- // postInstall fetch abort and leaves partial graph state behind.
200
- const expectedSchemaHash = await expectedSchemaHashForManagedUrl(ctx, url, log);
201
- log(`waiting for managed service "${ctx.domain.origin}" at ${url}…`);
202
- await waitForManagedServiceReady(url, ctx.domain.origin, { log, expectedSchemaHash });
284
+ // Publication fetch abort with an outcome-ambiguous install request.
285
+ log(`waiting for managed service "${origin}" at ${url}…`);
286
+ await dependencies.waitForManagedServiceReady(url, origin, {
287
+ log,
288
+ expectedSchemaRevision: schemaRevision,
289
+ expectedDeploymentVersion: deploymentVersion,
290
+ });
203
291
  // 4. Install the domain on the instance from the hosted URL. The child
204
- // kernel fetches the signed bundle, verifies it, and runs its postInstall.
292
+ // kernel fetches and verifies the signed Publication and bundle.
205
293
  // A managed deploy ALWAYS aliases identity — the domain's origin
206
- // (`ctx.domain.origin`) is served from the platform host (`…svc.<routing>`)
294
+ // (`origin`) is served from the platform host (`…svc.<routing>`)
207
295
  // it never matches — so consent to the override is implicit and required.
208
- log(`installing "${ctx.domain.origin}" on "${instance}" from ${url}…`);
209
- await runManagedInstallWithRetry(() => runAstrale(astraleBin(ctx.projectDir), ctx.projectDir, [
296
+ log(`installing "${origin}" on "${instance}" from ${url}…`);
297
+ // A timeout, reset, or 5xx after delivery is outcome-ambiguous: the first
298
+ // install may already have committed. Never replay without a public
299
+ // idempotency/reconciliation contract.
300
+ await dependencies.runAstrale(dependencies.astraleBin(ctx.projectDir), ctx.projectDir, [
210
301
  'domain',
211
302
  'install',
212
303
  url,
@@ -218,14 +309,11 @@ export function astrale(envs) {
218
309
  '--timeout',
219
310
  '240000',
220
311
  ...identityArgs(params),
221
- ]), {
222
- log,
223
- beforeRetry: () => waitForManagedServiceReady(url, ctx.domain.origin, { log, expectedSchemaHash }),
224
- });
312
+ ]);
225
313
  return {
226
314
  url,
227
315
  nextSteps: ` installed on "${instance}" — call it:\n` +
228
- ` astrale call "/${ctx.domain.origin}/..." -i ${instance}`,
316
+ ` astrale call "/${origin}/..." -i ${instance}`,
229
317
  };
230
318
  },
231
319
  secretsFile(params) {
@@ -233,29 +321,61 @@ export function astrale(envs) {
233
321
  },
234
322
  });
235
323
  }
236
- async function expectedSchemaHashForManagedUrl(ctx, url, log) {
237
- if (!ctx.schemaHashForUrl)
238
- return undefined;
239
- try {
240
- return await ctx.schemaHashForUrl(url);
241
- }
242
- catch (error) {
243
- log(`warning: could not compute expected schema hash for ${url}; ` +
244
- `readiness will verify domain/JWKS only (${errorMessage(error)})`);
245
- return undefined;
246
- }
324
+ /**
325
+ * Stable generation for the whole managed serving contract. The Worker bytes
326
+ * alone are insufficient: frontend files/routes, compatibility settings,
327
+ * bindings, and vars can all change without producing a different bundle.
328
+ */
329
+ export function managedDeploymentVersion(input) {
330
+ const hash = createHash('sha256');
331
+ const assets = managedFrontendAssets(input.frontend);
332
+ updateHashFrame(hash, 'format', 'astrale-managed-deployment-v2');
333
+ updateHashFrame(hash, 'worker', input.bundle);
334
+ updateHashFrame(hash, 'serving', JSON.stringify({
335
+ frontend: input.frontend === undefined
336
+ ? null
337
+ : {
338
+ source: input.frontend.artifact.source.kind,
339
+ digest: input.frontend.digest,
340
+ assets: assets.length > 0
341
+ ? {
342
+ files: assets.map(({ path, contentType }) => ({
343
+ path,
344
+ contentType: contentType ?? null,
345
+ })),
346
+ notFound: 'none',
347
+ runWorkerFirst: true,
348
+ }
349
+ : null,
350
+ },
351
+ compatibilityDate: input.compatibilityDate ?? null,
352
+ compatibilityFlags: [...(input.compatibilityFlags ?? [])].sort(),
353
+ serviceBindings: [...(input.serviceBindings ?? [])]
354
+ .map(({ name, service }) => ({ name, service }))
355
+ .sort((left, right) => compareStable(`${left.name}\0${left.service}`, `${right.name}\0${right.service}`)),
356
+ vars: Object.entries(input.vars ?? {}).sort(([left], [right]) => compareStable(left, right)),
357
+ }));
358
+ return hash.digest('hex');
247
359
  }
248
- function errorMessage(error) {
249
- return error instanceof Error ? error.message : String(error);
360
+ function compareStable(left, right) {
361
+ return left < right ? -1 : left > right ? 1 : 0;
250
362
  }
251
- function resolveAstraleClientDir(params, ctx) {
252
- return resolveClientDir({
253
- adapterName: 'astrale',
254
- env: ctx.env,
255
- projectDir: ctx.projectDir,
256
- domain: ctx.domain,
257
- ...(params.client ? { client: params.client } : {}),
258
- });
363
+ function updateHashFrame(hash, label, value) {
364
+ const bytes = typeof value === 'string' ? Buffer.from(value) : value;
365
+ hash.update(`${Buffer.byteLength(label)}:`);
366
+ hash.update(label);
367
+ hash.update(`:${bytes.byteLength}:`);
368
+ hash.update(bytes);
369
+ }
370
+ /** Preserve author vars while reserving the generation value owned by the adapter. */
371
+ export function managedDeploymentVars(vars, deploymentVersion) {
372
+ if (Object.hasOwn(vars ?? {}, DEPLOYMENT_VERSION_BINDING)) {
373
+ throw new Error(`astrale adapter: \`${DEPLOYMENT_VERSION_BINDING}\` is adapter-managed and cannot be set in \`vars\`.`);
374
+ }
375
+ if (deploymentVersion.length === 0) {
376
+ throw new TypeError('astrale adapter: deployment version must not be empty.');
377
+ }
378
+ return Object.freeze({ ...vars, [DEPLOYMENT_VERSION_BINDING]: deploymentVersion });
259
379
  }
260
380
  /** Serving MIME by file extension. The provider serves each asset with the type
261
381
  * declared here VERBATIM — Cloudflare does NOT re-infer it from the path, so an
@@ -291,30 +411,22 @@ function assetMime(path) {
291
411
  const dot = path.lastIndexOf('.');
292
412
  return dot === -1 ? undefined : ASSET_MIME[path.slice(dot + 1).toLowerCase()];
293
413
  }
294
- /** Walk a built SPA dir into the services schema's `AssetFileSchema[]` (paths
295
- * rooted at `/`), stamping each file's serving MIME from its extension. */
296
- function readAssetFiles(distClientDir) {
297
- const out = [];
298
- if (!existsSync(distClientDir))
299
- return out;
300
- const walk = (dir) => {
301
- for (const entry of readdirSync(dir)) {
302
- const full = join(dir, entry);
303
- if (statSync(full).isDirectory())
304
- walk(full);
305
- else {
306
- const path = `/${relative(distClientDir, full).split('\\').join('/')}`;
307
- const contentType = assetMime(path);
308
- out.push({
309
- path,
310
- contentBase64: readFileSync(full).toString('base64'),
311
- ...(contentType ? { contentType } : {}),
312
- });
313
- }
314
- }
315
- };
316
- walk(distClientDir);
317
- return out;
414
+ /** Convert the materialized frontend into the services provider's immutable
415
+ * asset envelope. External frontends deliberately retain routes but no local
416
+ * files, so the callable worker and browser host remain independently swappable. */
417
+ export function managedFrontendAssets(frontend) {
418
+ if (frontend === undefined || frontend.artifact.source.kind === 'external') {
419
+ return Object.freeze([]);
420
+ }
421
+ return Object.freeze(frontend.files.map((file) => {
422
+ const path = `/${file.path}`;
423
+ const contentType = file.mediaType ?? assetMime(path);
424
+ return Object.freeze({
425
+ path,
426
+ contentBase64: Buffer.from(file.content).toString('base64'),
427
+ ...(contentType === undefined ? {} : { contentType }),
428
+ });
429
+ }));
318
430
  }
319
431
  /** Semantic method address; independent of the installed domain's tree placement. */
320
432
  export function servicesDeployMethodFor(origin) {
@@ -361,17 +473,18 @@ async function astraleCall(projectDir, params, call) {
361
473
  function identityArgs(params) {
362
474
  return params.identity ? ['--as', params.identity] : [];
363
475
  }
364
- /** Spawn the astrale CLI, feed optional stdin, return stdout+stderr. Throws on non-zero exit. */
476
+ /** Spawn the astrale CLI, feed optional stdin, and return stdout. Throws with bounded diagnostics. */
365
477
  async function runAstrale(bin, cwd, args, stdin) {
366
- const { code, out } = await new Promise((resolve, reject) => {
478
+ const { code, stdout, stderr } = await new Promise((resolve, reject) => {
367
479
  const child = spawn(bin, args, {
368
480
  cwd,
369
481
  stdio: [stdin !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'],
370
482
  });
371
- let out = '';
372
- child.stdout?.on('data', (b) => (out += b.toString()));
373
- child.stderr?.on('data', (b) => (out += b.toString()));
374
- child.on('exit', (c) => resolve({ code: c ?? 1, out }));
483
+ let stdout = '';
484
+ let stderr = '';
485
+ child.stdout?.on('data', (b) => (stdout += b.toString()));
486
+ child.stderr?.on('data', (b) => (stderr += b.toString()));
487
+ child.on('exit', (c) => resolve({ code: c ?? 1, stdout, stderr }));
375
488
  child.on('error', reject);
376
489
  if (stdin !== undefined) {
377
490
  child.stdin?.write(stdin);
@@ -379,11 +492,12 @@ async function runAstrale(bin, cwd, args, stdin) {
379
492
  }
380
493
  });
381
494
  if (code !== 0) {
382
- const authShaped = /AUTH_ERROR|expired|not signed in|credential/i.test(out);
383
- throw new Error(`astrale ${args[0]} ${args[1] ?? ''} failed (exit ${code}): ${out.trim().slice(0, 500)}` +
495
+ const diagnostics = `${stdout}\n${stderr}`.trim();
496
+ const authShaped = /AUTH_ERROR|expired|not signed in|credential/i.test(diagnostics);
497
+ throw new Error(`astrale ${args[0]} ${args[1] ?? ''} failed (exit ${code}): ${diagnostics.slice(0, 500)}` +
384
498
  (authShaped ? `\nIs the astrale CLI installed and signed in? (astrale auth login)` : ''));
385
499
  }
386
- return out;
500
+ return stdout;
387
501
  }
388
502
  /** The user's astrale CLI: ~/.astrale/bin/astrale when present, else PATH. */
389
503
  function astraleBin(_projectDir) {