@gradial/aci 0.1.46 → 0.1.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/astro/error-page.d.ts +56 -0
- package/dist/astro/error-page.js +79 -0
- package/dist/astro/index.d.ts +4 -0
- package/dist/astro/index.js +9 -0
- package/dist/cli/emit-error-pages.d.mts +39 -0
- package/dist/cli/emit-error-pages.mjs +402 -0
- package/dist/collect/aci.js +1 -1
- package/dist/collect/aci.js.sha256 +1 -1
- package/dist/collect/webvitals.d.ts +3 -0
- package/dist/collect/webvitals.js +21 -11
- package/dist/content/default-error-page-html.d.ts +2 -0
- package/dist/content/default-error-page-html.js +6 -0
- package/dist/content/default-error-page.d.ts +23 -0
- package/dist/content/default-error-page.js +34 -0
- package/dist/content/error-page-kind.d.ts +57 -0
- package/dist/content/error-page-kind.js +106 -0
- package/dist/content/error-pages.d.ts +88 -0
- package/dist/content/error-pages.js +120 -0
- package/dist/content/index.d.ts +2 -0
- package/dist/content/index.js +2 -0
- package/dist/content/types.d.ts +12 -0
- package/dist/edge/personalization-core.js +6 -2
- package/dist/next/config.js +17 -0
- package/dist/next/error-page-instrumentation.d.ts +23 -0
- package/dist/next/error-page-instrumentation.js +36 -0
- package/dist/next/error-page-marker.d.ts +11 -0
- package/dist/next/error-page-marker.js +20 -0
- package/dist/next/error-pages.d.ts +52 -0
- package/dist/next/error-pages.js +94 -0
- package/dist/next/error-seat-payload.d.ts +33 -0
- package/dist/next/error-seat-payload.js +143 -0
- package/dist/next/error-seat.d.ts +57 -0
- package/dist/next/error-seat.js +83 -0
- package/dist/next/index.d.ts +4 -0
- package/dist/next/index.js +8 -0
- package/package.json +14 -8
- package/src/cli/emit-error-pages.mjs +424 -0
- package/templates/astro/template/package.json.tmpl +1 -1
- package/templates/astro/template/src/aci/error-page-instrumentation.mjs +33 -0
- package/templates/astro/template/src/pages/404.astro +54 -0
- package/templates/astro/template/src/pages/500.astro +53 -0
- package/templates/astro/template/src/pages/[...slug].astro +15 -2
- package/templates/common/.agents/skills/authoring/SKILL.md +71 -0
- package/templates/common/.content/config/robots.txt +4 -0
- package/templates/common/.content/config/site.json.tmpl +4 -0
- package/templates/common/.content/pages/404/_index.json +31 -0
- package/templates/common/.content/pages/error/_index.json +31 -0
- package/templates/common/.content/schemas/site.schema.json +9 -0
- package/templates/common/starter-content/.content/config/robots.txt +2 -0
- package/templates/common/starter-content/.content/config/site.json +4 -0
- package/templates/common/starter-content/.content/pages/404/_index.json +31 -0
- package/templates/common/starter-content/.content/pages/about/_index.json +3 -6
- package/templates/common/starter-content/.content/pages/error/_index.json +31 -0
- package/templates/common/starter-content/.content/pages/home/_index.json +10 -19
- package/templates/common/starter-content/.content/schemas/site.schema.json +9 -0
- package/templates/nextjs/template/package.json.tmpl +1 -1
- package/templates/nextjs/template/src/aci/error-page-instrumentation.d.mts +4 -0
- package/templates/nextjs/template/src/aci/error-page-instrumentation.mjs +38 -0
- package/templates/nextjs/template/src/app/error.tsx +18 -0
- package/templates/nextjs/template/src/app/not-found.tsx +13 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { normalizeRoute } from './routes.js';
|
|
2
|
+
// The kind/page_type vocabulary is defined once (quorum SIMPLE-005) in the
|
|
3
|
+
// client-safe leaf module ./error-page-kind.ts — THIS module reaches
|
|
4
|
+
// node:path via routes.js, so 'use client' components must import the leaf
|
|
5
|
+
// directly. Re-exported here as the canonical server-side import site.
|
|
6
|
+
export { errorPageType, errorPageInstrumentationTag, defaultPushErrorPageView, serializeErrorPageInstrumentation, } from './error-page-kind.js';
|
|
7
|
+
const ERROR_PAGE_KINDS = ['notFound', 'catchAll'];
|
|
8
|
+
/**
|
|
9
|
+
* The one D-5 posture config surface, shared by every stack: the same
|
|
10
|
+
* variable the Go content build and publish materializer read.
|
|
11
|
+
*/
|
|
12
|
+
export const ERROR_PAGES_POSTURE_ENV = 'ACI_ERROR_PAGES_POSTURE';
|
|
13
|
+
/**
|
|
14
|
+
* Normalizes a raw posture value exactly like the Go side
|
|
15
|
+
* (`contentbuild.normalizeErrorPagePosture`): absent/empty means warn, and an
|
|
16
|
+
* unknown value throws so a config typo cannot silently disable enforcement.
|
|
17
|
+
*/
|
|
18
|
+
function normalizeErrorPagePosture(raw) {
|
|
19
|
+
const value = raw ?? '';
|
|
20
|
+
if (value === '' || value === 'warn')
|
|
21
|
+
return 'warn';
|
|
22
|
+
if (value === 'enforce')
|
|
23
|
+
return 'enforce';
|
|
24
|
+
throw new Error(`unknown errorPages validation posture ${JSON.stringify(value)} (want "warn" or "enforce")`);
|
|
25
|
+
}
|
|
26
|
+
/** Reads the D-5 posture from ACI_ERROR_PAGES_POSTURE; throws on unknown values (fail closed). */
|
|
27
|
+
export function errorPagePostureFromEnv(env = process.env) {
|
|
28
|
+
return normalizeErrorPagePosture(env[ERROR_PAGES_POSTURE_ENV]);
|
|
29
|
+
}
|
|
30
|
+
/** Mirrors the site.schema.json binding pattern: "/<segment>/". */
|
|
31
|
+
export const ERROR_PAGE_ROUTE_PATTERN = /^\/(?:[A-Za-z0-9._~-]+\/)+$/;
|
|
32
|
+
const ERROR_PAGES_ABSENT_WARNING = 'site config has no errorPages bindings; the shipped branded-neutral default error page will be served for 404/5xx — author /404/ and /error/ pages and bind them via site.json#errorPages before the enforce posture lands (authorable-error-pages REQ-4/D-5)';
|
|
33
|
+
/**
|
|
34
|
+
* Validates the site's error-page bindings under the given posture.
|
|
35
|
+
* Never throws for validation outcomes: callers fail their build closed on a
|
|
36
|
+
* non-empty `errors` array.
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveErrorPageBindings(siteConfig, options = {}) {
|
|
39
|
+
const warnings = [];
|
|
40
|
+
const errors = [];
|
|
41
|
+
// Unknown posture values fail closed (quorum BUG-002, Go parity): a typo
|
|
42
|
+
// in ACI_ERROR_PAGES_POSTURE must never silently mean "warn".
|
|
43
|
+
let posture;
|
|
44
|
+
try {
|
|
45
|
+
posture = normalizeErrorPagePosture(options.posture);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
49
|
+
return { bindings: null, defaulted: false, warnings, errors };
|
|
50
|
+
}
|
|
51
|
+
const raw = siteConfig?.errorPages;
|
|
52
|
+
if (raw == null) {
|
|
53
|
+
if (posture === 'enforce') {
|
|
54
|
+
errors.push('site config requires errorPages.notFound and errorPages.catchAll bindings (posture "enforce")');
|
|
55
|
+
return { bindings: null, defaulted: false, warnings, errors };
|
|
56
|
+
}
|
|
57
|
+
warnings.push(ERROR_PAGES_ABSENT_WARNING);
|
|
58
|
+
return { bindings: null, defaulted: true, warnings, errors };
|
|
59
|
+
}
|
|
60
|
+
// Present bindings are strict in every posture (REQ-5).
|
|
61
|
+
for (const kind of ERROR_PAGE_KINDS) {
|
|
62
|
+
const route = raw[kind];
|
|
63
|
+
if (typeof route !== 'string' || route === '') {
|
|
64
|
+
errors.push(`errorPages.${kind} binding is required`);
|
|
65
|
+
}
|
|
66
|
+
else if (!ERROR_PAGE_ROUTE_PATTERN.test(route)) {
|
|
67
|
+
errors.push(`errorPages.${kind} "${route}" is not a route path (want "/<segment>/")`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (errors.length > 0) {
|
|
71
|
+
return { bindings: null, defaulted: false, warnings, errors };
|
|
72
|
+
}
|
|
73
|
+
const bindings = { notFound: raw.notFound, catchAll: raw.catchAll };
|
|
74
|
+
if (options.source) {
|
|
75
|
+
const routes = await options.source.listRoutes();
|
|
76
|
+
const byPath = new Map(routes.map((entry) => [entry.path, entry]));
|
|
77
|
+
for (const kind of ERROR_PAGE_KINDS) {
|
|
78
|
+
const route = bindings[kind];
|
|
79
|
+
const normalized = normalizeRoute(route);
|
|
80
|
+
const entry = byPath.get(normalized);
|
|
81
|
+
if (!entry) {
|
|
82
|
+
errors.push(`errorPages.${kind} "${route}" does not resolve to any page`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (entry.variants && Object.keys(entry.variants).length > 0) {
|
|
86
|
+
errors.push(`errorPages.${kind} "${route}" must not have audience or experiment overlays`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const page = await options.source
|
|
90
|
+
.getPage(normalized)
|
|
91
|
+
.catch(() => null);
|
|
92
|
+
if (!page || page.status !== 'published') {
|
|
93
|
+
errors.push(`errorPages.${kind} "${route}" must be a published page (status "${page?.status ?? 'unknown'}")`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (errors.length > 0) {
|
|
98
|
+
return { bindings: null, defaulted: false, warnings, errors };
|
|
99
|
+
}
|
|
100
|
+
return { bindings, defaulted: false, warnings, errors };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolves one error-page kind under the warn posture (default). Unlike the
|
|
104
|
+
* build-time `resolveErrorPageBindings`, this NEVER fails: error surfaces
|
|
105
|
+
* must not crash and must not re-enter the failing system (ANTI-2), so a
|
|
106
|
+
* broken binding — which the build already failed closed on (REQ-5) —
|
|
107
|
+
* degrades to the shipped default with the problem surfaced in `warnings`.
|
|
108
|
+
*/
|
|
109
|
+
export async function resolveErrorPage(kind, siteConfig, options = {}) {
|
|
110
|
+
const resolution = await resolveErrorPageBindings(siteConfig, options);
|
|
111
|
+
if (resolution.errors.length > 0 || resolution.defaulted || !resolution.bindings) {
|
|
112
|
+
return {
|
|
113
|
+
kind,
|
|
114
|
+
route: null,
|
|
115
|
+
defaulted: true,
|
|
116
|
+
warnings: [...resolution.warnings, ...resolution.errors]
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { kind, route: resolution.bindings[kind], defaulted: false, warnings: resolution.warnings };
|
|
120
|
+
}
|
package/dist/content/index.d.ts
CHANGED
package/dist/content/index.js
CHANGED
package/dist/content/types.d.ts
CHANGED
|
@@ -85,9 +85,21 @@ export interface KernelSiteConfig {
|
|
|
85
85
|
description?: string;
|
|
86
86
|
siteName?: string;
|
|
87
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* Error-page route bindings (authorable-error-pages REQ-1). Both values
|
|
90
|
+
* are public route paths (`/<segment>/`) resolving to ordinary published
|
|
91
|
+
* pages. Optional under the warn-then-enforce posture (REQ-4/D-5):
|
|
92
|
+
* absence is legal and produces a loud build/publish warning plus the
|
|
93
|
+
* shipped branded-neutral default page.
|
|
94
|
+
*/
|
|
95
|
+
errorPages?: SiteErrorPages;
|
|
88
96
|
/** Personalization collect-tag config (design/04); absent = disabled. */
|
|
89
97
|
personalization?: SitePersonalizationConfig;
|
|
90
98
|
}
|
|
99
|
+
export interface SiteErrorPages {
|
|
100
|
+
notFound: string;
|
|
101
|
+
catchAll: string;
|
|
102
|
+
}
|
|
91
103
|
export interface KernelRouteMetadata {
|
|
92
104
|
title: string;
|
|
93
105
|
description: string;
|
|
@@ -7,7 +7,7 @@ export function selectPersonalizationVariant(request, route, claims, experiments
|
|
|
7
7
|
export function resolvePersonalizationDecision(request, route, claims, experiments, identity) {
|
|
8
8
|
if (!route)
|
|
9
9
|
return { variant: null, experiment: null };
|
|
10
|
-
let variant = selectRuleVariant(request, route, claims);
|
|
10
|
+
let variant = selectRuleVariant(request, route, claims, experiments);
|
|
11
11
|
let experiment = null;
|
|
12
12
|
if (identity && identity.usable && route.variants && experiments) {
|
|
13
13
|
const candidates = Object.values(experiments)
|
|
@@ -30,13 +30,17 @@ export function resolvePersonalizationDecision(request, route, claims, experimen
|
|
|
30
30
|
}
|
|
31
31
|
return { variant: variant || null, experiment };
|
|
32
32
|
}
|
|
33
|
-
function selectRuleVariant(request, route, claims) {
|
|
33
|
+
function selectRuleVariant(request, route, claims, experiments) {
|
|
34
34
|
if (!route.variants)
|
|
35
35
|
return null;
|
|
36
36
|
const matches = [];
|
|
37
37
|
for (const key in route.variants) {
|
|
38
38
|
const variant = route.variants[key];
|
|
39
39
|
const values = variant.variantValues || variantValuesFromKey(key);
|
|
40
|
+
const experimentID = String(values.experiment || '').trim();
|
|
41
|
+
if (experimentID && (!experiments || !experiments[experimentID]
|
|
42
|
+
|| experiments[experimentID].status !== 'running'))
|
|
43
|
+
continue;
|
|
40
44
|
if (!requestDimensionsMatch(request, values))
|
|
41
45
|
continue;
|
|
42
46
|
const membershipMatch = (values.audience && claims && (claims.aud || []).includes(values.audience))
|
package/dist/next/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readdirSync, existsSync } from 'fs';
|
|
|
2
2
|
import { join, relative } from 'path';
|
|
3
3
|
import { createRequire } from 'module';
|
|
4
4
|
import { createContentWatchWebpackPlugin } from './content-watch.js';
|
|
5
|
+
import { buildErrorSeatPayload } from './error-seat-payload.js';
|
|
5
6
|
const require = createRequire(import.meta.url);
|
|
6
7
|
export function withAci(nextConfig = {}, options = {}) {
|
|
7
8
|
if (process.env.TURBOPACK) {
|
|
@@ -11,6 +12,21 @@ export function withAci(nextConfig = {}, options = {}) {
|
|
|
11
12
|
'`next dev` / `next build` without --turbopack.');
|
|
12
13
|
}
|
|
13
14
|
const publicPaths = collectPublicPaths(join(process.cwd(), 'public'));
|
|
15
|
+
// Authorable error pages (D-9): the Next error seat is client-only, so the
|
|
16
|
+
// bound catchAll page's content is serialized here — while next.config is
|
|
17
|
+
// evaluated — and injected into the bundle via DefinePlugin below. The D-5
|
|
18
|
+
// posture rides ACI_ERROR_PAGES_POSTURE (the same variable the Go stack
|
|
19
|
+
// reads): under warn (default), absent bindings/content and non-REQ-5
|
|
20
|
+
// serialization failures warn and serialize the shipped default (REQ-4);
|
|
21
|
+
// under enforce they fail the build closed, and unknown posture values
|
|
22
|
+
// fail closed too. Present-but-broken bindings fail closed always (REQ-5).
|
|
23
|
+
const errorSeat = buildErrorSeatPayload(undefined, process.env.ACI_ERROR_PAGES_POSTURE);
|
|
24
|
+
if (errorSeat.errors.length > 0) {
|
|
25
|
+
throw new Error(`ACI errorPages bindings are invalid (site.json#errorPages):\n- ${errorSeat.errors.join('\n- ')}`);
|
|
26
|
+
}
|
|
27
|
+
for (const warning of errorSeat.warnings) {
|
|
28
|
+
console.warn(`\n[aci error-pages] WARNING: ${warning}\n`);
|
|
29
|
+
}
|
|
14
30
|
const passthroughRoutes = normalizePassthroughRoutes(options.passthroughRoutes ?? []);
|
|
15
31
|
const assetPrefix = options.assetPrefix || '/.gradial-dam';
|
|
16
32
|
const assetOrigin = options.assetOrigin || '';
|
|
@@ -82,6 +98,7 @@ export function withAci(nextConfig = {}, options = {}) {
|
|
|
82
98
|
'__ACI_PUBLIC_PATHS__': JSON.stringify(publicPaths),
|
|
83
99
|
'__ACI_PASSTHROUGH_ROUTES__': JSON.stringify(passthroughRoutes),
|
|
84
100
|
'__ACI_ASSET_PREFIX__': JSON.stringify(assetPrefix),
|
|
101
|
+
'__ACI_ERROR_PAGE__': JSON.stringify(errorSeat.payload),
|
|
85
102
|
}));
|
|
86
103
|
return typeof userWebpack === 'function'
|
|
87
104
|
? userWebpack(config, context)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ErrorPageKind, type ErrorPagePush } from '../content/error-page-kind.js';
|
|
2
|
+
/**
|
|
3
|
+
* Error-surface analytics MECHANISM (authorable-error-pages REQ-12 / D-14 /
|
|
4
|
+
* D-15): calls the site's push function exactly ONCE per error view with
|
|
5
|
+
* the D-14 page_type. The push BODY is site policy — the seats forward the
|
|
6
|
+
* tenant-owned scaffolded module's `pushErrorPageView` via `push`; without
|
|
7
|
+
* it, `defaultPushErrorPageView` (the scaffolded default's twin) runs.
|
|
8
|
+
*
|
|
9
|
+
* Delivery MUST be a 'use client' useEffect: the not-found seat is
|
|
10
|
+
* client-rendered from the RSC flight payload, where React skips execution
|
|
11
|
+
* of inline script elements (spike A learning 3). The SDK error seats mount
|
|
12
|
+
* this only with error render context; ordinary routes — including direct
|
|
13
|
+
* GET /404/ and /error/ previews — never render it (REQ-12).
|
|
14
|
+
*
|
|
15
|
+
* Renders nothing. The seats (createNotFound, ErrorSeat) render the inert
|
|
16
|
+
* `data-aci-error-page` marker themselves — server-side where possible, so
|
|
17
|
+
* gates can assert it in fetched documents (a client component's output
|
|
18
|
+
* never appears in the serialized flight text).
|
|
19
|
+
*/
|
|
20
|
+
export declare function ErrorPageInstrumentation({ kind, push, }: {
|
|
21
|
+
kind: ErrorPageKind;
|
|
22
|
+
push?: ErrorPagePush;
|
|
23
|
+
}): null;
|
|
@@ -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
|
+
}
|