@gradial/aci 0.1.0 → 0.1.2

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 (68) hide show
  1. package/README.md +47 -2
  2. package/bin/aci +0 -0
  3. package/bin/aci.js +157 -0
  4. package/dist/assets/index.d.ts +3 -0
  5. package/dist/assets/index.js +3 -0
  6. package/dist/astro/index.d.ts +24 -2
  7. package/dist/astro/index.js +42 -4
  8. package/dist/block-ref.d.ts +34 -0
  9. package/dist/block-ref.js +34 -0
  10. package/dist/content/index.d.ts +0 -3
  11. package/dist/content/index.js +0 -3
  12. package/dist/content/provider.d.ts +32 -8
  13. package/dist/content/provider.js +26 -16
  14. package/dist/content/routes.d.ts +6 -12
  15. package/dist/content/routes.js +9 -55
  16. package/dist/content/validation.js +1 -1
  17. package/dist/define-component.d.ts +1 -0
  18. package/dist/define-component.js +1 -0
  19. package/dist/define-layout.d.ts +1 -0
  20. package/dist/define-layout.js +6 -1
  21. package/dist/dev/browser.d.ts +1 -1
  22. package/dist/dev/browser.js +1 -1
  23. package/dist/dev/index.d.ts +9 -3
  24. package/dist/dev/index.js +74 -8
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +4 -0
  27. package/dist/next/asset-route.d.ts +9 -0
  28. package/dist/next/asset-route.js +15 -0
  29. package/dist/next/config.d.ts +6 -0
  30. package/dist/next/config.js +25 -0
  31. package/dist/next/dev-refresh.js +4 -4
  32. package/dist/next/edge-config.d.ts +1 -0
  33. package/dist/next/edge-config.js +92 -0
  34. package/dist/next/index.d.ts +2 -0
  35. package/dist/next/index.js +2 -0
  36. package/dist/next/middleware.js +4 -6
  37. package/dist/next/server.d.ts +9 -24
  38. package/dist/next/server.js +100 -152
  39. package/dist/providers/file.d.ts +11 -17
  40. package/dist/providers/file.js +44 -78
  41. package/dist/providers/s3.d.ts +26 -0
  42. package/dist/providers/s3.js +174 -0
  43. package/dist/react/GradialMedia.d.ts +24 -0
  44. package/dist/react/GradialMedia.js +31 -0
  45. package/dist/react/GradialPicture.d.ts +14 -0
  46. package/dist/react/GradialPicture.js +30 -0
  47. package/dist/react/GradialVideoPlayer.d.ts +13 -0
  48. package/dist/react/GradialVideoPlayer.js +28 -0
  49. package/dist/react/index.d.ts +3 -0
  50. package/dist/react/index.js +3 -0
  51. package/dist/sveltekit/index.d.ts +18 -2
  52. package/dist/sveltekit/index.js +40 -4
  53. package/dist/testing/index.d.ts +14 -12
  54. package/dist/testing/index.js +41 -28
  55. package/dist/types/component.d.ts +24 -2
  56. package/dist/types/config.d.ts +4 -0
  57. package/dist/types/image.d.ts +51 -0
  58. package/dist/types/image.js +58 -0
  59. package/dist/types/index.d.ts +3 -0
  60. package/dist/types/index.js +3 -0
  61. package/dist/types/layout.d.ts +12 -0
  62. package/dist/types/media.d.ts +69 -0
  63. package/dist/types/media.js +86 -0
  64. package/dist/types/video.d.ts +70 -0
  65. package/dist/types/video.js +22 -0
  66. package/package.json +30 -2
  67. package/src/cli/compile-registry.mjs +303 -0
  68. package/src/cli/validate-content.mjs +489 -0
@@ -1,55 +1,31 @@
1
- import crypto from 'node:crypto';
2
- import { createClient, get as getEdgeConfigItem } from '@vercel/edge-config';
3
1
  import { headers } from 'next/headers';
2
+ import { FragmentNotFoundError, PageNotFoundError, } from '../content/provider.js';
3
+ import { S3ContentProvider } from '../providers/s3.js';
4
+ import { getUncachedEdgeConfigValue } from './edge-config.js';
4
5
  const RELEASE_HEADER = 'x-gradial-release-id';
5
- export class PageNotFoundError extends Error {
6
- constructor(route, cause) {
7
- super(`Gradial page fragment not found for route ${route}`);
8
- this.name = 'PageNotFoundError';
9
- this.cause = cause;
10
- }
11
- }
12
- export class FragmentNotFoundError extends Error {
13
- constructor(name, cause) {
14
- super(`Gradial fragment not found: ${name}`);
15
- this.name = 'FragmentNotFoundError';
16
- this.cause = cause;
17
- }
18
- }
6
+ export { FragmentNotFoundError, PageNotFoundError };
19
7
  export async function getPage(route = '', config = {}) {
20
- const releaseId = await resolveReleaseId(config);
21
- const key = releaseKey(config, releaseId, 'fragments', 'page', pageRouteSegment(route), 'index.json');
22
- try {
23
- return await getS3JSON(key, config);
24
- }
25
- catch (error) {
26
- throw new PageNotFoundError(route, error);
27
- }
8
+ const provider = await resolveProvider(config);
9
+ return await provider.getPage(route);
28
10
  }
29
11
  export async function getFragment(name, config = {}) {
30
- const releaseId = await resolveReleaseId(config);
31
- const cleanName = cleanPath(name).replace(/\.[^.]+$/, '');
32
- const key = releaseKey(config, releaseId, 'fragments', 'global', `${cleanName}.json`);
33
- try {
34
- return await getS3JSON(key, config);
35
- }
36
- catch (error) {
37
- throw new FragmentNotFoundError(name, error);
38
- }
12
+ const provider = await resolveProvider(config);
13
+ return await provider.getFragment(name);
39
14
  }
40
15
  export async function getRoutes(config = {}) {
41
- const releaseId = await resolveReleaseId(config);
42
- const manifest = await getS3JSON(releaseKey(config, releaseId, 'route-manifest.json'), config);
43
- return Object.keys(manifest.routes || {}).sort();
16
+ const provider = await resolveProvider(config);
17
+ const routes = await provider.listRoutes();
18
+ return routes.map((route) => route.path).sort();
44
19
  }
45
20
  export async function getRenderInput(route = '/', config = {}) {
21
+ const provider = await resolveProvider(config);
46
22
  const [page, siteConfig] = await Promise.all([
47
- getPage(route, config),
48
- getFragment('site', config),
23
+ provider.getPage(route),
24
+ provider.getSiteConfig(),
49
25
  ]);
50
26
  return {
51
27
  route,
52
- domain: siteConfig.domain || 'www.baremetal.local',
28
+ domain: siteConfig.domain || 'www.aci.local',
53
29
  locale: siteConfig.defaultLocale || 'en-us',
54
30
  siteConfig,
55
31
  page,
@@ -63,18 +39,91 @@ export async function routeFromNextParams(params) {
63
39
  return '/';
64
40
  return `/${resolved.slug.join('/')}/`;
65
41
  }
42
+ export async function generateGradialStaticParams() {
43
+ const { FileContentProvider } = await import('../providers/file.js');
44
+ const provider = new FileContentProvider();
45
+ const routes = await provider.listRoutes();
46
+ return routes.map((entry) => ({
47
+ slug: entry.path === '/' ? undefined : entry.path.replace(/^\/|\/$/g, '').split('/'),
48
+ }));
49
+ }
50
+ export class ReleaseAssetNotFoundError extends Error {
51
+ constructor(releaseId, assetPath, cause) {
52
+ super(`ACI release asset not found: ${releaseId}/${assetPath}`);
53
+ this.name = 'ReleaseAssetNotFoundError';
54
+ this.cause = cause;
55
+ }
56
+ }
57
+ export async function getReleaseAssetResponse(releaseId, assetPath, config = {}) {
58
+ const clean = cleanAssetPath(assetPath);
59
+ if (!releaseId || !clean) {
60
+ throw new ReleaseAssetNotFoundError(releaseId, assetPath);
61
+ }
62
+ const provider = new S3ContentProvider({
63
+ bucket: config.bucket,
64
+ keyPrefix: releaseKey(config, releaseId),
65
+ region: config.region,
66
+ accessKeyId: config.accessKeyId,
67
+ secretAccessKey: config.secretAccessKey,
68
+ sessionToken: config.sessionToken,
69
+ endpoint: config.endpoint,
70
+ });
71
+ const key = joinKey(provider.config.keyPrefix, 'assets', clean);
72
+ const res = await provider.fetchRaw(key);
73
+ if (!res.ok || !res.body) {
74
+ throw new ReleaseAssetNotFoundError(releaseId, clean, await safeResponseText(res));
75
+ }
76
+ const headers = new Headers();
77
+ copyHeader(res.headers, headers, 'content-type');
78
+ copyHeader(res.headers, headers, 'content-length');
79
+ copyHeader(res.headers, headers, 'etag');
80
+ copyHeader(res.headers, headers, 'last-modified');
81
+ headers.set('cache-control', 'public, max-age=31536000, immutable');
82
+ return new Response(res.body, { status: 200, headers });
83
+ }
84
+ function cleanAssetPath(value) {
85
+ const clean = value.replace(/^\/+|\/+$/g, '').replace(/\/+/g, '/');
86
+ if (!clean || clean.split('/').some((s) => s === '..' || s === '.'))
87
+ return '';
88
+ return clean;
89
+ }
90
+ function copyHeader(from, to, name) {
91
+ const value = from.get(name);
92
+ if (value)
93
+ to.set(name, value);
94
+ }
95
+ async function safeResponseText(res) {
96
+ try {
97
+ return await res.text();
98
+ }
99
+ catch {
100
+ return '';
101
+ }
102
+ }
66
103
  export async function resolveReleaseId(config = {}) {
67
104
  if (config.releaseId)
68
105
  return config.releaseId;
69
106
  const headerReleaseId = await releaseIdFromHeaders();
70
107
  if (headerReleaseId)
71
108
  return headerReleaseId;
72
- if (process.env.GRADIAL_RELEASE_ID)
73
- return process.env.GRADIAL_RELEASE_ID;
109
+ if (process.env.ACI_RELEASE_ID)
110
+ return process.env.ACI_RELEASE_ID;
74
111
  const activeReleaseId = await activeReleaseFromEdgeConfig(config);
75
112
  if (activeReleaseId)
76
113
  return activeReleaseId;
77
- throw new Error('Gradial release ID is not available from headers, GRADIAL_RELEASE_ID, or Edge Config');
114
+ throw new Error('ACI release ID is not available from headers, ACI_RELEASE_ID, or Edge Config');
115
+ }
116
+ async function resolveProvider(config) {
117
+ const releaseId = await resolveReleaseId(config);
118
+ return new S3ContentProvider({
119
+ bucket: config.bucket,
120
+ keyPrefix: releaseKey(config, releaseId),
121
+ region: config.region,
122
+ accessKeyId: config.accessKeyId,
123
+ secretAccessKey: config.secretAccessKey,
124
+ sessionToken: config.sessionToken,
125
+ endpoint: config.endpoint,
126
+ });
78
127
  }
79
128
  async function releaseIdFromHeaders() {
80
129
  try {
@@ -87,7 +136,7 @@ async function releaseIdFromHeaders() {
87
136
  }
88
137
  async function activeReleaseFromEdgeConfig(config) {
89
138
  const edgeConfig = config.edgeConfig || process.env.EDGE_CONFIG || '';
90
- const siteId = config.siteId || process.env.GRADIAL_SITE_ID || '';
139
+ const siteId = config.siteId || process.env.ACI_SITE_ID || '';
91
140
  if (!edgeConfig || !siteId)
92
141
  return '';
93
142
  const active = await activeReleasePointer(edgeConfig, siteId);
@@ -116,106 +165,10 @@ async function getEdgeConfigJSON(edgeConfig, key) {
116
165
  return value;
117
166
  }
118
167
  async function getEdgeConfigValue(edgeConfig, key) {
119
- return edgeConfig === process.env.EDGE_CONFIG
120
- ? await getEdgeConfigItem(key)
121
- : await createClient(edgeConfig).get(key);
122
- }
123
- async function getS3JSON(key, config) {
124
- const resolved = resolveS3Config(config);
125
- const url = s3ObjectURL(resolved, key);
126
- const signedHeaders = signedS3Headers('GET', url, resolved);
127
- const res = await fetch(url, {
128
- method: 'GET',
129
- headers: signedHeaders,
130
- cache: 'no-store',
131
- });
132
- if (!res.ok) {
133
- throw new Error(`S3 GET ${key} failed with status ${res.status}: ${await res.text()}`);
134
- }
135
- return (await res.json());
136
- }
137
- function resolveS3Config(config) {
138
- const resolved = {
139
- siteId: config.siteId || process.env.GRADIAL_SITE_ID || '',
140
- bucket: config.bucket || process.env.GRADIAL_S3_BUCKET || '',
141
- keyPrefix: config.keyPrefix || process.env.GRADIAL_S3_KEY_PREFIX || '',
142
- region: config.region || process.env.AWS_REGION || 'us-east-1',
143
- accessKeyId: config.accessKeyId || process.env.AWS_ACCESS_KEY_ID || '',
144
- secretAccessKey: config.secretAccessKey || process.env.AWS_SECRET_ACCESS_KEY || '',
145
- sessionToken: config.sessionToken || process.env.AWS_SESSION_TOKEN || '',
146
- endpoint: trimSlash(config.endpoint || process.env.GRADIAL_S3_ENDPOINT || ''),
147
- };
148
- for (const key of ['bucket', 'accessKeyId', 'secretAccessKey']) {
149
- if (!resolved[key])
150
- throw new Error(`Gradial S3 config missing ${key}`);
151
- }
152
- return resolved;
168
+ return getUncachedEdgeConfigValue(edgeConfig, key);
153
169
  }
154
- function releaseKey(config, releaseId, ...parts) {
155
- return joinKey(config.keyPrefix || process.env.GRADIAL_S3_KEY_PREFIX || '', 'releases', releaseId, ...parts);
156
- }
157
- function s3ObjectURL(config, key) {
158
- const encodedKey = encodeKey(key);
159
- if (config.endpoint) {
160
- return new URL(`${config.endpoint}/${encodeURIComponent(config.bucket)}${encodedKey}`);
161
- }
162
- return new URL(`https://${config.bucket}.s3.${config.region}.amazonaws.com${encodedKey}`);
163
- }
164
- function signedS3Headers(method, url, config) {
165
- const now = new Date();
166
- const amzDate = amzTimestamp(now);
167
- const dateStamp = amzDate.slice(0, 8);
168
- const payloadHash = sha256Hex('');
169
- const headers = new Headers({
170
- host: url.host,
171
- 'x-amz-content-sha256': payloadHash,
172
- 'x-amz-date': amzDate,
173
- });
174
- if (config.sessionToken)
175
- headers.set('x-amz-security-token', config.sessionToken);
176
- const signedHeaderNames = Array.from(headers.keys()).sort();
177
- const canonicalHeaders = signedHeaderNames.map((key) => `${key}:${headers.get(key)?.trim()}\n`).join('');
178
- const canonicalRequest = [
179
- method,
180
- url.pathname,
181
- url.searchParams.toString(),
182
- canonicalHeaders,
183
- signedHeaderNames.join(';'),
184
- payloadHash,
185
- ].join('\n');
186
- const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request`;
187
- const stringToSign = [
188
- 'AWS4-HMAC-SHA256',
189
- amzDate,
190
- credentialScope,
191
- sha256Hex(canonicalRequest),
192
- ].join('\n');
193
- const signingKey = awsSigningKey(config.secretAccessKey, dateStamp, config.region, 's3');
194
- const signature = hmacHex(signingKey, stringToSign);
195
- headers.set('authorization', `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaderNames.join(';')}, Signature=${signature}`);
196
- return headers;
197
- }
198
- function awsSigningKey(secret, dateStamp, region, service) {
199
- const kDate = hmac(Buffer.from(`AWS4${secret}`, 'utf8'), dateStamp);
200
- const kRegion = hmac(kDate, region);
201
- const kService = hmac(kRegion, service);
202
- return hmac(kService, 'aws4_request');
203
- }
204
- function amzTimestamp(date) {
205
- return date.toISOString().replace(/[:-]|\.\d{3}/g, '');
206
- }
207
- function hmac(key, value) {
208
- return crypto.createHmac('sha256', key).update(value, 'utf8').digest();
209
- }
210
- function hmacHex(key, value) {
211
- return crypto.createHmac('sha256', key).update(value, 'utf8').digest('hex');
212
- }
213
- function sha256Hex(value) {
214
- return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
215
- }
216
- function pageRouteSegment(route) {
217
- const clean = cleanPath(route);
218
- return clean === '' ? 'index' : clean;
170
+ function releaseKey(config, releaseId) {
171
+ return joinKey(config.keyPrefix || process.env.ACI_S3_KEY_PREFIX || '', 'releases', releaseId);
219
172
  }
220
173
  function activeReleaseKey(siteId) {
221
174
  return `activeRelease_${edgeKeySegment(siteId)}`;
@@ -255,15 +208,10 @@ function clampRelease(activeReleaseId, activeSequence, compatibility) {
255
208
  function edgeKeySegment(value) {
256
209
  return value.replace(/[^A-Za-z0-9_-]/g, '_');
257
210
  }
258
- function cleanPath(value) {
259
- return value.replace(/^\/+|\/+$/g, '').replace(/\/+/g, '/');
260
- }
261
211
  function joinKey(...parts) {
262
- return parts.map(cleanPath).filter(Boolean).join('/');
263
- }
264
- function encodeKey(key) {
265
- return `/${key.split('/').map(encodeURIComponent).join('/')}`;
266
- }
267
- function trimSlash(value) {
268
- return value.replace(/\/+$/, '');
212
+ return parts
213
+ .flatMap((part) => part.split('/'))
214
+ .map((part) => part.trim())
215
+ .filter(Boolean)
216
+ .join('/');
269
217
  }
@@ -1,18 +1,12 @@
1
- import { type ContentPathOptions } from '../content/routes.js';
2
- import { type ContentProvider, type RenderInputOptions } from '../content/provider.js';
3
- import type { KernelPage, KernelRouteMetadata, KernelSiteConfig, RenderInput } from '../content/types.js';
4
- export interface FileContentProviderOptions extends ContentPathOptions {
5
- parseSiteConfig?: (value: unknown, source: string) => KernelSiteConfig;
6
- parsePage?: (value: unknown, source: string) => KernelPage;
1
+ import { type CompiledManifest, type ContentProvider, type RouteEntry } from '../content/provider.js';
2
+ export declare class FileContentProvider implements ContentProvider {
3
+ #private;
4
+ readonly root: string;
5
+ constructor(root?: string);
6
+ listRoutes(): Promise<RouteEntry[]>;
7
+ getSiteConfig<T = unknown>(): Promise<T>;
8
+ getPage<T = unknown>(route: string): Promise<T>;
9
+ getFragment<T = unknown>(id: string): Promise<T>;
10
+ manifest(): Promise<CompiledManifest>;
11
+ private readJSON;
7
12
  }
8
- export declare class FileContentProvider<TPage extends KernelPage = KernelPage, TSiteConfig extends KernelSiteConfig = KernelSiteConfig> implements ContentProvider<TPage, TSiteConfig> {
9
- readonly options: FileContentProviderOptions;
10
- constructor(options?: FileContentProviderOptions);
11
- loadSiteConfig(): Promise<TSiteConfig>;
12
- loadPage(route?: string): Promise<TPage | null>;
13
- listRoutes(): Promise<string[]>;
14
- listPublishedRoutes(): Promise<string[]>;
15
- resolveRouteMetadata(route?: string): Promise<KernelRouteMetadata>;
16
- loadRenderInput(route?: string, options?: RenderInputOptions): Promise<RenderInput<TPage, TSiteConfig>>;
17
- }
18
- export declare function createFileContentProvider<TPage extends KernelPage = KernelPage, TSiteConfig extends KernelSiteConfig = KernelSiteConfig>(options?: FileContentProviderOptions): FileContentProvider<TPage, TSiteConfig>;
@@ -1,91 +1,57 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { localeRoot, normalizeRoute, routeFromRelativePagePath, routeToPagePath, siteRoot } from '../content/routes.js';
4
- import { loadRenderInput, routeMetadataForContent } from '../content/provider.js';
5
- import { parseKernelPage, parseKernelSiteConfig } from '../content/validation.js';
3
+ import { FragmentNotFoundError, PageNotFoundError } from '../content/provider.js';
4
+ import { normalizeRoute } from '../content/routes.js';
6
5
  export class FileContentProvider {
7
- options;
8
- constructor(options = {}) {
9
- this.options = options;
6
+ root;
7
+ #manifest;
8
+ constructor(root) {
9
+ const resolved = root
10
+ || process.env.ACI_CONTENT_ROOT
11
+ || '.aci/compiled';
12
+ this.root = path.resolve(process.cwd(), resolved);
10
13
  }
11
- async loadSiteConfig() {
12
- const source = path.join(siteRoot(this.options), 'site.json');
13
- const parsed = await readJSON(source);
14
- const value = this.options.parseSiteConfig
15
- ? this.options.parseSiteConfig(parsed, source)
16
- : parseKernelSiteConfig(parsed, source);
17
- return value;
18
- }
19
- async loadPage(route = '/') {
20
- const source = routeToPagePath(route, this.options);
14
+ async listRoutes() {
15
+ const manifest = await this.manifest();
16
+ return Object.values(manifest.routes).sort((a, b) => a.path.localeCompare(b.path));
17
+ }
18
+ async getSiteConfig() {
19
+ const manifest = await this.manifest();
20
+ return await this.readJSON(manifest.siteConfigRef);
21
+ }
22
+ async getPage(route) {
23
+ const manifest = await this.manifest();
24
+ const normalized = normalizeRoute(route);
25
+ const entry = Object.values(manifest.routes).find((candidate) => normalizeRoute(candidate.path) === normalized);
26
+ if (!entry) {
27
+ throw new PageNotFoundError(route);
28
+ }
21
29
  try {
22
- const parsed = await readJSON(source);
23
- const value = this.options.parsePage
24
- ? this.options.parsePage(parsed, source)
25
- : parseKernelPage(parsed, source);
26
- return value;
30
+ return await this.readJSON(entry.payloadRef);
27
31
  }
28
32
  catch (error) {
29
- if (isNotFound(error)) {
30
- return null;
31
- }
32
- throw error;
33
+ throw new PageNotFoundError(route, error);
33
34
  }
34
35
  }
35
- async listRoutes() {
36
- const files = await walk(localeRoot(this.options));
37
- return files
38
- .filter((entry) => entry.endsWith('/_index.json'))
39
- .map((entry) => routeFromRelativePagePath(entry))
40
- .sort((left, right) => left.localeCompare(right));
41
- }
42
- async listPublishedRoutes() {
43
- const routes = await this.listRoutes();
44
- const pages = await Promise.all(routes.map(async (route) => ({
45
- route,
46
- page: await this.loadPage(route)
47
- })));
48
- return pages
49
- .filter((entry) => entry.page?.status === 'published')
50
- .map((entry) => entry.route);
51
- }
52
- async resolveRouteMetadata(route = '/') {
53
- const normalizedRoute = normalizeRoute(route);
54
- const [siteConfig, page] = await Promise.all([
55
- this.loadSiteConfig(),
56
- this.loadPage(normalizedRoute)
57
- ]);
58
- return routeMetadataForContent(siteConfig, page);
59
- }
60
- async loadRenderInput(route = '/', options = {}) {
61
- return loadRenderInput(this, route, options);
62
- }
63
- }
64
- export function createFileContentProvider(options = {}) {
65
- return new FileContentProvider(options);
66
- }
67
- async function readJSON(source) {
68
- const raw = await fs.readFile(source, 'utf8');
69
- return JSON.parse(raw);
70
- }
71
- async function walk(directory, prefix = '') {
72
- const entries = await fs.readdir(directory, { withFileTypes: true });
73
- const files = [];
74
- for (const entry of entries) {
75
- const relative = prefix ? path.join(prefix, entry.name) : entry.name;
76
- const absolute = path.join(directory, entry.name);
77
- if (entry.isDirectory()) {
78
- files.push(...await walk(absolute, relative));
36
+ async getFragment(id) {
37
+ const manifest = await this.manifest();
38
+ const entry = manifest.fragments[id];
39
+ if (!entry) {
40
+ throw new FragmentNotFoundError(id);
41
+ }
42
+ try {
43
+ return await this.readJSON(entry.payloadRef);
79
44
  }
80
- else if (entry.isFile()) {
81
- files.push(relative);
45
+ catch (error) {
46
+ throw new FragmentNotFoundError(id, error);
82
47
  }
83
48
  }
84
- return files;
85
- }
86
- function isNotFound(error) {
87
- return typeof error === 'object'
88
- && error !== null
89
- && 'code' in error
90
- && error.code === 'ENOENT';
49
+ async manifest() {
50
+ this.#manifest ??= this.readJSON('manifest.json');
51
+ return await this.#manifest;
52
+ }
53
+ async readJSON(key) {
54
+ const raw = await fs.readFile(path.join(this.root, key), 'utf8');
55
+ return JSON.parse(raw);
56
+ }
91
57
  }
@@ -0,0 +1,26 @@
1
+ import { type CompiledManifest, type ContentProvider, type RouteEntry } from '../content/provider.js';
2
+ export interface S3ContentProviderConfig {
3
+ bucket?: string;
4
+ keyPrefix?: string;
5
+ region?: string;
6
+ accessKeyId?: string;
7
+ secretAccessKey?: string;
8
+ sessionToken?: string;
9
+ endpoint?: string;
10
+ }
11
+ type ResolvedS3Config = Required<S3ContentProviderConfig>;
12
+ export declare class S3ContentProvider implements ContentProvider {
13
+ #private;
14
+ readonly config: ResolvedS3Config;
15
+ constructor(config?: S3ContentProviderConfig);
16
+ listRoutes(): Promise<RouteEntry[]>;
17
+ getSiteConfig<T = unknown>(): Promise<T>;
18
+ getPage<T = unknown>(route: string): Promise<T>;
19
+ getFragment<T = unknown>(id: string): Promise<T>;
20
+ manifest(): Promise<CompiledManifest>;
21
+ fetchRaw(key: string): Promise<Response>;
22
+ private getJSON;
23
+ }
24
+ export declare function getS3JSON<T>(key: string, config: ResolvedS3Config): Promise<T>;
25
+ export declare function getS3Raw(key: string, config: ResolvedS3Config): Promise<Response>;
26
+ export {};
@@ -0,0 +1,174 @@
1
+ import crypto from 'node:crypto';
2
+ import { FragmentNotFoundError, PageNotFoundError } from '../content/provider.js';
3
+ import { normalizeRoute } from '../content/routes.js';
4
+ // ---------------------------------------------------------------------------
5
+ // S3 content provider — reads compiled content files from S3
6
+ // ---------------------------------------------------------------------------
7
+ export class S3ContentProvider {
8
+ config;
9
+ #manifest;
10
+ constructor(config = {}) {
11
+ this.config = resolveS3Config(config);
12
+ }
13
+ async listRoutes() {
14
+ const manifest = await this.manifest();
15
+ return Object.values(manifest.routes).sort((a, b) => a.path.localeCompare(b.path));
16
+ }
17
+ async getSiteConfig() {
18
+ const manifest = await this.manifest();
19
+ return await this.getJSON(manifest.siteConfigRef);
20
+ }
21
+ async getPage(route) {
22
+ const manifest = await this.manifest();
23
+ const normalized = normalizeRoute(route);
24
+ const entry = Object.values(manifest.routes).find((candidate) => normalizeRoute(candidate.path) === normalized);
25
+ if (!entry) {
26
+ throw new PageNotFoundError(route);
27
+ }
28
+ try {
29
+ return await this.getJSON(entry.payloadRef);
30
+ }
31
+ catch (error) {
32
+ throw new PageNotFoundError(route, error);
33
+ }
34
+ }
35
+ async getFragment(id) {
36
+ const manifest = await this.manifest();
37
+ const entry = manifest.fragments[id];
38
+ if (!entry) {
39
+ throw new FragmentNotFoundError(id);
40
+ }
41
+ try {
42
+ return await this.getJSON(entry.payloadRef);
43
+ }
44
+ catch (error) {
45
+ throw new FragmentNotFoundError(id, error);
46
+ }
47
+ }
48
+ async manifest() {
49
+ this.#manifest ??= this.getJSON('manifest.json');
50
+ return await this.#manifest;
51
+ }
52
+ async fetchRaw(key) {
53
+ return await getS3Raw(key, this.config);
54
+ }
55
+ async getJSON(key) {
56
+ return await getS3JSON(joinKey(this.config.keyPrefix, key), this.config);
57
+ }
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // S3 fetch with AWS SigV4 signing
61
+ // ---------------------------------------------------------------------------
62
+ export async function getS3JSON(key, config) {
63
+ const url = s3ObjectURL(config, key);
64
+ const signedHeaders = signedS3Headers('GET', url, config);
65
+ const res = await fetch(url, {
66
+ method: 'GET',
67
+ headers: signedHeaders,
68
+ cache: 'no-store'
69
+ });
70
+ if (!res.ok) {
71
+ throw new Error(`S3 GET ${key} failed with status ${res.status}: ${await res.text()}`);
72
+ }
73
+ return (await res.json());
74
+ }
75
+ export async function getS3Raw(key, config) {
76
+ const url = s3ObjectURL(config, key);
77
+ const signedHeaders = signedS3Headers('GET', url, config);
78
+ return await fetch(url, {
79
+ method: 'GET',
80
+ headers: signedHeaders,
81
+ cache: 'no-store'
82
+ });
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // Internal helpers
86
+ // ---------------------------------------------------------------------------
87
+ function resolveS3Config(config) {
88
+ const resolved = {
89
+ bucket: config.bucket || process.env.ACI_S3_BUCKET || '',
90
+ keyPrefix: config.keyPrefix || process.env.ACI_S3_KEY_PREFIX || '',
91
+ region: config.region || process.env.AWS_REGION || 'us-east-1',
92
+ accessKeyId: config.accessKeyId || process.env.AWS_ACCESS_KEY_ID || '',
93
+ secretAccessKey: config.secretAccessKey || process.env.AWS_SECRET_ACCESS_KEY || '',
94
+ sessionToken: config.sessionToken || process.env.AWS_SESSION_TOKEN || '',
95
+ endpoint: trimSlash(config.endpoint || process.env.ACI_S3_ENDPOINT || '')
96
+ };
97
+ for (const key of ['bucket', 'accessKeyId', 'secretAccessKey']) {
98
+ if (!resolved[key])
99
+ throw new Error(`S3 config missing ${key}`);
100
+ }
101
+ return resolved;
102
+ }
103
+ function s3ObjectURL(config, key) {
104
+ const encodedKey = encodeKey(key);
105
+ if (config.endpoint) {
106
+ return new URL(`${config.endpoint}/${encodeURIComponent(config.bucket)}${encodedKey}`);
107
+ }
108
+ return new URL(`https://${config.bucket}.s3.${config.region}.amazonaws.com${encodedKey}`);
109
+ }
110
+ function signedS3Headers(method, url, config) {
111
+ const now = new Date();
112
+ const amzDate = amzTimestamp(now);
113
+ const dateStamp = amzDate.slice(0, 8);
114
+ const payloadHash = sha256Hex('');
115
+ const headers = new Headers({
116
+ host: url.host,
117
+ 'x-amz-content-sha256': payloadHash,
118
+ 'x-amz-date': amzDate
119
+ });
120
+ if (config.sessionToken)
121
+ headers.set('x-amz-security-token', config.sessionToken);
122
+ const signedHeaderNames = Array.from(headers.keys()).sort();
123
+ const canonicalHeaders = signedHeaderNames.map((k) => `${k}:${headers.get(k)?.trim()}\n`).join('');
124
+ const canonicalRequest = [
125
+ method,
126
+ url.pathname,
127
+ url.searchParams.toString(),
128
+ canonicalHeaders,
129
+ signedHeaderNames.join(';'),
130
+ payloadHash
131
+ ].join('\n');
132
+ const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request`;
133
+ const stringToSign = [
134
+ 'AWS4-HMAC-SHA256',
135
+ amzDate,
136
+ credentialScope,
137
+ sha256Hex(canonicalRequest)
138
+ ].join('\n');
139
+ const signingKey = awsSigningKey(config.secretAccessKey, dateStamp, config.region, 's3');
140
+ const signature = hmacHex(signingKey, stringToSign);
141
+ headers.set('authorization', `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaderNames.join(';')}, Signature=${signature}`);
142
+ return headers;
143
+ }
144
+ function awsSigningKey(secret, dateStamp, region, service) {
145
+ const kDate = hmac(Buffer.from(`AWS4${secret}`, 'utf8'), dateStamp);
146
+ const kRegion = hmac(kDate, region);
147
+ const kService = hmac(kRegion, service);
148
+ return hmac(kService, 'aws4_request');
149
+ }
150
+ function amzTimestamp(date) {
151
+ return date.toISOString().replace(/[:-]|\.\d{3}/g, '');
152
+ }
153
+ function hmac(key, value) {
154
+ return crypto.createHmac('sha256', key).update(value, 'utf8').digest();
155
+ }
156
+ function hmacHex(key, value) {
157
+ return crypto.createHmac('sha256', key).update(value, 'utf8').digest('hex');
158
+ }
159
+ function sha256Hex(value) {
160
+ return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
161
+ }
162
+ function trimSlash(value) {
163
+ return value.replace(/\/+$/, '');
164
+ }
165
+ function encodeKey(key) {
166
+ return `/${key.split('/').map(encodeURIComponent).join('/')}`;
167
+ }
168
+ function joinKey(...parts) {
169
+ return parts
170
+ .flatMap((part) => part.split('/'))
171
+ .map((part) => part.trim())
172
+ .filter(Boolean)
173
+ .join('/');
174
+ }