blogwright 0.4.0-beta.4 → 0.4.0-beta.5

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/dist/cli.js CHANGED
@@ -15,6 +15,8 @@ Usage:
15
15
  Commands:
16
16
  init First-run wizard: writes config/production.jsonc
17
17
  bootstrap [env] Create/reconcile the infrastructure graph
18
+ (--switch-mode: move a bootstrapped stack
19
+ between static and SSR serving)
18
20
  deploy [env] Zip the repo, build in a MicroVM, publish to site/
19
21
  rollback <hash> [env] Re-deploy an existing build by hash
20
22
  delete [env] Empty the live site/ prefix
@@ -48,6 +50,10 @@ Options:
48
50
  --refresh Re-upload every file on deploy, even unchanged ones, so
49
51
  metadata fixes (content types, object tags) reach live
50
52
  objects the ETag comparison would otherwise skip.
53
+ --switch-mode Allow bootstrap to change a bootstrapped stack between
54
+ static and SSR serving. Without it that cutover is
55
+ refused, because the distribution moves before a deploy
56
+ in the new mode exists.
51
57
  --yes Confirm destructive operations
52
58
  --help Show this help
53
59
  `;
@@ -219,6 +225,10 @@ export async function main(argv, makeTerminal, makeContext, makeDiscoveryPorts,
219
225
  identifier: { type: 'string' },
220
226
  plain: { type: 'boolean', default: false },
221
227
  refresh: { type: 'boolean', default: false },
228
+ // Node's `parseArgs` matches option names literally - `--switch-mode`
229
+ // is declared kebab-case and read back the same way, because the camel
230
+ // form would reject the flag the usage text and docs advertise.
231
+ 'switch-mode': { type: 'boolean', default: false },
222
232
  yes: { type: 'boolean', default: false },
223
233
  help: { type: 'boolean', default: false },
224
234
  },
@@ -343,7 +353,7 @@ export async function main(argv, makeTerminal, makeContext, makeDiscoveryPorts,
343
353
  });
344
354
  switch (command) {
345
355
  case 'bootstrap':
346
- await commands.bootstrap(ctx);
356
+ await commands.bootstrap(ctx, undefined, { switchMode: values['switch-mode'] });
347
357
  break;
348
358
  case 'deploy':
349
359
  await commands.deploy(ctx, { refresh: values.refresh });
@@ -2,8 +2,17 @@ import { type ResourceNode } from 'blogwright-core';
2
2
  import type { OpsContext } from './context.js';
3
3
  import { type GraphContext } from './graph.js';
4
4
  import { type StatusEntry } from './render.js';
5
+ /**
6
+ * Refuse a silent serving-mode switch on a bootstrapped stack unless
7
+ * `--switch-mode` says the window is acceptable. A mode difference is a
8
+ * serving cutover, not a quiet convergence - the distribution's default
9
+ * behavior moves before a deploy in the new mode exists.
10
+ */
11
+ export declare function assertModeSwitchAllowed(ctx: OpsContext, switchMode: boolean): void;
5
12
  /** Create the full infrastructure graph. */
6
- export declare function bootstrap(ctx: OpsContext, nodes?: ResourceNode<OpsContext>[]): Promise<void>;
13
+ export declare function bootstrap(ctx: OpsContext, nodes?: ResourceNode<OpsContext>[], opts?: {
14
+ switchMode?: boolean;
15
+ }): Promise<void>;
7
16
  /**
8
17
  * Refuse to destroy the site while any plugin's own state object still
9
18
  * exists in the bucket - §State → Scoped state stores' "`blogwright
package/dist/commands.js CHANGED
@@ -24,12 +24,13 @@ import { AwsError, colors, findRepoRoot } from 'blogwright-core';
24
24
  * it. Both halves are asserted in `commands.test.ts`.
25
25
  */
26
26
  import { syncAfterDeploy } from 'blogwright-pds';
27
- import { invalidateChanged, invalidateCloudFront, manifestKey, microvmLogGroup, runBuild, } from './deploy.js';
27
+ import { invalidateChanged, invalidateCloudFront, manifestKey, microvmLogGroup, readManifest, runBuild, webFunctionJob, } from './deploy.js';
28
28
  import { applyGraph, destroyGraph } from './graph.js';
29
29
  import { clearRunningMicrovms } from './microvms.js';
30
30
  import { buildNodes, reconcileBuilderImage } from './nodes.js';
31
- import { formatDuration, logStatusEntries, renderHistoryTable, renderSummary, } from './render.js';
31
+ import { formatDuration, logStatusEntries, historyHasRevisions, NO_REVISION, renderHistoryTable, renderSummary, } from './render.js';
32
32
  import { buildRepoZip, COMMIT_FILE, listRepoFiles } from './repo.js';
33
+ import { assertNotSsrForPreviews, preflightWebFunction, recordManifest, publishRecordedRevision, publishWebRevision, readWebBuildResult, requireSsr, warnAboutSsrSeo, } from './web-functions.js';
33
34
  /** One line per invalidation outcome, shared by the summary card and logs. */
34
35
  function describeInvalidation(inv) {
35
36
  if (inv.mode === 'none')
@@ -48,8 +49,67 @@ function siteBaseUrl(ctx) {
48
49
  const cf = ctx.state.resources['cloudfront-distribution']?.domainName;
49
50
  return typeof cf === 'string' ? `https://${cf}` : undefined;
50
51
  }
52
+ /** The serving mode the config asks for. */
53
+ function configuredMode(ctx) {
54
+ return ctx.config.ssr ? 'ssr' : 'static';
55
+ }
56
+ /**
57
+ * The serving mode the live distribution was left in, or undefined before the
58
+ * first bootstrap. A distribution recorded without a mode was created before
59
+ * SSR existed, so it is static - reading that as "unknown" would let the very
60
+ * stacks the switch guard exists for through it.
61
+ */
62
+ function recordedMode(ctx) {
63
+ const distribution = ctx.state.resources['cloudfront-distribution'];
64
+ if (typeof distribution?.id !== 'string')
65
+ return undefined;
66
+ return distribution.mode === 'ssr' ? 'ssr' : 'static';
67
+ }
68
+ /**
69
+ * Refuse to publish in a mode the distribution is not serving. The config and
70
+ * the stack disagree only between editing `ssr` and re-bootstrapping, and a
71
+ * deploy in that gap publishes where nothing serves from - or, static over
72
+ * SSR, deletes the `site/` assets the live revision still references.
73
+ */
74
+ function assertServingModeMatches(ctx) {
75
+ const recorded = recordedMode(ctx);
76
+ const configured = configuredMode(ctx);
77
+ if (recorded === undefined || recorded === configured)
78
+ return;
79
+ throw new Error(`"${ctx.env}" is configured for ${configured} serving but its distribution serves ` +
80
+ `${recorded}. Run \`blogwright bootstrap ${ctx.env} --switch-mode\` first; nothing was ` +
81
+ 'built or published.');
82
+ }
83
+ /** What a mode switch does to the live site while the next deploy has not run. */
84
+ function servingWindow(mode) {
85
+ return mode === 'ssr'
86
+ ? 'the distribution answers 503 from the placeholder until the first SSR deploy completes'
87
+ : 'the distribution serves whatever site/ holds until the next static deploy';
88
+ }
89
+ /**
90
+ * Refuse a silent serving-mode switch on a bootstrapped stack unless
91
+ * `--switch-mode` says the window is acceptable. A mode difference is a
92
+ * serving cutover, not a quiet convergence - the distribution's default
93
+ * behavior moves before a deploy in the new mode exists.
94
+ */
95
+ export function assertModeSwitchAllowed(ctx, switchMode) {
96
+ const recorded = recordedMode(ctx);
97
+ if (recorded === undefined)
98
+ return;
99
+ const configured = configuredMode(ctx);
100
+ if (recorded === configured)
101
+ return;
102
+ const window = servingWindow(configured);
103
+ if (!switchMode) {
104
+ throw new Error(`refusing to switch "${ctx.env}" from ${recorded} to ${configured} serving: ${window}. ` +
105
+ 'Nothing was applied. Re-run with --switch-mode to move the distribution now.');
106
+ }
107
+ ctx.logger.warn(`switching "${ctx.env}" from ${recorded} to ${configured} serving: ${window}`);
108
+ }
51
109
  /** Create the full infrastructure graph. */
52
- export async function bootstrap(ctx, nodes = buildNodes(ctx)) {
110
+ export async function bootstrap(ctx, nodes = buildNodes(ctx), opts = {}) {
111
+ // Refused before any node is applied, so a refusal has no side effects.
112
+ assertModeSwitchAllowed(ctx, opts.switchMode ?? false);
53
113
  ctx.logger.info(colors.bold(`Bootstrapping "${ctx.env}" (bucket ${ctx.names.bucket})`));
54
114
  // The state bucket must exist before anything else can persist state.
55
115
  await applyGraph(nodes, ctx);
@@ -207,12 +267,43 @@ export async function destroy(ctx, opts) {
207
267
  await ctx.store.delete();
208
268
  ctx.logger.ok(`destroyed "${ctx.env}"`);
209
269
  }
270
+ /** The manifest source a publish step records timings under. */
271
+ function manifestSource(ctx, hash, startedAtMs, microvmId) {
272
+ return {
273
+ hash,
274
+ sourceKey: `build/${hash}.zip`,
275
+ startedAt: new Date(startedAtMs),
276
+ microvmId,
277
+ };
278
+ }
279
+ /**
280
+ * Read the agent's SSR result, recording a `failed` manifest when it is not
281
+ * there. A build that reported success but produced no result file has no
282
+ * revision outcome to wait for, and an SSR manifest's status is the outcome of
283
+ * the whole deploy - so the deploy cannot simply leave the hash unrecorded.
284
+ */
285
+ async function webBuildResultOrFail(ctx, source) {
286
+ try {
287
+ return await readWebBuildResult(ctx, source.hash);
288
+ }
289
+ catch (err) {
290
+ await recordManifest(ctx, source, 'failed', undefined, err.message);
291
+ throw err;
292
+ }
293
+ }
210
294
  /** Zip the repo, upload it, run the builder MicroVM, and invalidate the cache. */
211
295
  export async function deploy(ctx, opts = {}) {
212
296
  const startedAt = Date.now();
213
297
  const cwd = await findRepoRoot(ctx.ports.fs);
214
298
  const hash = await ctx.ports.vcs.revisionHash(cwd);
215
299
  ctx.logger.info(colors.bold(`Deploying ${hash} to "${ctx.env}"`));
300
+ assertServingModeMatches(ctx);
301
+ // In SSR mode the endpoint probe is mandatory and comes first: a caller that
302
+ // cannot invoke the endpoint is stopped before anything is built or published.
303
+ if (ctx.config.ssr) {
304
+ warnAboutSsrSeo(ctx);
305
+ await preflightWebFunction(ctx);
306
+ }
216
307
  const files = await listRepoFiles(ctx.ports, cwd, ctx.config.sourceIgnore, ctx.config.sourceInclude);
217
308
  ctx.logger.step(`zipping ${files.length} files`);
218
309
  const zip = await buildRepoZip(ctx.ports.fs, cwd, files, { [COMMIT_FILE]: hash });
@@ -226,14 +317,30 @@ export async function deploy(ctx, opts = {}) {
226
317
  hash,
227
318
  sourceKey,
228
319
  baseUrl: siteBaseUrl(ctx),
320
+ ...(ctx.config.ssr ? { webFunction: webFunctionJob(ctx, hash) } : {}),
229
321
  ...(opts.refresh ? { refresh: true } : {}),
230
322
  });
231
- const invalidation = await invalidateChanged(ctx, hash);
323
+ let invalidation;
324
+ let outcome;
325
+ if (ctx.config.ssr) {
326
+ const source = manifestSource(ctx, hash, startedAt, manifest.microvmId);
327
+ const result = await webBuildResultOrFail(ctx, source);
328
+ outcome = await publishWebRevision(ctx, source, result);
329
+ invalidation = outcome.invalidation;
330
+ }
331
+ else {
332
+ invalidation = await invalidateChanged(ctx, hash);
333
+ }
232
334
  // Production content changed - mirror it to the PDS (non-fatal; see syncAfterDeploy).
233
335
  await syncAfterDeploy(ctx);
234
336
  const url = siteBaseUrl(ctx);
235
337
  const rows = [
236
- { label: 'revision', value: hash },
338
+ {
339
+ label: 'revision',
340
+ value: outcome
341
+ ? `${outcome.revisionId} (${outcome.created ? 'created' : 'unchanged'})`
342
+ : hash,
343
+ },
237
344
  { label: 'environment', value: ctx.env },
238
345
  { label: 'source', value: `${files.length} files, ${(zip.byteLength / 1024).toFixed(0)} KiB` },
239
346
  { label: 'build', value: formatDuration(manifest.durationMs) },
@@ -245,8 +352,74 @@ export async function deploy(ctx, opts = {}) {
245
352
  }
246
353
  ctx.logger.ok(`deployed ${hash} in ${formatDuration(Date.now() - startedAt)}`);
247
354
  }
355
+ /**
356
+ * SSR rollback: reproduce a recorded revision from its artifact and settings
357
+ * without a rebuild, falling back to the build path when the manifest has no
358
+ * `webFunction` block, the recorded artifact version is gone, or `staticDir`
359
+ * means the artifact does not carry the assets its pages reference.
360
+ */
361
+ async function rollbackSsr(ctx, hash, opts = {}) {
362
+ const startedAt = Date.now();
363
+ warnAboutSsrSeo(ctx);
364
+ const config = requireSsr(ctx);
365
+ const manifest = await readManifest(ctx, hash);
366
+ const recorded = manifest?.webFunction;
367
+ // With staticDir configured the artifact cannot reproduce the rollback at all
368
+ // (it carries only the server), so the version check is not worth a request.
369
+ const artifactGone = recorded && config.staticDir === undefined
370
+ ? !(await ctx.clients.s3.objectVersionExists(ctx.names.bucket, recorded.artifactKey, recorded.artifactVersionId))
371
+ : false;
372
+ const rebuild = !recorded || config.staticDir !== undefined || artifactGone;
373
+ // Both paths probe: the endpoint is the only evidence that a rollback serves.
374
+ await preflightWebFunction(ctx);
375
+ let outcome;
376
+ if (rebuild) {
377
+ if (!recorded)
378
+ ctx.logger.warn(`${hash} has no webFunction manifest block - rebuilding`);
379
+ else if (config.staticDir !== undefined) {
380
+ ctx.logger.warn('ssr.staticDir is configured - rebuilding so its assets are republished');
381
+ }
382
+ else
383
+ ctx.logger.warn(`artifact version ${recorded.artifactVersionId} is gone - rebuilding`);
384
+ const sourceKey = `build/${hash}.zip`;
385
+ if (!(await ctx.clients.s3.objectExists(ctx.names.bucket, sourceKey))) {
386
+ throw new Error(`no build artifact at ${sourceKey}; cannot roll back to ${hash}`);
387
+ }
388
+ await reconcileBuilderImage(ctx);
389
+ const build = await runBuild(ctx, {
390
+ hash,
391
+ sourceKey,
392
+ baseUrl: siteBaseUrl(ctx),
393
+ webFunction: webFunctionJob(ctx, hash),
394
+ ...(opts.refresh ? { refresh: true } : {}),
395
+ });
396
+ const source = manifestSource(ctx, hash, startedAt, build.microvmId);
397
+ const result = await webBuildResultOrFail(ctx, source);
398
+ outcome = await publishWebRevision(ctx, source, result);
399
+ }
400
+ else {
401
+ outcome = await publishRecordedRevision(ctx, {
402
+ hash,
403
+ sourceKey: `build/${hash}.zip`,
404
+ startedAt: new Date(startedAt),
405
+ microvmId: 'rollback',
406
+ }, recorded);
407
+ }
408
+ ctx.logger.info(` revision ${outcome.revisionId} (${outcome.created ? 'created' : 'unchanged'}), ` +
409
+ `${describeInvalidation(outcome.invalidation)}`);
410
+ // A rollback changes production content too, but the PDS mirrors the *working tree*
411
+ // content, which a rollback does not restore - so only warn about the divergence.
412
+ if (ctx.env === 'production' && ctx.config.pds) {
413
+ ctx.logger.warn('rollback does not sync the PDS (records mirror the current repo content); ' +
414
+ 'check out the rolled-back revision and run `blogwright pds sync` if needed');
415
+ }
416
+ ctx.logger.ok(`rolled back to ${hash} in ${formatDuration(Date.now() - startedAt)}`);
417
+ }
248
418
  /** Re-run the builder against an existing source zip for the given hash. */
249
419
  export async function rollback(ctx, hash, opts = {}) {
420
+ assertServingModeMatches(ctx);
421
+ if (ctx.config.ssr)
422
+ return rollbackSsr(ctx, hash, opts);
250
423
  const sourceKey = `build/${hash}.zip`;
251
424
  if (!(await ctx.clients.s3.objectExists(ctx.names.bucket, sourceKey))) {
252
425
  throw new Error(`no build artifact at ${sourceKey}; cannot roll back to ${hash}`);
@@ -275,6 +448,7 @@ function assertPreviewId(id) {
275
448
  }
276
449
  /** Provision the shared preview stack (one CloudFront distribution + host router + OIDC role). */
277
450
  export async function previewBootstrap(ctx) {
451
+ assertNotSsrForPreviews(ctx);
278
452
  if (!ctx.domain)
279
453
  throw new Error('preview bootstrap requires a domain (e.g. preview.example.com)');
280
454
  ctx.logger.info(colors.bold(`Bootstrapping preview stack (bucket ${ctx.names.bucket})`));
@@ -283,6 +457,7 @@ export async function previewBootstrap(ctx) {
283
457
  }
284
458
  /** Build the current repo and publish it to this PR's preview prefix. Prints the URL. */
285
459
  export async function previewDeploy(ctx, id, opts = {}) {
460
+ assertNotSsrForPreviews(ctx);
286
461
  assertPreviewId(id);
287
462
  const startedAt = Date.now();
288
463
  const cwd = await findRepoRoot(ctx.ports.fs);
@@ -346,6 +521,15 @@ export async function previewTeardown(ctx, opts) {
346
521
  }
347
522
  /** Empty the live site/ prefix (leaves infra and build history intact). */
348
523
  export async function deleteSite(ctx) {
524
+ if (ctx.config.ssr) {
525
+ // Emptying site/ cannot unpublish a revision: the app renders every page
526
+ // the revision serves, and its routes would keep answering from a server
527
+ // whose static assets are gone.
528
+ throw new Error(`refusing to delete site/ for "${ctx.env}": the site is served by a Lambda Web Function ` +
529
+ 'revision, and emptying site/ cannot unpublish it. Deploy a new revision (or roll back), ' +
530
+ `or switch the stack back to static serving with \`blogwright bootstrap ${ctx.env} ` +
531
+ '--switch-mode` and deploy before deleting.');
532
+ }
349
533
  const count = await ctx.clients.s3.deletePrefix(ctx.names.bucket, 'site/');
350
534
  ctx.logger.ok(`deleted ${count} object(s) under site/`);
351
535
  await invalidateCloudFront(ctx);
@@ -378,13 +562,19 @@ export async function history(ctx) {
378
562
  ctx.logger.info(line);
379
563
  return;
380
564
  }
381
- // The plain form is the stable contract for CI logs and agents.
382
- ctx.logger.info(colors.bold('hash status finished duration'));
565
+ // The plain form is the stable contract for CI logs and agents. The revision
566
+ // column is appended only once a hash recorded one, so static output is unchanged.
567
+ const revisions = historyHasRevisions(manifests);
568
+ ctx.logger.info(colors.bold(`hash status finished duration${revisions ? ' revision' : ''}`));
383
569
  for (const m of manifests) {
384
570
  const cell = m.status === 'succeeded'
385
571
  ? colors.green(m.status.padEnd(10))
386
572
  : colors.red(m.status.padEnd(10));
387
- ctx.logger.info(`${m.hash.padEnd(13)} ${cell} ${m.finishedAt} ${(m.durationMs / 1000).toFixed(0)}s`);
573
+ const duration = `${(m.durationMs / 1000).toFixed(0)}s`;
574
+ const revision = revisions
575
+ ? ` ${duration.padEnd(8)} ${m.webFunction?.revisionId ?? NO_REVISION}`
576
+ : ` ${duration}`;
577
+ ctx.logger.info(`${m.hash.padEnd(13)} ${cell} ${m.finishedAt}${revision}`);
388
578
  }
389
579
  }
390
580
  /** Show CloudWatch build logs for a given hash. */
package/dist/deploy.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { type Microvm, type RunMicrovmInput } from 'blogwright-core';
1
+ import { type EntryShim, type Microvm, type RunMicrovmInput } from 'blogwright-core';
2
2
  import type { OpsContext } from './context.js';
3
+ import type { WebFunctionManifest } from './web-functions.js';
3
4
  export interface DeployManifest {
4
5
  hash: string;
5
6
  sourceKey: string;
@@ -9,7 +10,46 @@ export interface DeployManifest {
9
10
  durationMs: number;
10
11
  microvmId: string;
11
12
  message?: string;
13
+ /**
14
+ * The last revision verified for this hash (or found already serving).
15
+ * Rollback reads it to reproduce the revision a hash is pinned to. A
16
+ * `failed` manifest carries it only when an earlier outcome for the same
17
+ * hash was verified - see {@link writeManifest}.
18
+ */
19
+ webFunction?: WebFunctionManifest;
12
20
  }
21
+ /** Read one hash's deployment manifest, or undefined when there is none. */
22
+ export declare function readManifest(ctx: OpsContext, hash: string): Promise<DeployManifest | undefined>;
23
+ /**
24
+ * Write a hash's manifest and return what was written. In SSR mode a `failed`
25
+ * outcome keeps the `webFunction` block the hash's manifest already carried:
26
+ * that block is the only record of the artifact version and settings a
27
+ * verified revision was created with, and a failed redeploy or rollback of the
28
+ * same hash must not erase what `rollback` reproduces it from.
29
+ */
30
+ export declare function writeManifest(ctx: OpsContext, manifest: DeployManifest, opts?: {
31
+ ssr?: boolean | undefined;
32
+ }): Promise<DeployManifest>;
33
+ /** Artifact and result keys for one deploy hash. */
34
+ export declare function artifactKeys(hash: string): {
35
+ artifactKey: string;
36
+ resultKey: string;
37
+ };
38
+ /**
39
+ * The pending-job half of an SSR build: what to package, what to call the
40
+ * artifact, and where the agent writes the result the CLI's publish step reads.
41
+ */
42
+ export interface WebFunctionJob {
43
+ packageDir: string;
44
+ entryPoint: string;
45
+ /** The generated entry shim the agent adds to the package, when `ssr.shim` is set. */
46
+ shim?: EntryShim | undefined;
47
+ staticDir?: string | undefined;
48
+ artifactKey: string;
49
+ resultKey: string;
50
+ }
51
+ /** The SSR packaging job for a hash, from the environment's `ssr` block. */
52
+ export declare function webFunctionJob(ctx: OpsContext, hash: string): WebFunctionJob;
13
53
  /** Terminal outcome the log poller extracts from the agent's build markers. */
14
54
  export interface AgentStatus {
15
55
  state: 'idle' | 'building' | 'done' | 'failed';
@@ -55,6 +95,12 @@ export declare function runBuild(ctx: OpsContext, opts: {
55
95
  objectTags?: Record<string, string> | undefined;
56
96
  /** Re-upload every file, so metadata-only fixes (content type, tags) land. */
57
97
  refresh?: boolean | undefined;
98
+ /**
99
+ * Package the output as a Lambda Web Function revision artifact. When set,
100
+ * the manifest is left to the publish step: the build outcome alone is not
101
+ * the deploy outcome.
102
+ */
103
+ webFunction?: WebFunctionJob | undefined;
58
104
  }): Promise<DeployManifest>;
59
105
  export declare function invalidateCloudFront(ctx: OpsContext, paths?: string[]): Promise<void>;
60
106
  /** What a deploy's cache invalidation actually did, for the deploy summary. */
package/dist/deploy.js CHANGED
@@ -1,7 +1,56 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { AwsError, networkConnectors, pollUntil, sleep, } from 'blogwright-core';
2
+ import { AwsError, entryShim, networkConnectors, pollUntil, sleep, } from 'blogwright-core';
3
3
  import { formatDuration, spinnerFrame } from './render.js';
4
4
  import { resolveSeo } from './seo.js';
5
+ /** Read one hash's deployment manifest, or undefined when there is none. */
6
+ export async function readManifest(ctx, hash) {
7
+ const text = await ctx.clients.s3.getObjectText(ctx.names.bucket, manifestKey(hash));
8
+ if (!text)
9
+ return undefined;
10
+ try {
11
+ return JSON.parse(text);
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ /**
18
+ * Write a hash's manifest and return what was written. In SSR mode a `failed`
19
+ * outcome keeps the `webFunction` block the hash's manifest already carried:
20
+ * that block is the only record of the artifact version and settings a
21
+ * verified revision was created with, and a failed redeploy or rollback of the
22
+ * same hash must not erase what `rollback` reproduces it from.
23
+ */
24
+ export async function writeManifest(ctx, manifest, opts = {}) {
25
+ let record = manifest;
26
+ if (opts.ssr && manifest.status === 'failed' && !manifest.webFunction) {
27
+ const verified = (await readManifest(ctx, manifest.hash))?.webFunction;
28
+ if (verified)
29
+ record = { ...manifest, webFunction: verified };
30
+ }
31
+ await ctx.clients.s3.putObject(ctx.names.bucket, manifestKey(manifest.hash), JSON.stringify(record, null, 2), 'application/json');
32
+ return record;
33
+ }
34
+ /** Artifact and result keys for one deploy hash. */
35
+ export function artifactKeys(hash) {
36
+ return { artifactKey: `web/artifacts/${hash}.zip`, resultKey: `web/results/${hash}.json` };
37
+ }
38
+ /** The SSR packaging job for a hash, from the environment's `ssr` block. */
39
+ export function webFunctionJob(ctx, hash) {
40
+ const ssr = ctx.config.ssr;
41
+ if (!ssr)
42
+ throw new Error(`environment "${ctx.env}" has no config.ssr block`);
43
+ const { artifactKey, resultKey } = artifactKeys(hash);
44
+ const shim = entryShim(ssr);
45
+ return {
46
+ packageDir: ssr.packageDir,
47
+ entryPoint: ssr.entryPoint,
48
+ ...(shim !== undefined ? { shim } : {}),
49
+ ...(ssr.staticDir !== undefined ? { staticDir: ssr.staticDir } : {}),
50
+ artifactKey,
51
+ resultKey,
52
+ };
53
+ }
5
54
  function manifestKey(hash) {
6
55
  return `build/manifests/${hash}.json`;
7
56
  }
@@ -130,7 +179,9 @@ export async function pollBuild(ctx, hash, startTime, endpoint, token) {
130
179
  */
131
180
  export async function runBuild(ctx, opts) {
132
181
  const sitePrefix = opts.sitePrefix ?? 'site/';
133
- const seo = resolveSeo(ctx, opts.baseUrl);
182
+ // robots.txt/sitemap.xml are the application's routes in SSR mode, so no SEO
183
+ // payload is sent (and the agent writes none).
184
+ const seo = opts.webFunction ? {} : resolveSeo(ctx, opts.baseUrl);
134
185
  const pendingKey = `build/pending/${opts.target ?? 'site'}.json`;
135
186
  const imageArn = ctx.state.resources['microvm-image']?.arn;
136
187
  const execRoleArn = ctx.state.resources['iam-exec-role']?.arn;
@@ -175,6 +226,7 @@ export async function runBuild(ctx, opts) {
175
226
  distDir: ctx.config.paths.dist,
176
227
  objectTags: opts.objectTags ?? ctx.tags,
177
228
  ...(opts.refresh ? { refresh: true } : {}),
229
+ ...(opts.webFunction ? { webFunction: opts.webFunction } : {}),
178
230
  ...seo,
179
231
  }), 'application/json');
180
232
  // Confirm the MicroVM actually reached RUNNING (a FAILED launch must not fall through
@@ -216,7 +268,12 @@ export async function runBuild(ctx, opts) {
216
268
  microvmId: run.microvmId,
217
269
  ...(result.message ? { message: result.message } : {}),
218
270
  };
219
- await ctx.clients.s3.putObject(ctx.names.bucket, manifestKey(opts.hash), JSON.stringify(manifest, null, 2), 'application/json');
271
+ // In SSR mode a successful build is NOT yet a successful deploy: the publish
272
+ // step writes the manifest once the revision outcome is known. A failed build
273
+ // has no revision to wait for, so it is recorded here.
274
+ if (manifest.status === 'failed' || !opts.webFunction) {
275
+ await writeManifest(ctx, manifest, { ssr: Boolean(opts.webFunction) });
276
+ }
220
277
  if (manifest.status === 'failed') {
221
278
  throw new Error(`build failed for ${opts.hash}${manifest.message ? `: ${manifest.message}` : ''}`);
222
279
  }