@gradial/aci 0.1.45 → 0.1.47

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.
Files changed (90) hide show
  1. package/README.md +4 -4
  2. package/dist/astro/error-page.d.ts +56 -0
  3. package/dist/astro/error-page.js +79 -0
  4. package/dist/astro/index.d.ts +4 -0
  5. package/dist/astro/index.js +9 -0
  6. package/dist/audience-data-models.d.ts +142 -2
  7. package/dist/audience-data-models.js +2 -0
  8. package/dist/cli/compile-registry.d.mts +1 -5
  9. package/dist/cli/compile-registry.mjs +10 -25
  10. package/dist/cli/emit-error-pages.d.mts +39 -0
  11. package/dist/cli/emit-error-pages.mjs +402 -0
  12. package/dist/cli/validate-content.d.mts +1 -31
  13. package/dist/cli/validate-content.mjs +12 -50
  14. package/dist/cli/verify-next-cdn-routes.mjs +1 -0
  15. package/dist/content/default-error-page-html.d.ts +2 -0
  16. package/dist/content/default-error-page-html.js +6 -0
  17. package/dist/content/default-error-page.d.ts +23 -0
  18. package/dist/content/default-error-page.js +34 -0
  19. package/dist/content/error-page-kind.d.ts +57 -0
  20. package/dist/content/error-page-kind.js +106 -0
  21. package/dist/content/error-pages.d.ts +88 -0
  22. package/dist/content/error-pages.js +120 -0
  23. package/dist/content/index.d.ts +2 -0
  24. package/dist/content/index.js +2 -0
  25. package/dist/content/provider.d.ts +1 -1
  26. package/dist/content/resolve-slots.js +0 -1
  27. package/dist/content/types.d.ts +12 -2
  28. package/dist/internal/render-proof.d.ts +1 -0
  29. package/dist/internal/render-proof.js +197 -0
  30. package/dist/next/config.js +17 -0
  31. package/dist/next/error-page-instrumentation.d.ts +23 -0
  32. package/dist/next/error-page-instrumentation.js +36 -0
  33. package/dist/next/error-page-marker.d.ts +11 -0
  34. package/dist/next/error-page-marker.js +20 -0
  35. package/dist/next/error-pages.d.ts +52 -0
  36. package/dist/next/error-pages.js +94 -0
  37. package/dist/next/error-seat-payload.d.ts +33 -0
  38. package/dist/next/error-seat-payload.js +143 -0
  39. package/dist/next/error-seat.d.ts +57 -0
  40. package/dist/next/error-seat.js +83 -0
  41. package/dist/next/index.d.ts +4 -0
  42. package/dist/next/index.js +8 -0
  43. package/dist/next/middleware.js +9 -54
  44. package/dist/next/page.js +9 -4
  45. package/dist/next/personalization-host.d.ts +5 -0
  46. package/dist/next/server.js +88 -75
  47. package/dist/next/vercel-collect-sdk-route.js +6 -30
  48. package/dist/next/vercel-edge-projection-v1.d.ts +24 -0
  49. package/dist/next/vercel-edge-projection-v1.js +880 -0
  50. package/dist/next/vercel-s3-personalization.js +7 -11
  51. package/dist/providers/s3.d.ts +2 -4
  52. package/dist/providers/s3.js +173 -19
  53. package/dist/types/block-ref.d.ts +0 -2
  54. package/dist/types/block-ref.js +0 -1
  55. package/package.json +15 -9
  56. package/src/cli/compile-registry.mjs +10 -24
  57. package/src/cli/emit-error-pages.mjs +424 -0
  58. package/src/cli/validate-content.mjs +13 -52
  59. package/src/cli/verify-next-cdn-routes.mjs +6 -0
  60. package/src/types/block-ref.ts +0 -2
  61. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +6 -7
  62. package/templates/astro/starter-content/src/fixtures/composition.ts +2 -3
  63. package/templates/astro/starter-content/src/fixtures/sections.ts +1 -2
  64. package/templates/astro/template/package.json.tmpl +1 -1
  65. package/templates/astro/template/src/aci/error-page-instrumentation.mjs +33 -0
  66. package/templates/astro/template/src/pages/404.astro +54 -0
  67. package/templates/astro/template/src/pages/500.astro +53 -0
  68. package/templates/astro/template/src/pages/[...slug].astro +15 -2
  69. package/templates/common/.agents/skills/authoring/SKILL.md +74 -11
  70. package/templates/common/.agents/skills/authoring/references/block-slots.md +0 -3
  71. package/templates/common/.content/config/robots.txt +4 -0
  72. package/templates/common/.content/config/site.json.tmpl +4 -0
  73. package/templates/common/.content/pages/404/_index.json +31 -0
  74. package/templates/common/.content/pages/error/_index.json +31 -0
  75. package/templates/common/.content/pages/home/_index.json +0 -10
  76. package/templates/common/.content/schemas/site.schema.json +9 -0
  77. package/templates/common/starter-content/.content/config/robots.txt +2 -0
  78. package/templates/common/starter-content/.content/config/site.json +4 -0
  79. package/templates/common/starter-content/.content/pages/404/_index.json +31 -0
  80. package/templates/common/starter-content/.content/pages/about/_index.json +0 -3
  81. package/templates/common/starter-content/.content/pages/error/_index.json +31 -0
  82. package/templates/common/starter-content/.content/pages/home/_index.json +21 -14
  83. package/templates/common/starter-content/.content/schemas/site.schema.json +9 -0
  84. package/templates/nextjs/template/package.json.tmpl +1 -1
  85. package/templates/nextjs/template/src/aci/error-page-instrumentation.d.mts +4 -0
  86. package/templates/nextjs/template/src/aci/error-page-instrumentation.mjs +38 -0
  87. package/templates/nextjs/template/src/app/error.tsx +18 -0
  88. package/templates/nextjs/template/src/app/not-found.tsx +13 -0
  89. package/src/types/block-ref.test.ts +0 -45
  90. package/templates/nextjs/template/src/app/collect/route.ts +0 -18
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+ import { useEffect } from 'react';
3
+ // Client-safe leaf import — see error-page-marker.tsx.
4
+ import { defaultPushErrorPageView, errorPageType, } from '../content/error-page-kind.js';
5
+ /**
6
+ * Error-surface analytics MECHANISM (authorable-error-pages REQ-12 / D-14 /
7
+ * D-15): calls the site's push function exactly ONCE per error view with
8
+ * the D-14 page_type. The push BODY is site policy — the seats forward the
9
+ * tenant-owned scaffolded module's `pushErrorPageView` via `push`; without
10
+ * it, `defaultPushErrorPageView` (the scaffolded default's twin) runs.
11
+ *
12
+ * Delivery MUST be a 'use client' useEffect: the not-found seat is
13
+ * client-rendered from the RSC flight payload, where React skips execution
14
+ * of inline script elements (spike A learning 3). The SDK error seats mount
15
+ * this only with error render context; ordinary routes — including direct
16
+ * GET /404/ and /error/ previews — never render it (REQ-12).
17
+ *
18
+ * Renders nothing. The seats (createNotFound, ErrorSeat) render the inert
19
+ * `data-aci-error-page` marker themselves — server-side where possible, so
20
+ * gates can assert it in fetched documents (a client component's output
21
+ * never appears in the serialized flight text).
22
+ */
23
+ export function ErrorPageInstrumentation({ kind, push, }) {
24
+ const pageType = errorPageType(kind);
25
+ useEffect(() => {
26
+ let effectivePush = push ?? defaultPushErrorPageView;
27
+ if (typeof effectivePush !== 'function') {
28
+ // FIND-002: a non-function push (broken config plumbing) must degrade
29
+ // to the default, never crash the error surface itself.
30
+ console.warn('[aci error-pages] pushErrorPageView is not a function; using the SDK default push (D-15)');
31
+ effectivePush = defaultPushErrorPageView;
32
+ }
33
+ effectivePush(pageType);
34
+ }, [pageType, push]);
35
+ return null;
36
+ }
@@ -0,0 +1,11 @@
1
+ import { type ErrorPageKind } from '../content/error-page-kind.js';
2
+ /**
3
+ * Inert, invisible marker naming the error surface
4
+ * (`data-aci-error-page="not_found"|"error"`). Rendered by the seats next to
5
+ * ErrorPageInstrumentation as the fetch-level assertion hook for build and
6
+ * serve gates; its absence on ordinary routes proves direct /404/ and
7
+ * /error/ visits carry no error-typed instrumentation (REQ-12).
8
+ */
9
+ export declare function ErrorPageMarker({ kind }: {
10
+ kind: ErrorPageKind;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // Deliberately NO 'use client' directive: this module is shared by the
3
+ // server-rendered not-found seat and the client-only error seat. At the
4
+ // not-found seat the marker must be a SERVER-rendered element so it lands in
5
+ // the RSC flight payload — a client component's rendered output never
6
+ // appears in the serialized document, which is exactly why the build/serve
7
+ // gates could not assert a marker owned by ErrorPageInstrumentation.
8
+ // Import the client-safe leaf directly: content/error-pages.js reaches
9
+ // node:path via routes.js, which webpack rejects in browser bundles.
10
+ import { errorPageType } from '../content/error-page-kind.js';
11
+ /**
12
+ * Inert, invisible marker naming the error surface
13
+ * (`data-aci-error-page="not_found"|"error"`). Rendered by the seats next to
14
+ * ErrorPageInstrumentation as the fetch-level assertion hook for build and
15
+ * serve gates; its absence on ordinary routes proves direct /404/ and
16
+ * /error/ visits carry no error-typed instrumentation (REQ-12).
17
+ */
18
+ export function ErrorPageMarker({ kind }) {
19
+ return _jsx("span", { hidden: true, "data-aci-error-page": errorPageType(kind) });
20
+ }
@@ -0,0 +1,52 @@
1
+ import type { ReactElement } from 'react';
2
+ import { type FetchConfig } from './server.js';
3
+ import { type ErrorPageKind, type ErrorPagePush, type ResolvedErrorPage } from '../content/error-pages.js';
4
+ import type { KernelSiteConfig } from '../content/types.js';
5
+ import type { Registry } from '../registry.js';
6
+ /**
7
+ * Next runtime seams for authorable error pages (REQ-6, 02-design "SDK
8
+ * core"): `resolveErrorPage(kind)` + `renderPageForRoute(route)` — the pair
9
+ * both spike wrappers hand-rolled — plus `createNotFound(registry)`, which
10
+ * composes them so the starter's `not-found.tsx` stays thin.
11
+ */
12
+ export interface NextResolvedErrorPage extends ResolvedErrorPage {
13
+ siteConfig: KernelSiteConfig | null;
14
+ }
15
+ /**
16
+ * Resolves one error-page binding from the active site config under the
17
+ * warn posture (D-5). Never throws: an unreachable site config degrades to
18
+ * the shipped default — an error surface must not re-enter the failing
19
+ * system (ANTI-2).
20
+ */
21
+ export declare function resolveErrorPage(kind: ErrorPageKind, config?: FetchConfig): Promise<NextResolvedErrorPage>;
22
+ /**
23
+ * Renders the content page bound to `route` through the normal page
24
+ * pipeline (createPage: layout slots, fragments, registry blocks, collect/
25
+ * assignments tags), resolving to the same tree the catch-all route would
26
+ * serve. Throws whatever the pipeline throws — including the notFound()
27
+ * control error for missing content — so callers at error seats must wrap
28
+ * it (createNotFound does).
29
+ */
30
+ export declare function renderPageForRoute(registry: Registry, route: string): Promise<ReactElement>;
31
+ /**
32
+ * The shipped branded-neutral default error page (REQ-4/D-5) as an inline
33
+ * fragment — used when no binding exists or the bound content cannot be
34
+ * rendered. Self-contained by construction (inline styles only, REQ-14).
35
+ */
36
+ export declare function DefaultErrorPageContent(): import("react").JSX.Element;
37
+ /**
38
+ * Builds the component for Next's native not-found seat
39
+ * (`src/app/not-found.tsx`). The SDK catch-all invokes `notFound()` on
40
+ * missing content, so this renders with a real HTTP 404 (REQ-3/REQ-6).
41
+ *
42
+ * Fallback contract: if the bound notFound content is itself missing or
43
+ * fails to render at request time, the shipped default renders INLINE —
44
+ * the seat never re-invokes notFound() (a recursive seat was the spike's
45
+ * kill-list item) and never throws.
46
+ *
47
+ * `pushErrorPageView` (D-15): the tenant-owned policy module's push,
48
+ * forwarded to ErrorPageInstrumentation; omitted → the SDK default.
49
+ */
50
+ export declare function createNotFound(registry: Registry, config?: FetchConfig & {
51
+ pushErrorPageView?: ErrorPagePush;
52
+ }): () => Promise<ReactElement>;
@@ -0,0 +1,94 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { headers } from 'next/headers.js';
3
+ import { createPage } from './page.js';
4
+ import { getSiteConfig } from './server.js';
5
+ import { resolveErrorPage as resolveErrorPageForSite, } from '../content/error-pages.js';
6
+ import { defaultErrorPageFragmentHtml } from '../content/default-error-page.js';
7
+ import { ErrorPageInstrumentation } from './error-page-instrumentation.js';
8
+ import { ErrorPageMarker } from './error-page-marker.js';
9
+ /**
10
+ * Resolves one error-page binding from the active site config under the
11
+ * warn posture (D-5). Never throws: an unreachable site config degrades to
12
+ * the shipped default — an error surface must not re-enter the failing
13
+ * system (ANTI-2).
14
+ */
15
+ export async function resolveErrorPage(kind, config = {}) {
16
+ let siteConfig = null;
17
+ const warnings = [];
18
+ try {
19
+ siteConfig = await getSiteConfig(config);
20
+ }
21
+ catch (error) {
22
+ warnings.push(`site config unavailable at the error seat: ${String(error)}`);
23
+ }
24
+ const resolved = await resolveErrorPageForSite(kind, siteConfig);
25
+ return { ...resolved, warnings: [...warnings, ...resolved.warnings], siteConfig };
26
+ }
27
+ /**
28
+ * Renders the content page bound to `route` through the normal page
29
+ * pipeline (createPage: layout slots, fragments, registry blocks, collect/
30
+ * assignments tags), resolving to the same tree the catch-all route would
31
+ * serve. Throws whatever the pipeline throws — including the notFound()
32
+ * control error for missing content — so callers at error seats must wrap
33
+ * it (createNotFound does).
34
+ */
35
+ export async function renderPageForRoute(registry, route) {
36
+ const page = createPage(registry);
37
+ const slug = route.split('/').filter(Boolean);
38
+ return await page.default({ params: Promise.resolve({ slug }) });
39
+ }
40
+ /**
41
+ * The shipped branded-neutral default error page (REQ-4/D-5) as an inline
42
+ * fragment — used when no binding exists or the bound content cannot be
43
+ * rendered. Self-contained by construction (inline styles only, REQ-14).
44
+ */
45
+ export function DefaultErrorPageContent() {
46
+ return _jsx("div", { dangerouslySetInnerHTML: { __html: defaultErrorPageFragmentHtml() } });
47
+ }
48
+ /**
49
+ * Builds the component for Next's native not-found seat
50
+ * (`src/app/not-found.tsx`). The SDK catch-all invokes `notFound()` on
51
+ * missing content, so this renders with a real HTTP 404 (REQ-3/REQ-6).
52
+ *
53
+ * Fallback contract: if the bound notFound content is itself missing or
54
+ * fails to render at request time, the shipped default renders INLINE —
55
+ * the seat never re-invokes notFound() (a recursive seat was the spike's
56
+ * kill-list item) and never throws.
57
+ *
58
+ * `pushErrorPageView` (D-15): the tenant-owned policy module's push,
59
+ * forwarded to ErrorPageInstrumentation; omitted → the SDK default.
60
+ */
61
+ export function createNotFound(registry, config = {}) {
62
+ return async function NotFound() {
63
+ // Force dynamic rendering: Next prerenders /_not-found at build time,
64
+ // where no content provider is reachable. The DYNAMIC_SERVER_USAGE
65
+ // bailout must propagate for Next to mark the route dynamic; outside a
66
+ // request scope (unit tests, tooling) headers() just throws and is
67
+ // ignored.
68
+ try {
69
+ await headers();
70
+ }
71
+ catch (error) {
72
+ const digest = error?.digest;
73
+ if (typeof digest === 'string' && digest.startsWith('DYNAMIC_SERVER_USAGE'))
74
+ throw error;
75
+ }
76
+ const resolved = await resolveErrorPage('notFound', config);
77
+ for (const warning of resolved.warnings) {
78
+ console.warn(`[aci error-pages] ${warning}`);
79
+ }
80
+ let content = null;
81
+ if (resolved.route) {
82
+ try {
83
+ content = await renderPageForRoute(registry, resolved.route);
84
+ }
85
+ catch (error) {
86
+ // Includes the pipeline's own notFound() control error: bound
87
+ // content missing at request time → shipped default, inline.
88
+ console.warn(`[aci error-pages] bound notFound page "${resolved.route}" failed to render; serving the shipped default: ${String(error)}`);
89
+ content = null;
90
+ }
91
+ }
92
+ return (_jsxs(_Fragment, { children: [content ?? _jsx(DefaultErrorPageContent, {}), _jsx(ErrorPageMarker, { kind: "notFound" }), _jsx(ErrorPageInstrumentation, { kind: "notFound", push: config.pushErrorPageView })] }));
93
+ };
94
+ }
@@ -0,0 +1,33 @@
1
+ import type { ErrorSeatPayload } from './error-seat.js';
2
+ /**
3
+ * Build-time serialization of the bound catchAll page for the client-only
4
+ * Next error seat (authorable-error-pages D-9). Synchronous by design: it
5
+ * runs inside withAci() while next.config is evaluated, reading the compiled
6
+ * content tree directly (the async providers cannot be awaited there).
7
+ *
8
+ * Posture (REQ-4/REQ-5, D-5 warn-then-enforce; `posture` carries the
9
+ * ACI_ERROR_PAGES_POSTURE value, unknown values fail closed):
10
+ * - warn (default): absent bindings or absent compiled content → defaulted
11
+ * payload + warnings (withAci surfaces them loudly);
12
+ * - enforce: absent bindings/content are errors and withAci fails closed;
13
+ * - present-but-broken bindings — exactly the enumerated REQ-5 failures
14
+ * (malformed route, unresolved page, unpublished page, audience/experiment
15
+ * overlays — REQ-11) → `errors` in BOTH postures;
16
+ * - ANY other exception (stale/corrupt compiled content, old manifest
17
+ * shapes) warns-and-defaults under warn and errors only under enforce, so
18
+ * a stale .aci/compiled tree can never break a binding-less build.
19
+ *
20
+ * NOTE: this is a SYNCHRONOUS twin of the async binding validation in
21
+ * `src/content/error-pages.ts` (resolveErrorPageBindings). Keep the REQ-5
22
+ * semantics in lockstep when editing either; extraction is deferred.
23
+ *
24
+ * REQ-11 stripping is by construction: only the page's resolved content
25
+ * blocks are serialized — no collect/assignment tags, and the page-level
26
+ * `experiments` assignment context is dropped.
27
+ */
28
+ export interface ErrorSeatPayloadResult {
29
+ payload: ErrorSeatPayload;
30
+ warnings: string[];
31
+ errors: string[];
32
+ }
33
+ export declare function buildErrorSeatPayload(contentRoot?: string, posture?: string): ErrorSeatPayloadResult;
@@ -0,0 +1,143 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ERROR_PAGE_ROUTE_PATTERN } from '../content/error-pages.js';
4
+ import { defaultErrorPageFragmentHtml } from '../content/default-error-page.js';
5
+ import { normalizeRoute } from '../content/routes.js';
6
+ export function buildErrorSeatPayload(contentRoot, posture) {
7
+ const warnings = [];
8
+ const errors = [];
9
+ const defaultHtml = defaultErrorPageFragmentHtml();
10
+ const defaulted = () => ({
11
+ payload: { defaulted: true, defaultHtml },
12
+ warnings,
13
+ errors,
14
+ });
15
+ // Unknown posture values fail closed (quorum BUG-002, Go parity).
16
+ let effectivePosture;
17
+ const rawPosture = posture ?? '';
18
+ if (rawPosture === '' || rawPosture === 'warn') {
19
+ effectivePosture = 'warn';
20
+ }
21
+ else if (rawPosture === 'enforce') {
22
+ effectivePosture = 'enforce';
23
+ }
24
+ else {
25
+ errors.push(`unknown errorPages validation posture ${JSON.stringify(rawPosture)} (want "warn" or "enforce")`);
26
+ return defaulted();
27
+ }
28
+ const root = path.resolve(process.cwd(), contentRoot || process.env.ACI_CONTENT_ROOT || '.aci/compiled');
29
+ if (!existsSync(path.join(root, 'manifest.json'))) {
30
+ const message = `no compiled content at ${root}; the error seat will serve the shipped default page — `
31
+ + 'run the content compile before `next build` (the template build script\'s '
32
+ + '`emit-error-pages.mjs prepare` phase does this)';
33
+ if (effectivePosture === 'enforce') {
34
+ errors.push(`${message} (posture "enforce" fails closed, D-5)`);
35
+ }
36
+ else {
37
+ warnings.push(message);
38
+ }
39
+ return defaulted();
40
+ }
41
+ try {
42
+ const manifest = readJSON(root, 'manifest.json');
43
+ const siteConfig = readJSON(root, manifest.siteConfigRef);
44
+ const bindings = siteConfig.errorPages;
45
+ if (bindings == null) {
46
+ if (effectivePosture === 'enforce') {
47
+ errors.push('site config requires errorPages.notFound and errorPages.catchAll bindings (posture "enforce", D-5)');
48
+ return defaulted();
49
+ }
50
+ warnings.push('site config has no errorPages bindings; the error seat will serve the shipped '
51
+ + 'branded-neutral default page — author /404/ and /error/ pages and bind them via '
52
+ + 'site.json#errorPages (authorable-error-pages REQ-4/D-5)');
53
+ return defaulted();
54
+ }
55
+ // Present bindings are strict in every posture (REQ-5): BOTH kinds are
56
+ // validated here so a broken notFound binding fails `next build` too,
57
+ // even though only catchAll is serialized for the error seat.
58
+ let catchAllEntry = null;
59
+ for (const kind of ['notFound', 'catchAll']) {
60
+ const route = bindings[kind];
61
+ if (typeof route !== 'string' || !ERROR_PAGE_ROUTE_PATTERN.test(route)) {
62
+ errors.push(`errorPages.${kind} ${JSON.stringify(route)} is not a route path (want "/<segment>/")`);
63
+ continue;
64
+ }
65
+ const normalized = normalizeRoute(route);
66
+ const entry = Object.values(manifest.routes).find((candidate) => normalizeRoute(candidate.path) === normalized);
67
+ if (!entry) {
68
+ errors.push(`errorPages.${kind} "${route}" does not resolve to any page`);
69
+ continue;
70
+ }
71
+ if (entry.variants && Object.keys(entry.variants).length > 0) {
72
+ errors.push(`errorPages.${kind} "${route}" must not have audience or experiment overlays (REQ-11)`);
73
+ continue;
74
+ }
75
+ const boundPage = readJSON(root, entry.payloadRef);
76
+ if (boundPage.status !== 'published') {
77
+ errors.push(`errorPages.${kind} "${route}" must be a published page (status "${boundPage.status}")`);
78
+ continue;
79
+ }
80
+ if (kind === 'catchAll')
81
+ catchAllEntry = entry;
82
+ }
83
+ if (errors.length > 0 || !catchAllEntry) {
84
+ return defaulted();
85
+ }
86
+ const route = bindings.catchAll;
87
+ const page = readJSON(root, catchAllEntry.payloadRef);
88
+ const layout = readJSON(root, `layouts/${page.layout}.json`);
89
+ const slots = {};
90
+ const slotOrder = [];
91
+ for (const slot of layout.slots) {
92
+ slotOrder.push(slot.name);
93
+ const pageBlocks = page.regions?.[slot.name];
94
+ if (pageBlocks?.length) {
95
+ slots[slot.name] = pageBlocks;
96
+ continue;
97
+ }
98
+ const defaults = layout.defaults?.[slot.name];
99
+ if (!defaults?.length)
100
+ continue;
101
+ const blocks = [];
102
+ for (const ref of defaults) {
103
+ const fragmentRef = manifest.fragments[ref.fragmentId]?.payloadRef;
104
+ if (!fragmentRef)
105
+ continue;
106
+ const fragment = readJSON(root, fragmentRef);
107
+ blocks.push({ id: fragment.id, component: fragment.component, props: fragment.props || {} });
108
+ }
109
+ if (blocks.length)
110
+ slots[slot.name] = blocks;
111
+ }
112
+ return {
113
+ payload: {
114
+ defaulted: false,
115
+ defaultHtml,
116
+ route,
117
+ title: page.metadata?.title,
118
+ slotOrder,
119
+ slots,
120
+ },
121
+ warnings,
122
+ errors,
123
+ };
124
+ }
125
+ catch (error) {
126
+ // Not one of the enumerated REQ-5 binding failures (those are pushed to
127
+ // `errors` above): stale/corrupt compiled content or an old manifest
128
+ // shape. Warn-and-default under the warn posture so it can never break a
129
+ // build; fail closed only under enforce (quorum RISK-002).
130
+ const message = `error-seat serialization failed reading compiled content at ${root}: ${String(error)}`
131
+ + ' — the error seat will serve the shipped default page; recompile the content (.aci/compiled) to clear this';
132
+ if (effectivePosture === 'enforce') {
133
+ errors.push(`${message} (posture "enforce" fails closed, D-5)`);
134
+ }
135
+ else {
136
+ warnings.push(message);
137
+ }
138
+ return defaulted();
139
+ }
140
+ }
141
+ function readJSON(root, ref) {
142
+ return JSON.parse(readFileSync(path.join(root, ref), 'utf8'));
143
+ }
@@ -0,0 +1,57 @@
1
+ import type { KernelPageBlock } from '../content/types.js';
2
+ import type { ErrorPagePush } from '../content/error-page-kind.js';
3
+ import type { Registry } from '../registry.js';
4
+ /**
5
+ * Build-time serialization of the bound catchAll page (authorable-error-pages
6
+ * D-9): Next's error seat is client-only and cannot reach the server content
7
+ * provider, so withAci() resolves `site.json#errorPages.catchAll` while
8
+ * next.config is evaluated and injects the page's resolved slots into the
9
+ * bundle as the `__ACI_ERROR_PAGE__` webpack define (same mechanism as the
10
+ * middleware's `__ACI_PUBLIC_PATHS__`). Content edits to the bound page
11
+ * therefore require a code build — accepted by D-4.
12
+ */
13
+ export interface ErrorSeatPayload {
14
+ /** True when no binding is authored: render the shipped default (REQ-4). */
15
+ defaulted: boolean;
16
+ /** Shipped branded-neutral default fragment — always present as last resort. */
17
+ defaultHtml: string;
18
+ route?: string;
19
+ title?: string;
20
+ /** Layout slot order, then resolved blocks per slot (fragments included). */
21
+ slotOrder?: string[];
22
+ slots?: Record<string, KernelPageBlock[]>;
23
+ }
24
+ /** The build-time serialized payload, or null when the define is absent. */
25
+ export declare function errorSeatPayload(): ErrorSeatPayload | null;
26
+ export interface ErrorSeatProps {
27
+ registry: Registry;
28
+ /** Next passes the boundary error; it is logged by Next, never rendered. */
29
+ error?: (Error & {
30
+ digest?: string;
31
+ }) | null;
32
+ /** Test seam: overrides the build-time serialization when provided. */
33
+ payload?: ErrorSeatPayload | null;
34
+ /**
35
+ * The tenant-owned policy module's push (D-15), forwarded to
36
+ * ErrorPageInstrumentation; omitted → the SDK default.
37
+ */
38
+ pushErrorPageView?: ErrorPagePush;
39
+ }
40
+ /**
41
+ * The Next app-error seat (`src/app/error.tsx` delegates here). Renders the
42
+ * bound catchAll page's serialized content entirely from the client bundle —
43
+ * no fetch, no content provider: the failing system is never re-entered
44
+ * (ANTI-2). Collect/assignment tags are stripped by construction (REQ-11):
45
+ * only content blocks are serialized and this renderer emits no script tags.
46
+ *
47
+ * Coverage (documented for operators): an error boundary catches render/
48
+ * lifecycle errors of route segments BELOW the root layout. It does NOT
49
+ * catch: errors in the root layout itself (Next's global-error seat),
50
+ * middleware failures, event-handler/async errors, or platform 5xx
51
+ * (crash/timeout/throttle) — the latter are covered by the emitted
52
+ * `public/500.html` where the hosting plan supports substitution (REQ-10).
53
+ *
54
+ * The seat must never throw: unknown serialized components are skipped, and
55
+ * a missing/defaulted payload renders the shipped default fragment.
56
+ */
57
+ export declare function ErrorSeat({ registry, payload, pushErrorPageView }: ErrorSeatProps): import("react").JSX.Element;
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Component, Fragment } from 'react';
4
+ import { ErrorPageInstrumentation } from './error-page-instrumentation.js';
5
+ import { ErrorPageMarker } from './error-page-marker.js';
6
+ /** The build-time serialized payload, or null when the define is absent. */
7
+ export function errorSeatPayload() {
8
+ return typeof __ACI_ERROR_PAGE__ === 'undefined' ? null : __ACI_ERROR_PAGE__;
9
+ }
10
+ /**
11
+ * The Next app-error seat (`src/app/error.tsx` delegates here). Renders the
12
+ * bound catchAll page's serialized content entirely from the client bundle —
13
+ * no fetch, no content provider: the failing system is never re-entered
14
+ * (ANTI-2). Collect/assignment tags are stripped by construction (REQ-11):
15
+ * only content blocks are serialized and this renderer emits no script tags.
16
+ *
17
+ * Coverage (documented for operators): an error boundary catches render/
18
+ * lifecycle errors of route segments BELOW the root layout. It does NOT
19
+ * catch: errors in the root layout itself (Next's global-error seat),
20
+ * middleware failures, event-handler/async errors, or platform 5xx
21
+ * (crash/timeout/throttle) — the latter are covered by the emitted
22
+ * `public/500.html` where the hosting plan supports substitution (REQ-10).
23
+ *
24
+ * The seat must never throw: unknown serialized components are skipped, and
25
+ * a missing/defaulted payload renders the shipped default fragment.
26
+ */
27
+ export function ErrorSeat({ registry, payload, pushErrorPageView }) {
28
+ const data = payload !== undefined ? payload : errorSeatPayload();
29
+ return (_jsxs(_Fragment, { children: [_jsx(PayloadBoundary, { fallbackHtml: data?.defaultHtml || LAST_RESORT_HTML, children: renderPayload(data, registry) }), _jsx(ErrorPageMarker, { kind: "catchAll" }), _jsx(ErrorPageInstrumentation, { kind: "catchAll", push: pushErrorPageView })] }));
30
+ }
31
+ /**
32
+ * The seat's own safety net (quorum RISK-004): the serialized payload renders
33
+ * arbitrary registry components, and one that throws at the error seat would
34
+ * otherwise take the whole seat down. This client error boundary catches the
35
+ * render failure and falls back to the shipped default HTML.
36
+ */
37
+ class PayloadBoundary extends Component {
38
+ state = { failed: false };
39
+ static getDerivedStateFromError() {
40
+ return { failed: true };
41
+ }
42
+ render() {
43
+ if (this.state.failed) {
44
+ return _jsx("div", { dangerouslySetInnerHTML: { __html: this.props.fallbackHtml } });
45
+ }
46
+ return this.props.children;
47
+ }
48
+ }
49
+ const LAST_RESORT_HTML = '<main style="max-width:26rem;margin:0 auto;padding:4rem 1rem;text-align:center;font-family:system-ui,sans-serif">'
50
+ + '<h1 style="font-size:1.5rem">Something went wrong.</h1>'
51
+ + '<p>An unexpected error occurred while serving this page.</p>'
52
+ + '<a href="/">Back to homepage</a></main>';
53
+ function renderPayload(data, registry) {
54
+ if (!data || data.defaulted || !data.slots || !data.slotOrder) {
55
+ return _jsx("div", { dangerouslySetInnerHTML: { __html: data?.defaultHtml || LAST_RESORT_HTML } });
56
+ }
57
+ return data.slotOrder.map((slotName) => {
58
+ const blocks = data.slots?.[slotName];
59
+ if (!blocks?.length)
60
+ return null;
61
+ const rendered = renderClientBlocks(blocks, registry);
62
+ if (slotName === 'main') {
63
+ return (_jsx("main", { id: "content-main", children: rendered }, "main"));
64
+ }
65
+ return _jsx(Fragment, { children: rendered }, slotName);
66
+ });
67
+ }
68
+ // A synchronous client-side mirror of next/render renderBlocks (that one is
69
+ // async and server-oriented). Unknown components are skipped — the error
70
+ // seat degrades, it never crashes.
71
+ function renderClientBlocks(blocks, registry) {
72
+ return blocks.map((block) => {
73
+ const entry = registry.find((r) => r.id === block.component);
74
+ if (!entry)
75
+ return null;
76
+ const Component = entry.component;
77
+ const componentProps = { ...block.props };
78
+ if (entry.contract.blockSlots) {
79
+ componentProps.renderChildren = (children) => renderClientBlocks(children, registry);
80
+ }
81
+ return _jsx(Component, { ...componentProps }, block.id);
82
+ });
83
+ }
@@ -7,3 +7,7 @@ export { PageNotFoundError, FragmentNotFoundError, LayoutNotFoundError } from '.
7
7
  export { CollectScript, type CollectScriptProps } from './collect-script.js';
8
8
  export { aciMiddlewareMatcher } from './middleware.js';
9
9
  export { AssignmentsScript, type AssignmentsScriptProps } from './assignments-script.js';
10
+ export { createNotFound, renderPageForRoute, resolveErrorPage, type NextResolvedErrorPage, } from './error-pages.js';
11
+ export { ErrorPageInstrumentation } from './error-page-instrumentation.js';
12
+ export { ErrorPageMarker } from './error-page-marker.js';
13
+ export { errorPageType, type ErrorPageType } from '../content/error-pages.js';
@@ -7,3 +7,11 @@ export { PageNotFoundError, FragmentNotFoundError, LayoutNotFoundError } from '.
7
7
  export { CollectScript } from './collect-script.js';
8
8
  export { aciMiddlewareMatcher } from './middleware.js';
9
9
  export { AssignmentsScript } from './assignments-script.js';
10
+ export { createNotFound, renderPageForRoute, resolveErrorPage, } from './error-pages.js';
11
+ export { ErrorPageInstrumentation } from './error-page-instrumentation.js';
12
+ export { ErrorPageMarker } from './error-page-marker.js';
13
+ // Single definition of the kind→page_type mapping (quorum SIMPLE-005).
14
+ export { errorPageType } from '../content/error-pages.js';
15
+ // NOTE: ErrorSeat is deliberately NOT re-exported here — client components
16
+ // (src/app/error.tsx) must import '@gradial/aci/next/error-seat', because
17
+ // this barrel pulls server-only modules (next/headers).
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server.js';
2
2
  import { getUncachedEdgeConfigValue } from './edge-config.js';
3
3
  import { PREVIEW_TOKEN_COOKIE, PREVIEW_RELEASE_COOKIE, extractPreviewToken, resolvePreviewContext, createPreviewCookies, previewSignKey, } from '../preview/core.js';
4
4
  import { CONTENT_PATH_HEADER, CONTENT_PATH_CACHE_PARAM, resolvePersonalizationRequest, } from './personalization-host.js';
5
+ import { activeReleaseV1Key, clampDeploymentCompatibleReleaseV1, decodeActiveReleaseV1, decodeDeploymentCompatibilityV1, deploymentCompatibilityKey, } from './vercel-edge-projection-v1.js';
5
6
  const RELEASE_SCOPED_PREFIX = '/__r';
6
7
  const FRAMEWORK_ORIGIN_AUTH_HEADER = 'x-gradial-framework-origin-auth';
7
8
  const FRAMEWORK_SITE_ID_HEADER = 'x-baremetal-site-id';
@@ -371,73 +372,27 @@ async function activeReleaseFromEdgeConfig(config) {
371
372
  if (!edgeConfig || !siteId)
372
373
  return '';
373
374
  const active = await activeReleasePointer(edgeConfig, siteId);
374
- if (!active.releaseId)
375
+ if (active == null)
375
376
  return '';
376
377
  const deploymentId = config.deploymentId || process.env.VERCEL_DEPLOYMENT_ID || '';
377
378
  if (!deploymentId)
378
379
  return active.releaseId;
379
- const compatibility = await getEdgeConfigJSON(edgeConfig, deploymentCompatibilityKey(siteId, deploymentId));
380
- if (!compatibility)
380
+ const compatibilityValue = await getEdgeConfigValue(edgeConfig, deploymentCompatibilityKey(siteId, deploymentId));
381
+ if (compatibilityValue == null)
381
382
  return active.releaseId;
382
- return clampRelease(active.releaseId, active.sequence || 0, compatibility);
383
+ return clampDeploymentCompatibleReleaseV1(active, decodeDeploymentCompatibilityV1(compatibilityValue));
383
384
  }
384
385
  function siteIdForConfig(config) {
385
386
  return config.siteId || process.env.ACI_SITE_ID || '';
386
387
  }
387
- async function getEdgeConfigJSON(edgeConfig, key) {
388
- const value = await getEdgeConfigValue(edgeConfig, key);
389
- if (!value)
390
- return null;
391
- if (typeof value === 'string') {
392
- try {
393
- return JSON.parse(value);
394
- }
395
- catch {
396
- return null;
397
- }
398
- }
399
- return value;
400
- }
401
388
  async function getEdgeConfigValue(edgeConfig, key) {
402
389
  return getUncachedEdgeConfigValue(edgeConfig, key);
403
390
  }
404
- function activeReleaseKey(siteId) {
405
- return `activeRelease_${edgeKeySegment(siteId)}`;
406
- }
407
- function deploymentCompatibilityKey(siteId, deploymentId) {
408
- return `deploymentCompatibility_${edgeKeySegment(siteId)}_${edgeKeySegment(deploymentId)}`;
409
- }
410
391
  async function activeReleasePointer(edgeConfig, siteId) {
411
- const value = await getEdgeConfigValue(edgeConfig, activeReleaseKey(siteId));
412
- if (!value)
413
- return { releaseId: '' };
414
- if (typeof value === 'string') {
415
- try {
416
- const parsed = JSON.parse(value);
417
- if (parsed.releaseId)
418
- return parsed;
419
- }
420
- catch {
421
- return { releaseId: value };
422
- }
423
- return { releaseId: value };
424
- }
425
- const pointer = value;
426
- return { releaseId: pointer.releaseId || '', sequence: pointer.sequence };
427
- }
428
- function clampRelease(activeReleaseId, activeSequence, compatibility) {
429
- if (!activeSequence)
430
- return activeReleaseId;
431
- if (compatibility.minSequence && activeSequence < compatibility.minSequence && compatibility.minReleaseId) {
432
- return compatibility.minReleaseId;
433
- }
434
- if (compatibility.maxSequence && activeSequence > compatibility.maxSequence && compatibility.maxReleaseId) {
435
- return compatibility.maxReleaseId;
436
- }
437
- return activeReleaseId;
438
- }
439
- function edgeKeySegment(value) {
440
- return value.replace(/[^A-Za-z0-9_-]/g, '_');
392
+ const value = await getEdgeConfigValue(edgeConfig, activeReleaseV1Key(siteId));
393
+ if (value == null)
394
+ return null;
395
+ return decodeActiveReleaseV1(value);
441
396
  }
442
397
  function isPassthroughRoute(pathname) {
443
398
  return PASSTHROUGH_ROUTES.some((route) => routeMatches(pathname, route));