@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.
- package/README.md +47 -2
- package/bin/aci +0 -0
- package/bin/aci.js +157 -0
- package/dist/assets/index.d.ts +3 -0
- package/dist/assets/index.js +3 -0
- package/dist/astro/index.d.ts +24 -2
- package/dist/astro/index.js +42 -4
- package/dist/block-ref.d.ts +34 -0
- package/dist/block-ref.js +34 -0
- package/dist/content/index.d.ts +0 -3
- package/dist/content/index.js +0 -3
- package/dist/content/provider.d.ts +32 -8
- package/dist/content/provider.js +26 -16
- package/dist/content/routes.d.ts +6 -12
- package/dist/content/routes.js +9 -55
- package/dist/content/validation.js +1 -1
- package/dist/define-component.d.ts +1 -0
- package/dist/define-component.js +1 -0
- package/dist/define-layout.d.ts +1 -0
- package/dist/define-layout.js +6 -1
- package/dist/dev/browser.d.ts +1 -1
- package/dist/dev/browser.js +1 -1
- package/dist/dev/index.d.ts +9 -3
- package/dist/dev/index.js +74 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/next/asset-route.d.ts +9 -0
- package/dist/next/asset-route.js +15 -0
- package/dist/next/config.d.ts +6 -0
- package/dist/next/config.js +25 -0
- package/dist/next/dev-refresh.js +4 -4
- package/dist/next/edge-config.d.ts +1 -0
- package/dist/next/edge-config.js +92 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/middleware.js +4 -6
- package/dist/next/server.d.ts +9 -24
- package/dist/next/server.js +100 -152
- package/dist/providers/file.d.ts +11 -17
- package/dist/providers/file.js +44 -78
- package/dist/providers/s3.d.ts +26 -0
- package/dist/providers/s3.js +174 -0
- package/dist/react/GradialMedia.d.ts +24 -0
- package/dist/react/GradialMedia.js +31 -0
- package/dist/react/GradialPicture.d.ts +14 -0
- package/dist/react/GradialPicture.js +30 -0
- package/dist/react/GradialVideoPlayer.d.ts +13 -0
- package/dist/react/GradialVideoPlayer.js +28 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.js +3 -0
- package/dist/sveltekit/index.d.ts +18 -2
- package/dist/sveltekit/index.js +40 -4
- package/dist/testing/index.d.ts +14 -12
- package/dist/testing/index.js +41 -28
- package/dist/types/component.d.ts +24 -2
- package/dist/types/config.d.ts +4 -0
- package/dist/types/image.d.ts +51 -0
- package/dist/types/image.js +58 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.js +3 -0
- package/dist/types/layout.d.ts +12 -0
- package/dist/types/media.d.ts +69 -0
- package/dist/types/media.js +86 -0
- package/dist/types/video.d.ts +70 -0
- package/dist/types/video.js +22 -0
- package/package.json +30 -2
- package/src/cli/compile-registry.mjs +303 -0
- package/src/cli/validate-content.mjs +489 -0
package/dist/next/server.js
CHANGED
|
@@ -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
|
|
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
|
|
21
|
-
|
|
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
|
|
31
|
-
|
|
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
|
|
42
|
-
const
|
|
43
|
-
return
|
|
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
|
|
48
|
-
|
|
23
|
+
provider.getPage(route),
|
|
24
|
+
provider.getSiteConfig(),
|
|
49
25
|
]);
|
|
50
26
|
return {
|
|
51
27
|
route,
|
|
52
|
-
domain: siteConfig.domain || 'www.
|
|
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.
|
|
73
|
-
return process.env.
|
|
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('
|
|
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.
|
|
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
|
|
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
|
|
155
|
-
return joinKey(config.keyPrefix || process.env.
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
}
|
package/dist/providers/file.d.ts
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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>;
|
package/dist/providers/file.js
CHANGED
|
@@ -1,91 +1,57 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return
|
|
18
|
-
}
|
|
19
|
-
async
|
|
20
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
throw error;
|
|
33
|
+
throw new PageNotFoundError(route, error);
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
|
-
async
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
81
|
-
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw new FragmentNotFoundError(id, error);
|
|
82
47
|
}
|
|
83
48
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|