blogwright 0.4.0-beta.3 → 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): 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
@@ -22,8 +31,8 @@ export declare function bootstrap(ctx: OpsContext): Promise<void>;
22
31
  *
23
32
  * Reads the bucket, not the plugin registry, so the refusal holds even for
24
33
  * a plugin that has since been uninstalled. Runs inside the teardown verbs
25
- * themselves, not `createContext` - so no other command pays for the extra
26
- * `listObjects` call and plugin discovery stays lazy - and ahead of
34
+ * themselves, not `createContext`, sharing the listing with the post-bootstrap
35
+ * warning while plugin discovery stays lazy - and ahead of
27
36
  * `clearRunningMicrovms`/`destroyGraph` in each, so a refusal has zero side
28
37
  * effects: nothing is terminated, nothing is deleted.
29
38
  *
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,15 +49,75 @@ 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) {
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
- await applyGraph(buildNodes(ctx), ctx);
115
+ await applyGraph(nodes, ctx);
56
116
  ctx.logger.ok(`bootstrap complete for "${ctx.env}"`);
57
117
  const domain = ctx.state.resources['cloudfront-distribution']?.domainName;
58
118
  if (typeof domain === 'string')
59
119
  ctx.logger.info(`Site will be served at https://${domain}`);
120
+ await warnAboutScopedState(ctx);
60
121
  }
61
122
  /**
62
123
  * The prefix every state object - scoped or not - is filed under
@@ -85,6 +146,27 @@ function scopedStateScopes(env, objects) {
85
146
  .map((key) => key.slice(prefix.length, -'.json'.length));
86
147
  return [...new Set(scopes)].sort();
87
148
  }
149
+ /** Listing is strict; each caller decides whether a failure can be recovered. */
150
+ async function listScopedStateScopes(ctx) {
151
+ const objects = await ctx.clients.s3.listObjects(ctx.names.bucket, STATE_PREFIX);
152
+ return scopedStateScopes(ctx.env, objects);
153
+ }
154
+ /** A never-bootstrapped plugin has no scoped key, so cannot produce a warning. */
155
+ async function warnAboutScopedState(ctx) {
156
+ let scopes;
157
+ try {
158
+ scopes = await listScopedStateScopes(ctx);
159
+ }
160
+ catch (err) {
161
+ ctx.logger.warn(`could not check plugin state for "${ctx.env}" at s3://${ctx.names.bucket}/${STATE_PREFIX} ` +
162
+ `(bootstrap succeeded): ${String(err)}`);
163
+ return;
164
+ }
165
+ for (const scope of scopes) {
166
+ ctx.logger.warn(`plugin state for "${scope}" still exists in "${ctx.env}"; ` +
167
+ `run \`blogwright ${scope} bootstrap ${ctx.env}\` to reconcile its resources`);
168
+ }
169
+ }
88
170
  /**
89
171
  * Refuse to destroy the site while any plugin's own state object still
90
172
  * exists in the bucket - §State → Scoped state stores' "`blogwright
@@ -103,8 +185,8 @@ function scopedStateScopes(env, objects) {
103
185
  *
104
186
  * Reads the bucket, not the plugin registry, so the refusal holds even for
105
187
  * a plugin that has since been uninstalled. Runs inside the teardown verbs
106
- * themselves, not `createContext` - so no other command pays for the extra
107
- * `listObjects` call and plugin discovery stays lazy - and ahead of
188
+ * themselves, not `createContext`, sharing the listing with the post-bootstrap
189
+ * warning while plugin discovery stays lazy - and ahead of
108
190
  * `clearRunningMicrovms`/`destroyGraph` in each, so a refusal has zero side
109
191
  * effects: nothing is terminated, nothing is deleted.
110
192
  *
@@ -134,9 +216,9 @@ function scopedStateScopes(env, objects) {
134
216
  * `destroy()` run over the entire production graph.
135
217
  */
136
218
  export async function assertNoScopedState(ctx) {
137
- let objects;
219
+ let scopes;
138
220
  try {
139
- objects = await ctx.clients.s3.listObjects(ctx.names.bucket, STATE_PREFIX);
221
+ scopes = await listScopedStateScopes(ctx);
140
222
  }
141
223
  catch (err) {
142
224
  // No bucket, no state objects - see this function's doc comment. Matched
@@ -152,7 +234,6 @@ export async function assertNoScopedState(ctx) {
152
234
  return;
153
235
  throw err;
154
236
  }
155
- const scopes = scopedStateScopes(ctx.env, objects);
156
237
  if (scopes.length === 0)
157
238
  return;
158
239
  // The remedy MUST name the environment. `runPlugin` resolves a plugin
@@ -186,12 +267,43 @@ export async function destroy(ctx, opts) {
186
267
  await ctx.store.delete();
187
268
  ctx.logger.ok(`destroyed "${ctx.env}"`);
188
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
+ }
189
294
  /** Zip the repo, upload it, run the builder MicroVM, and invalidate the cache. */
190
295
  export async function deploy(ctx, opts = {}) {
191
296
  const startedAt = Date.now();
192
297
  const cwd = await findRepoRoot(ctx.ports.fs);
193
298
  const hash = await ctx.ports.vcs.revisionHash(cwd);
194
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
+ }
195
307
  const files = await listRepoFiles(ctx.ports, cwd, ctx.config.sourceIgnore, ctx.config.sourceInclude);
196
308
  ctx.logger.step(`zipping ${files.length} files`);
197
309
  const zip = await buildRepoZip(ctx.ports.fs, cwd, files, { [COMMIT_FILE]: hash });
@@ -205,14 +317,30 @@ export async function deploy(ctx, opts = {}) {
205
317
  hash,
206
318
  sourceKey,
207
319
  baseUrl: siteBaseUrl(ctx),
320
+ ...(ctx.config.ssr ? { webFunction: webFunctionJob(ctx, hash) } : {}),
208
321
  ...(opts.refresh ? { refresh: true } : {}),
209
322
  });
210
- 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
+ }
211
334
  // Production content changed - mirror it to the PDS (non-fatal; see syncAfterDeploy).
212
335
  await syncAfterDeploy(ctx);
213
336
  const url = siteBaseUrl(ctx);
214
337
  const rows = [
215
- { label: 'revision', value: hash },
338
+ {
339
+ label: 'revision',
340
+ value: outcome
341
+ ? `${outcome.revisionId} (${outcome.created ? 'created' : 'unchanged'})`
342
+ : hash,
343
+ },
216
344
  { label: 'environment', value: ctx.env },
217
345
  { label: 'source', value: `${files.length} files, ${(zip.byteLength / 1024).toFixed(0)} KiB` },
218
346
  { label: 'build', value: formatDuration(manifest.durationMs) },
@@ -224,8 +352,74 @@ export async function deploy(ctx, opts = {}) {
224
352
  }
225
353
  ctx.logger.ok(`deployed ${hash} in ${formatDuration(Date.now() - startedAt)}`);
226
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
+ }
227
418
  /** Re-run the builder against an existing source zip for the given hash. */
228
419
  export async function rollback(ctx, hash, opts = {}) {
420
+ assertServingModeMatches(ctx);
421
+ if (ctx.config.ssr)
422
+ return rollbackSsr(ctx, hash, opts);
229
423
  const sourceKey = `build/${hash}.zip`;
230
424
  if (!(await ctx.clients.s3.objectExists(ctx.names.bucket, sourceKey))) {
231
425
  throw new Error(`no build artifact at ${sourceKey}; cannot roll back to ${hash}`);
@@ -254,6 +448,7 @@ function assertPreviewId(id) {
254
448
  }
255
449
  /** Provision the shared preview stack (one CloudFront distribution + host router + OIDC role). */
256
450
  export async function previewBootstrap(ctx) {
451
+ assertNotSsrForPreviews(ctx);
257
452
  if (!ctx.domain)
258
453
  throw new Error('preview bootstrap requires a domain (e.g. preview.example.com)');
259
454
  ctx.logger.info(colors.bold(`Bootstrapping preview stack (bucket ${ctx.names.bucket})`));
@@ -262,6 +457,7 @@ export async function previewBootstrap(ctx) {
262
457
  }
263
458
  /** Build the current repo and publish it to this PR's preview prefix. Prints the URL. */
264
459
  export async function previewDeploy(ctx, id, opts = {}) {
460
+ assertNotSsrForPreviews(ctx);
265
461
  assertPreviewId(id);
266
462
  const startedAt = Date.now();
267
463
  const cwd = await findRepoRoot(ctx.ports.fs);
@@ -325,6 +521,15 @@ export async function previewTeardown(ctx, opts) {
325
521
  }
326
522
  /** Empty the live site/ prefix (leaves infra and build history intact). */
327
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
+ }
328
533
  const count = await ctx.clients.s3.deletePrefix(ctx.names.bucket, 'site/');
329
534
  ctx.logger.ok(`deleted ${count} object(s) under site/`);
330
535
  await invalidateCloudFront(ctx);
@@ -357,13 +562,19 @@ export async function history(ctx) {
357
562
  ctx.logger.info(line);
358
563
  return;
359
564
  }
360
- // The plain form is the stable contract for CI logs and agents.
361
- 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' : ''}`));
362
569
  for (const m of manifests) {
363
570
  const cell = m.status === 'succeeded'
364
571
  ? colors.green(m.status.padEnd(10))
365
572
  : colors.red(m.status.padEnd(10));
366
- 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}`);
367
578
  }
368
579
  }
369
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
  }