@gradial/aci 0.1.53-canary.700 → 0.1.53-canary.702

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/CHANGELOG.md CHANGED
@@ -16,6 +16,11 @@
16
16
 
17
17
  ### Migration required
18
18
 
19
+ - Error-artifact `prepare` no longer compiles authored content. Sites relying
20
+ on that side effect must run `content:compile` during local development or
21
+ explicit content validation. Production Next builds require contracts and
22
+ code only; authored error-page content is resolved during ACI preview/publish.
23
+
19
24
  - **Block root identity: render the root on every render** (ADR 0051) — each
20
25
  registered component must put `aciRootAttrs(props)` (or its rest props) on
21
26
  its own root element, and must render that root even when its content is
@@ -66,6 +71,12 @@
66
71
 
67
72
  ### Changed
68
73
 
74
+ - Next production builds and error-artifact preparation are content-independent
75
+ without `ACI_CODE_ARTIFACT_BUILD`. Local Next development still validates and
76
+ previews authored error seats; production authored error pages belong to
77
+ preview/publish. Sites should compile contracts in `prebuild`, validate content
78
+ explicitly, and remove build-phase switches from their scripts.
79
+
69
80
  - **`rootIdentity` reads every registry file again.** The `registry` option
70
81
  introduced in 0.1.52-rc.33 is still accepted, but it is no longer required:
71
82
  the check covers every file that calls `createRegistry(` (a main registry
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Agentic Content Infrastructure SDK
2
2
 
3
- `@gradial/aci` `0.1.53-canary.700` is a pre-1.0 public-surface reset for the ACI
3
+ `@gradial/aci` `0.1.53-canary.702` is a pre-1.0 public-surface reset for the ACI
4
4
  client SDK. Breaking changes from the old `0.1.x` accidental surface are
5
5
  intentional: the root import is framework-neutral, framework runtime behavior is
6
6
  behind explicit subpaths, and the first required customer path is React/Next.
@@ -8,7 +8,7 @@ behind explicit subpaths, and the first required customer path is React/Next.
8
8
  ## Install
9
9
 
10
10
  ```bash
11
- npm install @gradial/aci@0.1.53-canary.700 zod@^4
11
+ npm install @gradial/aci@0.1.53-canary.702 zod@^4
12
12
  ```
13
13
 
14
14
  ## Root Import
@@ -137,13 +137,13 @@ host bundles select and serve immutable release artifacts.
137
137
  The first RC scaffold path is Next:
138
138
 
139
139
  ```bash
140
- npx @gradial/aci@0.1.53-canary.700 init my-site --template next-starter
140
+ npx @gradial/aci@0.1.53-canary.702 init my-site --template next-starter
141
141
  ```
142
142
 
143
143
  List available templates:
144
144
 
145
145
  ```bash
146
- npx @gradial/aci@0.1.53-canary.700 init --list
146
+ npx @gradial/aci@0.1.53-canary.702 init --list
147
147
  ```
148
148
 
149
149
  ## Personalization: The One-Tag Install
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- export function prepareCodeArtifact(root: string | undefined, content: any): Promise<string>;
2
+ export function prepare(root: string | undefined, content: any): Promise<string>;
3
3
  /**
4
4
  * D-15 policy resolution for the 500.html instrumentation tag (page_type
5
5
  * "error"; self-contained inline delivery — this copy is a real static
@@ -38,3 +38,4 @@ export function writeInstrumentationArtifact(root: string | undefined, content:
38
38
  * loudly instead.
39
39
  */
40
40
  export function stampInstrumentation(html: any, instrumentationTag: any): any;
41
+ export function emit(root: string | undefined, content: any): Promise<void>;
@@ -1,80 +1,19 @@
1
1
  #!/usr/bin/env node
2
- // Authorable error pages — build integration (spec:
3
- // specs/features/authorable-error-pages, T3/T11; REQ-10/REQ-11/REQ-14, D-9,
4
- // D-16).
5
- //
6
- // ACI_CODE_ARTIFACT_BUILD=1 makes both phases content-independent: prepare
7
- // serializes only the code-owned policy at its existing .aci/compiled path.
8
- // Emit uses the shipped default without resolving authored error-page bindings.
9
- //
10
- // Two phases. The Next template wires both around `next build`; the Astro
11
- // template runs only `prepare` before `astro build` (its 404/500 seats
12
- // render through the framework, but the D-16 instrumentation artifact must
13
- // still reach the compiled content root for the AWS lane):
14
- //
15
- // prepare (before `next build`)
16
- // Ensures compiled content exists at .aci/compiled so withAci() can
17
- // serialize the bound catchAll page for the client-only error seat
18
- // (D-9). Compiles ./.content via the platform CLI when needed; when
19
- // neither compiled content nor authored content exists, it warns and
20
- // leaves the shipped default in play (REQ-4, warn posture). Then writes
21
- // error-pages/instrumentation.json (both kinds' serialized tags) into
22
- // the compiled content root so the tag crosses the JS→Go boundary as
23
- // data: builders retain the instrumentation file at its existing capsule
24
- // path, and the Go publish materializer stamps the kind-matching tag into the CDN
25
- // error artifacts (D-16). An unserializable tenant push fails the build
26
- // loudly here (delta FIND-006).
27
- //
28
- // emit (after `next build`)
29
- // Materializes `public/500.html` from the page bound to
30
- // site.json#errorPages.catchAll by booting the standalone build,
31
- // fetching the bound route, and post-processing the document. Boot-and-
32
- // fetch beats re-rendering the page here because the standalone server
33
- // is the only renderer that produces the document exactly as served
34
- // (layout shell, resolved fragments, RSC-injected head and styles).
35
- // Post-processing:
36
- // - all script tags and preload/prefetch links stripped — this
37
- // removes the collect and assignment tags (REQ-11) along with the
38
- // framework runtime, which is useless in a statically served copy;
39
- // - same-origin stylesheets inlined from .next/static (REQ-14 spirit:
40
- // inline styles; note @font-face/image URLs inside the CSS keep
41
- // their same-origin paths — this copy is served by the Vercel
42
- // platform on the deployment that owns those assets);
43
- // - one self-contained inline script appended pushing the D-14
44
- // `page_view` (page_type "error") — a static document executes
45
- // inline scripts, unlike the flight-rendered not-found seat. The
46
- // push body is site policy (D-15): the tenant-owned
47
- // src/aci/error-page-instrumentation.mjs when present, the SDK
48
- // default otherwise;
49
- // - hard size budget: ≤150 KB raw (REQ-14) — exceeding it fails the
50
- // build; trimming the design system or falling back to system
51
- // fonts is the sanctioned escape hatch.
52
- // Skips when the site already provides its own public/500.html (a file
53
- // without the generation marker); regenerates its own prior output.
54
- // Absent bindings emit the shipped branded-neutral default (REQ-4).
55
- // Vercel picks static per-status files out of the build output
56
- // ("file presence = configuration"); on the current Pro plan the file
57
- // serves app-level 500s only and lights up for platform 5xx on a plan
58
- // upgrade (REQ-10, P-1).
59
- //
60
- // Usage: node ./node_modules/@gradial/aci/src/cli/emit-error-pages.mjs <prepare|emit>
61
- import { execFileSync, spawn } from 'node:child_process';
2
+ // Code-build error artifacts. prepare transports code-owned analytics policy;
3
+ // emit writes a content-independent static 500. Authored error-page bindings
4
+ // and rendering belong to content preview/publish, never the framework build.
5
+ // Usage: emit-error-pages.mjs <prepare|emit>
62
6
  import fs from 'node:fs';
63
- import net from 'node:net';
64
7
  import path from 'node:path';
65
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
66
9
  const MARKER = '<!-- aci:error-pages generated -->';
67
10
  const SIZE_BUDGET = 150 * 1024;
68
- const BOOT_DEADLINE_MS = 60_000;
69
11
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
70
12
  const siteRoot = process.cwd();
71
13
  const contentRoot = path.resolve(siteRoot, process.env.ACI_CONTENT_ROOT || '.aci/compiled');
72
14
  function log(message) {
73
15
  console.log(`[aci error-pages] ${message}`);
74
16
  }
75
- function warn(message) {
76
- console.warn(`\n[aci error-pages] WARNING: ${message}\n`);
77
- }
78
17
  function fail(message) {
79
18
  console.error(`\n[aci error-pages] ERROR: ${message}\n`);
80
19
  process.exit(1);
@@ -82,175 +21,11 @@ function fail(message) {
82
21
  async function sdkContent() {
83
22
  return await import(pathToFileURL(path.join(packageRoot, 'dist', 'content', 'index.js')).href);
84
23
  }
85
- function hasCompiledContent() {
86
- return fs.existsSync(path.join(contentRoot, 'manifest.json'));
87
- }
88
- // ---------------------------------------------------------------------------
89
- // prepare
90
- // ---------------------------------------------------------------------------
91
- // Retain the deployed transport path for SDK-first upgrades. This file is
92
- // executable policy, not a compiled page snapshot; do not compile content here.
93
- export async function prepareCodeArtifact(root = siteRoot, content) {
24
+ // Keep the existing capsule transport path for SDK-first upgrades. This file
25
+ // is executable policy, not a compiled content snapshot.
26
+ export async function prepare(root = siteRoot, content) {
94
27
  return writeInstrumentationArtifact(root, content, path.join(root, '.aci', 'compiled'));
95
28
  }
96
- async function prepare() {
97
- if (process.env.ACI_CODE_ARTIFACT_BUILD === '1') {
98
- // Code artifacts carry executable policy only. The selected content and
99
- // its error-page bindings belong to preview/publish, never this build.
100
- await prepareCodeArtifact();
101
- return;
102
- }
103
- if (hasCompiledContent()) {
104
- log(`compiled content present at ${contentRoot}`);
105
- }
106
- else {
107
- const authored = path.join(siteRoot, '.content');
108
- if (!fs.existsSync(authored)) {
109
- warn(`neither compiled content (${contentRoot}) nor authored content (./.content) exists; `
110
- + 'the error seat and 500.html will serve the shipped default page (REQ-4)');
111
- }
112
- else {
113
- log('compiling ./.content for the error-page build steps');
114
- execFileSync('node', [path.join(packageRoot, 'bin', 'aci.js'), 'content', 'compile', '--content', authored, '--out', contentRoot], { cwd: siteRoot, stdio: 'inherit' });
115
- }
116
- }
117
- // D-16: the instrumentation artifact rides the compiled content root into
118
- // the AWS-lane code capsule (source/.aci/compiled/**). Written even when
119
- // content is absent — the tenant policy module is independent of authored
120
- // content, and an artifact the Go materializer never receives silently
121
- // degrades to the default tags (the exact gap D-16 closes).
122
- try {
123
- await writeInstrumentationArtifact();
124
- }
125
- catch (error) {
126
- fail(error.message);
127
- }
128
- }
129
- // ---------------------------------------------------------------------------
130
- // emit
131
- // ---------------------------------------------------------------------------
132
- async function resolveCatchAllRoute() {
133
- const { resolveErrorPageBindings, defaultErrorPageHtml, errorPagePostureFromEnv } = await sdkContent();
134
- // D-5 posture: the one shared config surface (ACI_ERROR_PAGES_POSTURE);
135
- // unknown values fail closed here just like the Go stack.
136
- let posture;
137
- try {
138
- posture = errorPagePostureFromEnv();
139
- }
140
- catch (error) {
141
- fail(error.message);
142
- }
143
- if (!hasCompiledContent()) {
144
- if (posture === 'enforce') {
145
- fail(`no compiled content at ${contentRoot}; the enforce posture fails closed (D-5)`);
146
- }
147
- warn(`no compiled content at ${contentRoot}; emitting the shipped default 500.html (REQ-4)`);
148
- return { route: null, defaultHtml: defaultErrorPageHtml() };
149
- }
150
- const { FileContentProvider } = await import(pathToFileURL(path.join(packageRoot, 'dist', 'providers', 'file.js')).href);
151
- const provider = new FileContentProvider(contentRoot);
152
- const siteConfig = await provider.getSiteConfig();
153
- const resolution = await resolveErrorPageBindings(siteConfig, { source: provider, posture });
154
- if (resolution.errors.length > 0) {
155
- fail(`errorPages bindings are invalid (REQ-5, fail closed):\n- ${resolution.errors.join('\n- ')}`);
156
- }
157
- for (const warning of resolution.warnings)
158
- warn(warning);
159
- if (resolution.defaulted || !resolution.bindings) {
160
- return { route: null, defaultHtml: defaultErrorPageHtml() };
161
- }
162
- return { route: resolution.bindings.catchAll, defaultHtml: defaultErrorPageHtml() };
163
- }
164
- async function freePort() {
165
- return new Promise((resolve, reject) => {
166
- const server = net.createServer();
167
- server.unref();
168
- server.on('error', reject);
169
- server.listen(0, '127.0.0.1', () => {
170
- const { port } = server.address();
171
- server.close(() => resolve(port));
172
- });
173
- });
174
- }
175
- async function fetchRenderedRoute(route) {
176
- const serverPath = path.join(siteRoot, '.next', 'standalone', 'server.js');
177
- if (!fs.existsSync(serverPath)) {
178
- fail(`${serverPath} missing — the emit phase must run after \`next build\` (withAci uses standalone output)`);
179
- }
180
- const port = await freePort();
181
- const logChunks = [];
182
- const child = spawn('node', [serverPath], {
183
- cwd: siteRoot,
184
- detached: true,
185
- stdio: ['ignore', 'pipe', 'pipe'],
186
- env: {
187
- ...process.env,
188
- NODE_ENV: 'production',
189
- PORT: String(port),
190
- HOSTNAME: '127.0.0.1',
191
- ACI_CONTENT_PROVIDER: 'file',
192
- ACI_CONTENT_ROOT: contentRoot,
193
- },
194
- });
195
- child.stdout.on('data', (c) => logChunks.push(String(c)));
196
- child.stderr.on('data', (c) => logChunks.push(String(c)));
197
- const exited = new Promise((resolve) => {
198
- child.on('exit', resolve);
199
- child.on('error', (error) => {
200
- logChunks.push(`spawn failed: ${error.message}\n`);
201
- resolve(null);
202
- });
203
- });
204
- try {
205
- const url = `http://127.0.0.1:${port}${route}`;
206
- const start = Date.now();
207
- let last = 'no attempt made';
208
- while (Date.now() - start < BOOT_DEADLINE_MS) {
209
- try {
210
- const response = await fetch(url, { redirect: 'follow' });
211
- if (response.status === 200)
212
- return await response.text();
213
- last = `status ${response.status}`;
214
- }
215
- catch (error) {
216
- last = error.message;
217
- }
218
- await new Promise((r) => setTimeout(r, 500));
219
- }
220
- fail(`GET ${url} did not render within ${BOOT_DEADLINE_MS}ms (last: ${last})\n--- server output ---\n${logChunks.join('').slice(-4000)}`);
221
- return '';
222
- }
223
- finally {
224
- const killGroup = (signal) => {
225
- if (!child.pid)
226
- return;
227
- try {
228
- process.kill(-child.pid, signal);
229
- }
230
- catch { /* gone */ }
231
- };
232
- killGroup('SIGTERM');
233
- const killTimer = setTimeout(() => killGroup('SIGKILL'), 10_000);
234
- await exited;
235
- clearTimeout(killTimer);
236
- killGroup('SIGKILL');
237
- }
238
- }
239
- function inlineStylesheets(html) {
240
- return html.replace(/<link\b[^>]*rel="stylesheet"[^>]*>/gi, (tag) => {
241
- const href = tag.match(/href="([^"]+)"/i)?.[1];
242
- if (!href || !href.startsWith('/_next/')) {
243
- warn(`dropping non-inlinable stylesheet ${href ?? '<unknown>'} from 500.html (self-containment, REQ-14)`);
244
- return '';
245
- }
246
- const cssPath = path.join(siteRoot, '.next', href.replace(/^\/_next\//, ''));
247
- if (!fs.existsSync(cssPath)) {
248
- warn(`stylesheet ${href} not found in .next output; dropping it from 500.html`);
249
- return '';
250
- }
251
- return `<style>${fs.readFileSync(cssPath, 'utf8')}</style>`;
252
- });
253
- }
254
29
  // Relative convention path of the tenant-owned instrumentation policy
255
30
  // module (D-15, code-organization R-14).
256
31
  const INSTRUMENTATION_MODULE = path.join('src', 'aci', 'error-page-instrumentation.mjs');
@@ -351,48 +126,16 @@ export function stampInstrumentation(html, instrumentationTag) {
351
126
  }
352
127
  return stamped;
353
128
  }
354
- function postProcess(html, instrumentationTag) {
355
- let out = html;
356
- // Strip every script tag: framework runtime chunks are dead weight in a
357
- // statically served copy, and this removes the collect script and the
358
- // data-aci-assignments tag (REQ-11) in one sweep.
359
- out = out.replace(/<script\b[\s\S]*?<\/script>/gi, '');
360
- out = out.replace(/<link\b[^>]*rel="(?:preload|modulepreload|prefetch)"[^>]*>/gi, '');
361
- out = inlineStylesheets(out);
362
- return stampInstrumentation(out, instrumentationTag);
363
- }
364
- async function emit() {
365
- const publicDir = path.join(siteRoot, 'public');
129
+ export async function emit(root = siteRoot, content) {
130
+ const publicDir = path.join(root, 'public');
366
131
  const target = path.join(publicDir, '500.html');
367
132
  if (fs.existsSync(target) && !fs.readFileSync(target, 'utf8').includes(MARKER)) {
368
133
  log('public/500.html already provided by the site; leaving it untouched (skip-if-present)');
369
134
  return;
370
135
  }
371
- const { route, defaultHtml } = process.env.ACI_CODE_ARTIFACT_BUILD === '1'
372
- ? { route: null, defaultHtml: (await sdkContent()).defaultErrorPageHtml() }
373
- : await resolveCatchAllRoute();
374
- let instrumentationTag;
375
- try {
376
- instrumentationTag = await resolveInstrumentationTag();
377
- }
378
- catch (error) {
379
- fail(error.message);
380
- }
381
- let html;
382
- try {
383
- if (!route) {
384
- // The shipped default is already self-contained; it gets the same
385
- // instrumentation-plus-marker append used for rendered pages.
386
- html = stampInstrumentation(defaultHtml, instrumentationTag);
387
- }
388
- else {
389
- log(`rendering bound catchAll page ${route} for public/500.html`);
390
- html = postProcess(await fetchRenderedRoute(route), instrumentationTag);
391
- }
392
- }
393
- catch (error) {
394
- fail(error.message);
395
- }
136
+ const sdk = content ?? (await sdkContent());
137
+ const instrumentationTag = await resolveInstrumentationTag(root, sdk);
138
+ const html = stampInstrumentation(sdk.defaultErrorPageHtml(), instrumentationTag);
396
139
  const size = Buffer.byteLength(html);
397
140
  if (size > SIZE_BUDGET) {
398
141
  fail(`public/500.html is ${size} bytes, over the ${SIZE_BUDGET}-byte budget (REQ-14). `
@@ -400,7 +143,7 @@ async function emit() {
400
143
  }
401
144
  fs.mkdirSync(publicDir, { recursive: true });
402
145
  fs.writeFileSync(target, html);
403
- log(`wrote public/500.html (${size} bytes${route ? `, from ${route}` : ', shipped default'})`);
146
+ log(`wrote public/500.html (${size} bytes, shipped default)`);
404
147
  }
405
148
  // Run only when executed as a CLI — the module is also imported by unit
406
149
  // tests for stampInstrumentation.
@@ -13,19 +13,12 @@ export function withAci(nextConfig = {}, options = {}) {
13
13
  '`next dev` / `next build` without --turbopack.');
14
14
  }
15
15
  const publicPaths = collectPublicPaths(join(process.cwd(), 'public'));
16
- // Authorable error pages (D-9): the Next error seat is client-only, so the
17
- // bound catchAll page's content is serialized here — while next.config is
18
- // evaluated — and injected into the bundle via DefinePlugin below. The D-5
19
- // posture rides ACI_ERROR_PAGES_POSTURE (the same variable the Go stack
20
- // reads): under warn (default), absent bindings/content and non-REQ-5
21
- // serialization failures warn and serialize the shipped default (REQ-4);
22
- // under enforce they fail the build closed, and unknown posture values
23
- // fail closed too. Present-but-broken bindings fail closed always (REQ-5).
24
- // A code artifact is independent of every content snapshot. Authored error
25
- // bindings are validated when previewing/publishing the selected content.
26
- const errorSeat = process.env.ACI_CODE_ARTIFACT_BUILD === '1'
27
- ? { payload: { defaulted: true, defaultHtml: defaultErrorPageFragmentHtml() }, warnings: [], errors: [] }
28
- : buildErrorSeatPayload(undefined, process.env.ACI_ERROR_PAGES_POSTURE);
16
+ // Local dev previews the authored error seat. Production code builds must
17
+ // never bake a content snapshot into the native client-only error seat;
18
+ // the selected release's authored errors are materialized at publish.
19
+ const errorSeat = process.env.NODE_ENV === 'development'
20
+ ? buildErrorSeatPayload(undefined, process.env.ACI_ERROR_PAGES_POSTURE)
21
+ : { payload: { defaulted: true, defaultHtml: defaultErrorPageFragmentHtml() }, warnings: [], errors: [] };
29
22
  if (errorSeat.errors.length > 0) {
30
23
  throw new Error(`ACI errorPages bindings are invalid (site.json#errorPages):\n- ${errorSeat.errors.join('\n- ')}`);
31
24
  }
@@ -1,6 +1,6 @@
1
1
  import type { ErrorSeatPayload } from './error-seat.js';
2
2
  /**
3
- * Build-time serialization of the bound catchAll page for the client-only
3
+ * Local-development serialization of the bound catchAll page for the client-only
4
4
  * Next error seat (authorable-error-pages D-9). Synchronous by design: it
5
5
  * runs inside withAci() while next.config is evaluated, reading the compiled
6
6
  * content tree directly (the async providers cannot be awaited there).
@@ -29,8 +29,7 @@ export function buildErrorSeatPayload(contentRoot, posture) {
29
29
  const root = path.resolve(process.cwd(), contentRoot || process.env.ACI_CONTENT_ROOT || '.aci/compiled');
30
30
  if (!existsSync(path.join(root, 'manifest.json'))) {
31
31
  const message = `no compiled content at ${root}; the error seat will serve the shipped default page — `
32
- + 'run the content compile before `next build` (the template build script\'s '
33
- + '`emit-error-pages.mjs prepare` phase does this)';
32
+ + 'run `npm run content:compile` to preview authored error pages locally';
34
33
  if (effectivePosture === 'enforce') {
35
34
  errors.push(`${message} (posture "enforce" fails closed, D-5)`);
36
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.53-canary.700",
3
+ "version": "0.1.53-canary.702",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/gradial/bare-metal-go.git",
@@ -164,11 +164,11 @@
164
164
  "astro": "^6.4.8"
165
165
  },
166
166
  "optionalDependencies": {
167
- "@gradial/aci-cli-darwin-arm64": "0.1.53-canary.700",
168
- "@gradial/aci-cli-darwin-x64": "0.1.53-canary.700",
169
- "@gradial/aci-cli-linux-arm64": "0.1.53-canary.700",
170
- "@gradial/aci-cli-linux-x64": "0.1.53-canary.700",
171
- "@gradial/aci-cli-win32-arm64": "0.1.53-canary.700",
172
- "@gradial/aci-cli-win32-x64": "0.1.53-canary.700"
167
+ "@gradial/aci-cli-darwin-arm64": "0.1.53-canary.702",
168
+ "@gradial/aci-cli-darwin-x64": "0.1.53-canary.702",
169
+ "@gradial/aci-cli-linux-arm64": "0.1.53-canary.702",
170
+ "@gradial/aci-cli-linux-x64": "0.1.53-canary.702",
171
+ "@gradial/aci-cli-win32-arm64": "0.1.53-canary.702",
172
+ "@gradial/aci-cli-win32-x64": "0.1.53-canary.702"
173
173
  }
174
174
  }
@@ -1,73 +1,15 @@
1
1
  #!/usr/bin/env node
2
- // Authorable error pages — build integration (spec:
3
- // specs/features/authorable-error-pages, T3/T11; REQ-10/REQ-11/REQ-14, D-9,
4
- // D-16).
5
- //
6
- // ACI_CODE_ARTIFACT_BUILD=1 makes both phases content-independent: prepare
7
- // serializes only the code-owned policy at its existing .aci/compiled path.
8
- // Emit uses the shipped default without resolving authored error-page bindings.
9
- //
10
- // Two phases. The Next template wires both around `next build`; the Astro
11
- // template runs only `prepare` before `astro build` (its 404/500 seats
12
- // render through the framework, but the D-16 instrumentation artifact must
13
- // still reach the compiled content root for the AWS lane):
14
- //
15
- // prepare (before `next build`)
16
- // Ensures compiled content exists at .aci/compiled so withAci() can
17
- // serialize the bound catchAll page for the client-only error seat
18
- // (D-9). Compiles ./.content via the platform CLI when needed; when
19
- // neither compiled content nor authored content exists, it warns and
20
- // leaves the shipped default in play (REQ-4, warn posture). Then writes
21
- // error-pages/instrumentation.json (both kinds' serialized tags) into
22
- // the compiled content root so the tag crosses the JS→Go boundary as
23
- // data: builders retain the instrumentation file at its existing capsule
24
- // path, and the Go publish materializer stamps the kind-matching tag into the CDN
25
- // error artifacts (D-16). An unserializable tenant push fails the build
26
- // loudly here (delta FIND-006).
27
- //
28
- // emit (after `next build`)
29
- // Materializes `public/500.html` from the page bound to
30
- // site.json#errorPages.catchAll by booting the standalone build,
31
- // fetching the bound route, and post-processing the document. Boot-and-
32
- // fetch beats re-rendering the page here because the standalone server
33
- // is the only renderer that produces the document exactly as served
34
- // (layout shell, resolved fragments, RSC-injected head and styles).
35
- // Post-processing:
36
- // - all script tags and preload/prefetch links stripped — this
37
- // removes the collect and assignment tags (REQ-11) along with the
38
- // framework runtime, which is useless in a statically served copy;
39
- // - same-origin stylesheets inlined from .next/static (REQ-14 spirit:
40
- // inline styles; note @font-face/image URLs inside the CSS keep
41
- // their same-origin paths — this copy is served by the Vercel
42
- // platform on the deployment that owns those assets);
43
- // - one self-contained inline script appended pushing the D-14
44
- // `page_view` (page_type "error") — a static document executes
45
- // inline scripts, unlike the flight-rendered not-found seat. The
46
- // push body is site policy (D-15): the tenant-owned
47
- // src/aci/error-page-instrumentation.mjs when present, the SDK
48
- // default otherwise;
49
- // - hard size budget: ≤150 KB raw (REQ-14) — exceeding it fails the
50
- // build; trimming the design system or falling back to system
51
- // fonts is the sanctioned escape hatch.
52
- // Skips when the site already provides its own public/500.html (a file
53
- // without the generation marker); regenerates its own prior output.
54
- // Absent bindings emit the shipped branded-neutral default (REQ-4).
55
- // Vercel picks static per-status files out of the build output
56
- // ("file presence = configuration"); on the current Pro plan the file
57
- // serves app-level 500s only and lights up for platform 5xx on a plan
58
- // upgrade (REQ-10, P-1).
59
- //
60
- // Usage: node ./node_modules/@gradial/aci/src/cli/emit-error-pages.mjs <prepare|emit>
2
+ // Code-build error artifacts. prepare transports code-owned analytics policy;
3
+ // emit writes a content-independent static 500. Authored error-page bindings
4
+ // and rendering belong to content preview/publish, never the framework build.
5
+ // Usage: emit-error-pages.mjs <prepare|emit>
61
6
 
62
- import { execFileSync, spawn } from 'node:child_process';
63
7
  import fs from 'node:fs';
64
- import net from 'node:net';
65
8
  import path from 'node:path';
66
9
  import { fileURLToPath, pathToFileURL } from 'node:url';
67
10
 
68
11
  const MARKER = '<!-- aci:error-pages generated -->';
69
12
  const SIZE_BUDGET = 150 * 1024;
70
- const BOOT_DEADLINE_MS = 60_000;
71
13
 
72
14
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
73
15
  const siteRoot = process.cwd();
@@ -76,9 +18,6 @@ const contentRoot = path.resolve(siteRoot, process.env.ACI_CONTENT_ROOT || '.aci
76
18
  function log(message) {
77
19
  console.log(`[aci error-pages] ${message}`);
78
20
  }
79
- function warn(message) {
80
- console.warn(`\n[aci error-pages] WARNING: ${message}\n`);
81
- }
82
21
  function fail(message) {
83
22
  console.error(`\n[aci error-pages] ERROR: ${message}\n`);
84
23
  process.exit(1);
@@ -88,181 +27,12 @@ async function sdkContent() {
88
27
  return await import(pathToFileURL(path.join(packageRoot, 'dist', 'content', 'index.js')).href);
89
28
  }
90
29
 
91
- function hasCompiledContent() {
92
- return fs.existsSync(path.join(contentRoot, 'manifest.json'));
93
- }
94
-
95
- // ---------------------------------------------------------------------------
96
- // prepare
97
- // ---------------------------------------------------------------------------
98
-
99
- // Retain the deployed transport path for SDK-first upgrades. This file is
100
- // executable policy, not a compiled page snapshot; do not compile content here.
101
- export async function prepareCodeArtifact(root = siteRoot, content) {
30
+ // Keep the existing capsule transport path for SDK-first upgrades. This file
31
+ // is executable policy, not a compiled content snapshot.
32
+ export async function prepare(root = siteRoot, content) {
102
33
  return writeInstrumentationArtifact(root, content, path.join(root, '.aci', 'compiled'));
103
34
  }
104
35
 
105
- async function prepare() {
106
- if (process.env.ACI_CODE_ARTIFACT_BUILD === '1') {
107
- // Code artifacts carry executable policy only. The selected content and
108
- // its error-page bindings belong to preview/publish, never this build.
109
- await prepareCodeArtifact();
110
- return;
111
- }
112
- if (hasCompiledContent()) {
113
- log(`compiled content present at ${contentRoot}`);
114
- } else {
115
- const authored = path.join(siteRoot, '.content');
116
- if (!fs.existsSync(authored)) {
117
- warn(
118
- `neither compiled content (${contentRoot}) nor authored content (./.content) exists; `
119
- + 'the error seat and 500.html will serve the shipped default page (REQ-4)',
120
- );
121
- } else {
122
- log('compiling ./.content for the error-page build steps');
123
- execFileSync(
124
- 'node',
125
- [path.join(packageRoot, 'bin', 'aci.js'), 'content', 'compile', '--content', authored, '--out', contentRoot],
126
- { cwd: siteRoot, stdio: 'inherit' },
127
- );
128
- }
129
- }
130
- // D-16: the instrumentation artifact rides the compiled content root into
131
- // the AWS-lane code capsule (source/.aci/compiled/**). Written even when
132
- // content is absent — the tenant policy module is independent of authored
133
- // content, and an artifact the Go materializer never receives silently
134
- // degrades to the default tags (the exact gap D-16 closes).
135
- try {
136
- await writeInstrumentationArtifact();
137
- } catch (error) {
138
- fail(error.message);
139
- }
140
- }
141
-
142
- // ---------------------------------------------------------------------------
143
- // emit
144
- // ---------------------------------------------------------------------------
145
-
146
- async function resolveCatchAllRoute() {
147
- const { resolveErrorPageBindings, defaultErrorPageHtml, errorPagePostureFromEnv } = await sdkContent();
148
- // D-5 posture: the one shared config surface (ACI_ERROR_PAGES_POSTURE);
149
- // unknown values fail closed here just like the Go stack.
150
- let posture;
151
- try {
152
- posture = errorPagePostureFromEnv();
153
- } catch (error) {
154
- fail(error.message);
155
- }
156
- if (!hasCompiledContent()) {
157
- if (posture === 'enforce') {
158
- fail(`no compiled content at ${contentRoot}; the enforce posture fails closed (D-5)`);
159
- }
160
- warn(`no compiled content at ${contentRoot}; emitting the shipped default 500.html (REQ-4)`);
161
- return { route: null, defaultHtml: defaultErrorPageHtml() };
162
- }
163
- const { FileContentProvider } = await import(
164
- pathToFileURL(path.join(packageRoot, 'dist', 'providers', 'file.js')).href
165
- );
166
- const provider = new FileContentProvider(contentRoot);
167
- const siteConfig = await provider.getSiteConfig();
168
- const resolution = await resolveErrorPageBindings(siteConfig, { source: provider, posture });
169
- if (resolution.errors.length > 0) {
170
- fail(`errorPages bindings are invalid (REQ-5, fail closed):\n- ${resolution.errors.join('\n- ')}`);
171
- }
172
- for (const warning of resolution.warnings) warn(warning);
173
- if (resolution.defaulted || !resolution.bindings) {
174
- return { route: null, defaultHtml: defaultErrorPageHtml() };
175
- }
176
- return { route: resolution.bindings.catchAll, defaultHtml: defaultErrorPageHtml() };
177
- }
178
-
179
- async function freePort() {
180
- return new Promise((resolve, reject) => {
181
- const server = net.createServer();
182
- server.unref();
183
- server.on('error', reject);
184
- server.listen(0, '127.0.0.1', () => {
185
- const { port } = server.address();
186
- server.close(() => resolve(port));
187
- });
188
- });
189
- }
190
-
191
- async function fetchRenderedRoute(route) {
192
- const serverPath = path.join(siteRoot, '.next', 'standalone', 'server.js');
193
- if (!fs.existsSync(serverPath)) {
194
- fail(`${serverPath} missing — the emit phase must run after \`next build\` (withAci uses standalone output)`);
195
- }
196
- const port = await freePort();
197
- const logChunks = [];
198
- const child = spawn('node', [serverPath], {
199
- cwd: siteRoot,
200
- detached: true,
201
- stdio: ['ignore', 'pipe', 'pipe'],
202
- env: {
203
- ...process.env,
204
- NODE_ENV: 'production',
205
- PORT: String(port),
206
- HOSTNAME: '127.0.0.1',
207
- ACI_CONTENT_PROVIDER: 'file',
208
- ACI_CONTENT_ROOT: contentRoot,
209
- },
210
- });
211
- child.stdout.on('data', (c) => logChunks.push(String(c)));
212
- child.stderr.on('data', (c) => logChunks.push(String(c)));
213
- const exited = new Promise((resolve) => {
214
- child.on('exit', resolve);
215
- child.on('error', (error) => {
216
- logChunks.push(`spawn failed: ${error.message}\n`);
217
- resolve(null);
218
- });
219
- });
220
-
221
- try {
222
- const url = `http://127.0.0.1:${port}${route}`;
223
- const start = Date.now();
224
- let last = 'no attempt made';
225
- while (Date.now() - start < BOOT_DEADLINE_MS) {
226
- try {
227
- const response = await fetch(url, { redirect: 'follow' });
228
- if (response.status === 200) return await response.text();
229
- last = `status ${response.status}`;
230
- } catch (error) {
231
- last = error.message;
232
- }
233
- await new Promise((r) => setTimeout(r, 500));
234
- }
235
- fail(`GET ${url} did not render within ${BOOT_DEADLINE_MS}ms (last: ${last})\n--- server output ---\n${logChunks.join('').slice(-4000)}`);
236
- return '';
237
- } finally {
238
- const killGroup = (signal) => {
239
- if (!child.pid) return;
240
- try { process.kill(-child.pid, signal); } catch { /* gone */ }
241
- };
242
- killGroup('SIGTERM');
243
- const killTimer = setTimeout(() => killGroup('SIGKILL'), 10_000);
244
- await exited;
245
- clearTimeout(killTimer);
246
- killGroup('SIGKILL');
247
- }
248
- }
249
-
250
- function inlineStylesheets(html) {
251
- return html.replace(/<link\b[^>]*rel="stylesheet"[^>]*>/gi, (tag) => {
252
- const href = tag.match(/href="([^"]+)"/i)?.[1];
253
- if (!href || !href.startsWith('/_next/')) {
254
- warn(`dropping non-inlinable stylesheet ${href ?? '<unknown>'} from 500.html (self-containment, REQ-14)`);
255
- return '';
256
- }
257
- const cssPath = path.join(siteRoot, '.next', href.replace(/^\/_next\//, ''));
258
- if (!fs.existsSync(cssPath)) {
259
- warn(`stylesheet ${href} not found in .next output; dropping it from 500.html`);
260
- return '';
261
- }
262
- return `<style>${fs.readFileSync(cssPath, 'utf8')}</style>`;
263
- });
264
- }
265
-
266
36
  // Relative convention path of the tenant-owned instrumentation policy
267
37
  // module (D-15, code-organization R-14).
268
38
  const INSTRUMENTATION_MODULE = path.join('src', 'aci', 'error-page-instrumentation.mjs');
@@ -372,47 +142,17 @@ export function stampInstrumentation(html, instrumentationTag) {
372
142
  return stamped;
373
143
  }
374
144
 
375
- function postProcess(html, instrumentationTag) {
376
- let out = html;
377
- // Strip every script tag: framework runtime chunks are dead weight in a
378
- // statically served copy, and this removes the collect script and the
379
- // data-aci-assignments tag (REQ-11) in one sweep.
380
- out = out.replace(/<script\b[\s\S]*?<\/script>/gi, '');
381
- out = out.replace(/<link\b[^>]*rel="(?:preload|modulepreload|prefetch)"[^>]*>/gi, '');
382
- out = inlineStylesheets(out);
383
- return stampInstrumentation(out, instrumentationTag);
384
- }
385
-
386
- async function emit() {
387
- const publicDir = path.join(siteRoot, 'public');
145
+ export async function emit(root = siteRoot, content) {
146
+ const publicDir = path.join(root, 'public');
388
147
  const target = path.join(publicDir, '500.html');
389
148
  if (fs.existsSync(target) && !fs.readFileSync(target, 'utf8').includes(MARKER)) {
390
149
  log('public/500.html already provided by the site; leaving it untouched (skip-if-present)');
391
150
  return;
392
151
  }
393
152
 
394
- const { route, defaultHtml } = process.env.ACI_CODE_ARTIFACT_BUILD === '1'
395
- ? { route: null, defaultHtml: (await sdkContent()).defaultErrorPageHtml() }
396
- : await resolveCatchAllRoute();
397
- let instrumentationTag;
398
- try {
399
- instrumentationTag = await resolveInstrumentationTag();
400
- } catch (error) {
401
- fail(error.message);
402
- }
403
- let html;
404
- try {
405
- if (!route) {
406
- // The shipped default is already self-contained; it gets the same
407
- // instrumentation-plus-marker append used for rendered pages.
408
- html = stampInstrumentation(defaultHtml, instrumentationTag);
409
- } else {
410
- log(`rendering bound catchAll page ${route} for public/500.html`);
411
- html = postProcess(await fetchRenderedRoute(route), instrumentationTag);
412
- }
413
- } catch (error) {
414
- fail(error.message);
415
- }
153
+ const sdk = content ?? (await sdkContent());
154
+ const instrumentationTag = await resolveInstrumentationTag(root, sdk);
155
+ const html = stampInstrumentation(sdk.defaultErrorPageHtml(), instrumentationTag);
416
156
 
417
157
  const size = Buffer.byteLength(html);
418
158
  if (size > SIZE_BUDGET) {
@@ -424,7 +164,7 @@ async function emit() {
424
164
 
425
165
  fs.mkdirSync(publicDir, { recursive: true });
426
166
  fs.writeFileSync(target, html);
427
- log(`wrote public/500.html (${size} bytes${route ? `, from ${route}` : ', shipped default'})`);
167
+ log(`wrote public/500.html (${size} bytes, shipped default)`);
428
168
  }
429
169
 
430
170
  // Run only when executed as a CLI — the module is also imported by unit