@gradial/aci 0.1.51-ssr.3 → 0.1.51-ssr.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Agentic Content Infrastructure SDK
2
2
 
3
- `@gradial/aci` `0.1.51-ssr.3` is a pre-1.0 public-surface reset for the ACI
3
+ `@gradial/aci` `0.1.51-ssr.5` 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.51-ssr.3 zod@^4
11
+ npm install @gradial/aci@0.1.51-ssr.5 zod@^4
12
12
  ```
13
13
 
14
14
  ## Root Import
@@ -78,13 +78,13 @@ host bundles select and serve immutable release artifacts.
78
78
  The first RC scaffold path is Next:
79
79
 
80
80
  ```bash
81
- npx @gradial/aci@0.1.51-ssr.3 init my-site --template next-starter
81
+ npx @gradial/aci@0.1.51-ssr.5 init my-site --template next-starter
82
82
  ```
83
83
 
84
84
  List available templates:
85
85
 
86
86
  ```bash
87
- npx @gradial/aci@0.1.51-ssr.3 init --list
87
+ npx @gradial/aci@0.1.51-ssr.5 init --list
88
88
  ```
89
89
 
90
90
  ## Personalization: The One-Tag Install
@@ -15,11 +15,16 @@ import { ErrorPageMarker } from './error-page-marker.js';
15
15
  export async function resolveErrorPage(kind) {
16
16
  let siteConfig = null;
17
17
  const warnings = [];
18
- try {
19
- siteConfig = await getSiteConfig();
20
- }
21
- catch (error) {
22
- warnings.push(`site config unavailable at the error seat: ${String(error)}`);
18
+ // A deployed SSR release has no local `.aci/compiled` tree. The error
19
+ // seat must remain independent of the renderer that just failed, so serve
20
+ // the shipped default rather than opening FileContentProvider here.
21
+ if (process.env.HOSTING_DELIVERY_MODE !== 'ssr') {
22
+ try {
23
+ siteConfig = await getSiteConfig();
24
+ }
25
+ catch (error) {
26
+ warnings.push(`site config unavailable at the error seat: ${String(error)}`);
27
+ }
23
28
  }
24
29
  const resolved = await resolveErrorPageForSite(kind, siteConfig);
25
30
  return { ...resolved, warnings: [...warnings, ...resolved.warnings], siteConfig };
package/dist/next/page.js CHANGED
@@ -43,18 +43,24 @@ export function createPage(registry, options) {
43
43
  const dynamic = 'force-dynamic';
44
44
  async function generateMetadata({ params }) {
45
45
  const resolved = await resolveRoute(params);
46
- const input = await getRenderInput(resolved.route, resolved.context);
47
- const meta = routeMetadataForContent(input.siteConfig, input.page);
48
- return {
49
- title: meta.title,
50
- description: meta.description,
51
- alternates: meta.canonical ? { canonical: meta.canonical } : undefined,
52
- openGraph: {
46
+ try {
47
+ const input = await getRenderInput(resolved.route, resolved.context);
48
+ const meta = routeMetadataForContent(input.siteConfig, input.page);
49
+ return {
53
50
  title: meta.title,
54
51
  description: meta.description,
55
- siteName: meta.siteName,
56
- },
57
- };
52
+ alternates: meta.canonical ? { canonical: meta.canonical } : undefined,
53
+ openGraph: {
54
+ title: meta.title,
55
+ description: meta.description,
56
+ siteName: meta.siteName,
57
+ },
58
+ };
59
+ }
60
+ catch (error) {
61
+ logRenderFailure('metadata', resolved, error);
62
+ throw error;
63
+ }
58
64
  }
59
65
  async function CatchAllPage({ params }) {
60
66
  const resolved = await resolveRoute(params);
@@ -63,8 +69,13 @@ export function createPage(registry, options) {
63
69
  const input = await getRenderInput(resolved.route, context);
64
70
  if (!input.page)
65
71
  notFound();
66
- const layout = await getLayout(input.page.layout, context);
67
- const resolvedSlots = await resolveSlots(layout, input.page.regions || {}, (id) => getFragment(id, context));
72
+ const provider = input.contentProvider;
73
+ const layout = provider
74
+ ? await provider.getLayout(input.page.layout)
75
+ : await getLayout(input.page.layout, context);
76
+ const resolvedSlots = await resolveSlots(layout, input.page.regions || {}, (id) => provider
77
+ ? provider.getFragment(id)
78
+ : getFragment(id, context));
68
79
  const renderedSlots = await Promise.all(layout.slots.map(async (slot) => {
69
80
  const blocks = resolvedSlots[slot.name];
70
81
  if (!blocks?.length)
@@ -81,6 +92,7 @@ export function createPage(registry, options) {
81
92
  catch (error) {
82
93
  if (error instanceof PageNotFoundError)
83
94
  notFound();
95
+ logRenderFailure('page', resolved, error);
84
96
  throw error;
85
97
  }
86
98
  }
@@ -95,7 +107,9 @@ export function createPage(registry, options) {
95
107
  */
96
108
  async function resolveRoute(params) {
97
109
  const resolved = await params;
98
- const slug = resolved.slug ?? [];
110
+ const slug = decodePathSegments(resolved.slug ?? []);
111
+ if (!slug)
112
+ return { route: '/' };
99
113
  if (slug.length >= 2 && slug[0] === '__r') {
100
114
  const releaseId = slug[1];
101
115
  const selected = slug.slice(2);
@@ -129,3 +143,22 @@ function portableReleaseId(value) {
129
143
  function portablePathPart(value) {
130
144
  return value.length > 0 && value !== '.' && value !== '..' && !value.includes('/');
131
145
  }
146
+ function decodePathSegments(segments) {
147
+ try {
148
+ return segments.map((segment) => decodeURIComponent(segment));
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ function logRenderFailure(phase, resolved, error) {
155
+ const detail = error instanceof Error ? error.message : String(error);
156
+ console.error(JSON.stringify({
157
+ message: 'aci.ssr.render_failed',
158
+ phase,
159
+ releaseId: resolved.context?.releaseId,
160
+ pageRef: resolved.context?.pageRef,
161
+ route: resolved.route,
162
+ error: detail.slice(0, 512),
163
+ }));
164
+ }
@@ -1,4 +1,4 @@
1
- import { FragmentNotFoundError, LayoutNotFoundError, PageNotFoundError } from '../content/provider.js';
1
+ import { FragmentNotFoundError, LayoutNotFoundError, PageNotFoundError, type ContentProvider } from '../content/provider.js';
2
2
  import { type ReleaseObjectReader } from '../providers/object.js';
3
3
  export { FragmentNotFoundError, PageNotFoundError, LayoutNotFoundError };
4
4
  export interface ServerRenderInput<TPage, TSite extends SiteConfig = SiteConfig> {
@@ -8,6 +8,8 @@ export interface ServerRenderInput<TPage, TSite extends SiteConfig = SiteConfig>
8
8
  siteConfig: TSite;
9
9
  page: TPage;
10
10
  fragments?: Record<string, unknown>;
11
+ /** Internal request-local provider reused by createPage for chrome reads. */
12
+ contentProvider?: ContentProvider;
11
13
  }
12
14
  export interface SiteConfig {
13
15
  domain?: string;
@@ -65,6 +65,7 @@ export async function getRenderInput(route = '/', context = {}) {
65
65
  locale: siteConfig.defaultLocale || 'en-us',
66
66
  siteConfig,
67
67
  page,
68
+ contentProvider: provider,
68
69
  };
69
70
  }
70
71
  // getCompilerRenderInput is the offline page-compiler seam used by the Next
@@ -1,4 +1,4 @@
1
- import { type CompiledManifest, type ContentProvider, type RouteEntry } from '../content/provider.js';
1
+ import { type ContentProvider, type RouteEntry } from '../content/provider.js';
2
2
  export interface ReleaseObjectReader {
3
3
  readReleaseObject(releaseId: string, path: string): Promise<Uint8Array>;
4
4
  }
@@ -7,7 +7,7 @@ export interface ObjectContentProviderOptions {
7
7
  pageRef: string;
8
8
  reader: ReleaseObjectReader;
9
9
  }
10
- /** Reads one immutable compiled release through a host-injected object reader. */
10
+ /** Reads one exact immutable SSR page release through a host-injected reader. */
11
11
  export declare class ObjectContentProvider implements ContentProvider {
12
12
  #private;
13
13
  readonly releaseId: string;
@@ -19,6 +19,7 @@ export declare class ObjectContentProvider implements ContentProvider {
19
19
  getPage<T = unknown>(route: string): Promise<T>;
20
20
  getFragment<T = unknown>(id: string): Promise<T>;
21
21
  getLayout<T = unknown>(name: string): Promise<T>;
22
- manifest(): Promise<CompiledManifest>;
22
+ private envelope;
23
+ private readSharedJSON;
23
24
  private readJSON;
24
25
  }
@@ -1,11 +1,58 @@
1
1
  import { FragmentNotFoundError, LayoutNotFoundError, PageNotFoundError, } from '../content/provider.js';
2
2
  import { normalizeRoute } from '../content/routes.js';
3
- /** Reads one immutable compiled release through a host-injected object reader. */
3
+ const SSR_PAGE_ENVELOPE_SCHEMA = 'gradial.ssr-page.v1';
4
+ const SHARED_CACHE_MAX_ENTRIES = 128;
5
+ const SHARED_CACHE_MAX_BYTES = 16 * 1024 * 1024;
6
+ // Release objects are immutable. This bounded process-local cache needs no
7
+ // invalidation or TTL: an entry is either the exact verified bytes named by a
8
+ // release envelope or it is evicted. Pages and manifests intentionally bypass
9
+ // it so high-cardinality request objects cannot consume the cache.
10
+ class ImmutableSharedObjectCache {
11
+ #entries = new Map();
12
+ #pending = new Map();
13
+ #bytes = 0;
14
+ async read(key, load) {
15
+ const cached = this.#entries.get(key);
16
+ if (cached) {
17
+ this.#entries.delete(key);
18
+ this.#entries.set(key, cached);
19
+ return cached.bytes;
20
+ }
21
+ let pending = this.#pending.get(key);
22
+ if (!pending) {
23
+ pending = load();
24
+ this.#pending.set(key, pending);
25
+ pending.then((bytes) => this.#store(key, bytes), () => undefined)
26
+ .finally(() => this.#pending.delete(key));
27
+ }
28
+ return await pending;
29
+ }
30
+ #store(key, bytes) {
31
+ if (bytes.byteLength > SHARED_CACHE_MAX_BYTES)
32
+ return;
33
+ const existing = this.#entries.get(key);
34
+ if (existing)
35
+ this.#bytes -= existing.size;
36
+ while (this.#entries.size >= SHARED_CACHE_MAX_ENTRIES || this.#bytes + bytes.byteLength > SHARED_CACHE_MAX_BYTES) {
37
+ const oldest = this.#entries.keys().next().value;
38
+ if (oldest === undefined)
39
+ break;
40
+ const evicted = this.#entries.get(oldest);
41
+ this.#entries.delete(oldest);
42
+ if (evicted)
43
+ this.#bytes -= evicted.size;
44
+ }
45
+ this.#entries.set(key, { bytes, size: bytes.byteLength });
46
+ this.#bytes += bytes.byteLength;
47
+ }
48
+ }
49
+ const sharedObjectCache = new ImmutableSharedObjectCache();
50
+ /** Reads one exact immutable SSR page release through a host-injected reader. */
4
51
  export class ObjectContentProvider {
5
52
  releaseId;
6
53
  pageRef;
7
54
  reader;
8
- #manifest;
55
+ #envelope;
9
56
  constructor(options) {
10
57
  if (!portableReleaseId(options.releaseId))
11
58
  throw new Error('ACI release ID is invalid');
@@ -15,73 +62,113 @@ export class ObjectContentProvider {
15
62
  this.pageRef = options.pageRef;
16
63
  this.reader = options.reader;
17
64
  }
65
+ // Hosted SSR receives an exact page ref from edge routing. Enumerating a
66
+ // whole release would require manifest.json and is intentionally unavailable
67
+ // on this provider; FileContentProvider remains the manifest consumer.
18
68
  async listRoutes() {
19
- return Object.values((await this.manifest()).routes)
20
- .sort((left, right) => left.path.localeCompare(right.path));
69
+ throw new Error('ACI hosted SSR does not support route enumeration');
21
70
  }
22
71
  async getSiteConfig() {
23
- const manifest = await this.manifest();
24
- return await this.readJSON(manifest.siteConfigRef);
72
+ const envelope = await this.envelope();
73
+ return await this.readSharedJSON(envelope.render.site);
25
74
  }
26
75
  async getPage(route) {
27
- const normalized = normalizeRoute(route);
28
- const entries = Object.values((await this.manifest()).routes)
29
- .filter((entry) => normalizeRoute(entry.path) === normalized);
30
- if (entries.length !== 1)
31
- throw new PageNotFoundError(route);
32
- const entry = entries[0];
33
- const candidates = [
34
- { ref: entry.payloadRef, digest: entry.digest },
35
- ...Object.values(entry.variants ?? {}).map((variant) => ({
36
- ref: variant.payloadRef,
37
- digest: variant.digest,
38
- })),
39
- ];
40
- const exact = candidates.find((candidate) => candidate.ref === this.pageRef);
41
- if (!exact)
42
- throw new PageNotFoundError(route, new Error(`selected page ref is not declared: ${this.pageRef}`));
43
- try {
44
- return await this.readJSON(exact.ref, exact.digest);
45
- }
46
- catch (error) {
47
- throw new PageNotFoundError(route, error);
76
+ const envelope = await this.envelope();
77
+ if (normalizeRoute(route) !== normalizeRoute(envelope.route)) {
78
+ throw new PageNotFoundError(route, new Error(`selected page route is ${envelope.route}`));
48
79
  }
80
+ return envelope.page;
49
81
  }
50
82
  async getFragment(id) {
51
- const entry = (await this.manifest()).fragments[id];
52
- if (!entry)
53
- throw new FragmentNotFoundError(id);
83
+ const envelope = await this.envelope();
84
+ const ref = envelope.render.fragments?.[id];
85
+ if (!ref)
86
+ throw new FragmentNotFoundError(id, new Error('fragment is not bound by the selected page'));
54
87
  try {
55
- return await this.readJSON(entry.payloadRef, entry.digest);
88
+ return await this.readSharedJSON(ref);
56
89
  }
57
90
  catch (error) {
58
91
  throw new FragmentNotFoundError(id, error);
59
92
  }
60
93
  }
61
94
  async getLayout(name) {
62
- const entry = (await this.manifest()).layouts?.[name];
63
- if (!entry)
64
- throw new LayoutNotFoundError(name);
95
+ const envelope = await this.envelope();
96
+ if (name !== envelope.render.layout.name) {
97
+ throw new LayoutNotFoundError(name, new Error(`selected page layout is ${envelope.render.layout.name}`));
98
+ }
65
99
  try {
66
- return await this.readJSON(`layouts/${name}/index.json`);
100
+ return await this.readSharedJSON(envelope.render.layout);
67
101
  }
68
102
  catch (error) {
69
103
  throw new LayoutNotFoundError(name, error);
70
104
  }
71
105
  }
72
- async manifest() {
73
- this.#manifest ??= this.readJSON('manifest.json');
74
- return await this.#manifest;
106
+ async envelope() {
107
+ this.#envelope ??= this.readJSON(this.pageRef).then((value) => validateEnvelope(value, this.pageRef));
108
+ return await this.#envelope;
109
+ }
110
+ async readSharedJSON(ref) {
111
+ const path = cleanObjectPath(ref.payloadRef);
112
+ if (!path || path !== ref.payloadRef)
113
+ throw new Error(`ACI shared object path is invalid: ${ref.payloadRef}`);
114
+ const key = JSON.stringify([this.releaseId, path, ref.digest]);
115
+ const bytes = await sharedObjectCache.read(key, async () => {
116
+ const value = await this.reader.readReleaseObject(this.releaseId, path);
117
+ await verifyDigest(value, ref.digest, path);
118
+ return value;
119
+ });
120
+ return parseJSON(bytes, path);
75
121
  }
76
- async readJSON(path, digest) {
122
+ async readJSON(path) {
77
123
  const clean = cleanObjectPath(path);
78
124
  if (!clean || clean !== path)
79
125
  throw new Error(`ACI release object path is invalid: ${path}`);
80
- const bytes = await this.reader.readReleaseObject(this.releaseId, clean);
81
- if (digest)
82
- await verifyDigest(bytes, digest, clean);
126
+ return parseJSON(await this.reader.readReleaseObject(this.releaseId, clean), clean);
127
+ }
128
+ }
129
+ function validateEnvelope(value, pageRef) {
130
+ if (!isObject(value) || value.schemaVersion !== SSR_PAGE_ENVELOPE_SCHEMA || typeof value.route !== 'string' || !isObject(value.render)) {
131
+ throw new Error(`ACI SSR page envelope is invalid: ${pageRef}`);
132
+ }
133
+ const render = value.render;
134
+ if (!validRef(render.site) || !isObject(render.layout) || typeof render.layout.name !== 'string' || !validRef(render.layout)) {
135
+ throw new Error(`ACI SSR page envelope bindings are invalid: ${pageRef}`);
136
+ }
137
+ const fragments = {};
138
+ if (render.fragments !== undefined) {
139
+ if (!isObject(render.fragments))
140
+ throw new Error(`ACI SSR page fragments are invalid: ${pageRef}`);
141
+ for (const [id, ref] of Object.entries(render.fragments)) {
142
+ if (!id || !validRef(ref))
143
+ throw new Error(`ACI SSR page fragment binding is invalid: ${pageRef}`);
144
+ fragments[id] = ref;
145
+ }
146
+ }
147
+ return {
148
+ schemaVersion: SSR_PAGE_ENVELOPE_SCHEMA,
149
+ route: value.route,
150
+ variantKey: typeof value.variantKey === 'string' ? value.variantKey : '',
151
+ page: value.page,
152
+ render: {
153
+ site: render.site,
154
+ layout: { name: render.layout.name, payloadRef: render.layout.payloadRef, digest: render.layout.digest },
155
+ fragments,
156
+ },
157
+ };
158
+ }
159
+ function validRef(value) {
160
+ return isObject(value) && typeof value.payloadRef === 'string' && cleanObjectPath(value.payloadRef) === value.payloadRef && /^sha256:[0-9a-f]{64}$/.test(String(value.digest));
161
+ }
162
+ function isObject(value) {
163
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
164
+ }
165
+ function parseJSON(bytes, path) {
166
+ try {
83
167
  return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
84
168
  }
169
+ catch (error) {
170
+ throw new Error(`ACI release object is not valid JSON: ${path}`, { cause: error });
171
+ }
85
172
  }
86
173
  async function verifyDigest(bytes, expected, path) {
87
174
  const match = /^sha256:([0-9a-f]{64})$/.exec(expected);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.51-ssr.3",
3
+ "version": "0.1.51-ssr.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/gradial/bare-metal-go.git",
@@ -146,11 +146,11 @@
146
146
  "zod": "^4.0.0"
147
147
  },
148
148
  "optionalDependencies": {
149
- "@gradial/aci-cli-darwin-arm64": "0.1.51-ssr.3",
150
- "@gradial/aci-cli-darwin-x64": "0.1.51-ssr.3",
151
- "@gradial/aci-cli-linux-arm64": "0.1.51-ssr.3",
152
- "@gradial/aci-cli-linux-x64": "0.1.51-ssr.3",
153
- "@gradial/aci-cli-win32-arm64": "0.1.51-ssr.3",
154
- "@gradial/aci-cli-win32-x64": "0.1.51-ssr.3"
149
+ "@gradial/aci-cli-darwin-arm64": "0.1.51-ssr.5",
150
+ "@gradial/aci-cli-darwin-x64": "0.1.51-ssr.5",
151
+ "@gradial/aci-cli-linux-arm64": "0.1.51-ssr.5",
152
+ "@gradial/aci-cli-linux-x64": "0.1.51-ssr.5",
153
+ "@gradial/aci-cli-win32-arm64": "0.1.51-ssr.5",
154
+ "@gradial/aci-cli-win32-x64": "0.1.51-ssr.5"
155
155
  }
156
156
  }