@gradial/aci 0.1.6 → 0.1.8
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/bin/aci +0 -0
- package/dist/content/cache.d.ts +25 -0
- package/dist/content/cache.js +83 -0
- package/dist/content/cache.test.d.ts +1 -0
- package/dist/content/cache.test.js +86 -0
- package/dist/content/index.d.ts +1 -0
- package/dist/content/index.js +1 -0
- package/dist/content/provider.js +2 -1
- package/dist/next/server.js +16 -2
- package/dist/providers/s3.js +3 -0
- package/package.json +7 -8
- package/src/cli/validate-content.mjs +0 -0
package/bin/aci
ADDED
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ContentProvider, CompiledManifest } from './provider.js';
|
|
2
|
+
export declare function getCacheStats(): {
|
|
3
|
+
manifest: {
|
|
4
|
+
hits: number;
|
|
5
|
+
misses: number;
|
|
6
|
+
};
|
|
7
|
+
siteConfig: {
|
|
8
|
+
hits: number;
|
|
9
|
+
misses: number;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
export declare function resetCacheStats(): void;
|
|
13
|
+
/**
|
|
14
|
+
* Get manifest with caching. Use this instead of calling provider.manifest() directly.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getCachedManifest(provider: ContentProvider): Promise<CompiledManifest>;
|
|
17
|
+
/**
|
|
18
|
+
* Get site config with caching. Use this instead of calling provider.getSiteConfig() directly.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getCachedSiteConfig<T>(provider: ContentProvider): Promise<T>;
|
|
21
|
+
/**
|
|
22
|
+
* Clear cached data for a specific provider.
|
|
23
|
+
* Useful for testing or manual cache invalidation.
|
|
24
|
+
*/
|
|
25
|
+
export declare function clearProviderCache(provider: ContentProvider): void;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic content caching layer.
|
|
3
|
+
*
|
|
4
|
+
* Caches shared resources (manifest, siteConfig) across requests
|
|
5
|
+
* to reduce S3 fetches on warm Lambda/serverless containers.
|
|
6
|
+
*
|
|
7
|
+
* Keyed by provider instance for automatic invalidation when
|
|
8
|
+
* provider config changes (e.g., new releaseId).
|
|
9
|
+
*/
|
|
10
|
+
// Cross-request cache - survives serverless warm starts
|
|
11
|
+
const manifestCache = new WeakMap();
|
|
12
|
+
const siteConfigCache = new WeakMap();
|
|
13
|
+
// Cache hit/miss stats for observability
|
|
14
|
+
let manifestHits = 0;
|
|
15
|
+
let manifestMisses = 0;
|
|
16
|
+
let siteConfigHits = 0;
|
|
17
|
+
let siteConfigMisses = 0;
|
|
18
|
+
export function getCacheStats() {
|
|
19
|
+
return {
|
|
20
|
+
manifest: { hits: manifestHits, misses: manifestMisses },
|
|
21
|
+
siteConfig: { hits: siteConfigHits, misses: siteConfigMisses },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function resetCacheStats() {
|
|
25
|
+
manifestHits = 0;
|
|
26
|
+
manifestMisses = 0;
|
|
27
|
+
siteConfigHits = 0;
|
|
28
|
+
siteConfigMisses = 0;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Get manifest with caching. Use this instead of calling provider.manifest() directly.
|
|
32
|
+
*/
|
|
33
|
+
export function getCachedManifest(provider) {
|
|
34
|
+
const cached = manifestCache.get(provider);
|
|
35
|
+
if (cached) {
|
|
36
|
+
manifestHits++;
|
|
37
|
+
console.log('[ACI Cache] manifest HIT - serving from cache');
|
|
38
|
+
return cached;
|
|
39
|
+
}
|
|
40
|
+
manifestMisses++;
|
|
41
|
+
console.log('[ACI Cache] manifest MISS - fetching from provider');
|
|
42
|
+
// Only S3ContentProvider has manifest(), FileContentProvider doesn't
|
|
43
|
+
if (!('manifest' in provider) || typeof provider.manifest !== 'function') {
|
|
44
|
+
throw new Error('Provider does not support manifest()');
|
|
45
|
+
}
|
|
46
|
+
const promise = provider.manifest().catch((error) => {
|
|
47
|
+
console.error('[ACI Cache] manifest fetch FAILED:', error.message);
|
|
48
|
+
// Clear cache on error to allow retry on next request
|
|
49
|
+
manifestCache.delete(provider);
|
|
50
|
+
throw error;
|
|
51
|
+
});
|
|
52
|
+
manifestCache.set(provider, promise);
|
|
53
|
+
return promise;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Get site config with caching. Use this instead of calling provider.getSiteConfig() directly.
|
|
57
|
+
*/
|
|
58
|
+
export function getCachedSiteConfig(provider) {
|
|
59
|
+
const cached = siteConfigCache.get(provider);
|
|
60
|
+
if (cached) {
|
|
61
|
+
siteConfigHits++;
|
|
62
|
+
console.log('[ACI Cache] siteConfig HIT - serving from cache');
|
|
63
|
+
return cached;
|
|
64
|
+
}
|
|
65
|
+
siteConfigMisses++;
|
|
66
|
+
console.log('[ACI Cache] siteConfig MISS - fetching from provider');
|
|
67
|
+
const promise = provider.getSiteConfig().catch((error) => {
|
|
68
|
+
console.error('[ACI Cache] siteConfig fetch FAILED:', error.message);
|
|
69
|
+
// Clear cache on error to allow retry on next request
|
|
70
|
+
siteConfigCache.delete(provider);
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
73
|
+
siteConfigCache.set(provider, promise);
|
|
74
|
+
return promise;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Clear cached data for a specific provider.
|
|
78
|
+
* Useful for testing or manual cache invalidation.
|
|
79
|
+
*/
|
|
80
|
+
export function clearProviderCache(provider) {
|
|
81
|
+
manifestCache.delete(provider);
|
|
82
|
+
siteConfigCache.delete(provider);
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from '@jest/globals';
|
|
2
|
+
import { getCachedSiteConfig, getCachedManifest, clearProviderCache, getCacheStats, resetCacheStats } from './cache.js';
|
|
3
|
+
describe('Content Cache', () => {
|
|
4
|
+
let mockProvider;
|
|
5
|
+
let getSiteConfigCallCount = 0;
|
|
6
|
+
let manifestCallCount = 0;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
getSiteConfigCallCount = 0;
|
|
9
|
+
manifestCallCount = 0;
|
|
10
|
+
resetCacheStats();
|
|
11
|
+
mockProvider = {
|
|
12
|
+
getSiteConfig: async () => {
|
|
13
|
+
getSiteConfigCallCount++;
|
|
14
|
+
return { title: 'Test Site' };
|
|
15
|
+
},
|
|
16
|
+
manifest: async () => {
|
|
17
|
+
manifestCallCount++;
|
|
18
|
+
return {
|
|
19
|
+
routes: {},
|
|
20
|
+
fragments: {},
|
|
21
|
+
siteConfigRef: 'site.json'
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
getPage: async () => ({}),
|
|
25
|
+
getFragment: async () => ({}),
|
|
26
|
+
listRoutes: async () => []
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
it('should cache siteConfig across multiple calls', async () => {
|
|
30
|
+
const result1 = await getCachedSiteConfig(mockProvider);
|
|
31
|
+
const result2 = await getCachedSiteConfig(mockProvider);
|
|
32
|
+
expect(getSiteConfigCallCount).toBe(1); // Only called once
|
|
33
|
+
expect(result1).toEqual(result2);
|
|
34
|
+
const stats = getCacheStats();
|
|
35
|
+
expect(stats.siteConfig.hits).toBe(1);
|
|
36
|
+
expect(stats.siteConfig.misses).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
it('should cache manifest across multiple calls', async () => {
|
|
39
|
+
const result1 = await getCachedManifest(mockProvider);
|
|
40
|
+
const result2 = await getCachedManifest(mockProvider);
|
|
41
|
+
expect(manifestCallCount).toBe(1); // Only called once
|
|
42
|
+
expect(result1).toEqual(result2);
|
|
43
|
+
const stats = getCacheStats();
|
|
44
|
+
expect(stats.manifest.hits).toBe(1);
|
|
45
|
+
expect(stats.manifest.misses).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
it('should clear cache for a provider', async () => {
|
|
48
|
+
await getCachedSiteConfig(mockProvider);
|
|
49
|
+
expect(getSiteConfigCallCount).toBe(1);
|
|
50
|
+
clearProviderCache(mockProvider);
|
|
51
|
+
await getCachedSiteConfig(mockProvider);
|
|
52
|
+
expect(getSiteConfigCallCount).toBe(2); // Called again after clear
|
|
53
|
+
const stats = getCacheStats();
|
|
54
|
+
expect(stats.siteConfig.misses).toBe(2);
|
|
55
|
+
});
|
|
56
|
+
it('should not cache rejected promises', async () => {
|
|
57
|
+
const errorProvider = {
|
|
58
|
+
getSiteConfig: async () => {
|
|
59
|
+
getSiteConfigCallCount++;
|
|
60
|
+
throw new Error('S3 timeout');
|
|
61
|
+
},
|
|
62
|
+
getPage: async () => ({}),
|
|
63
|
+
getFragment: async () => ({}),
|
|
64
|
+
listRoutes: async () => []
|
|
65
|
+
};
|
|
66
|
+
await expect(getCachedSiteConfig(errorProvider)).rejects.toThrow('S3 timeout');
|
|
67
|
+
expect(getSiteConfigCallCount).toBe(1);
|
|
68
|
+
// Second call should retry (not cached)
|
|
69
|
+
await expect(getCachedSiteConfig(errorProvider)).rejects.toThrow('S3 timeout');
|
|
70
|
+
expect(getSiteConfigCallCount).toBe(2);
|
|
71
|
+
});
|
|
72
|
+
it('should cache separately per provider instance', async () => {
|
|
73
|
+
const provider2 = {
|
|
74
|
+
getSiteConfig: async () => ({ title: 'Site 2' }),
|
|
75
|
+
getPage: async () => ({}),
|
|
76
|
+
getFragment: async () => ({}),
|
|
77
|
+
listRoutes: async () => []
|
|
78
|
+
};
|
|
79
|
+
const result1 = await getCachedSiteConfig(mockProvider);
|
|
80
|
+
const result2 = await getCachedSiteConfig(provider2);
|
|
81
|
+
expect(result1).toEqual({ title: 'Test Site' });
|
|
82
|
+
expect(result2).toEqual({ title: 'Site 2' });
|
|
83
|
+
const stats = getCacheStats();
|
|
84
|
+
expect(stats.siteConfig.misses).toBe(2); // Both are cache misses
|
|
85
|
+
});
|
|
86
|
+
});
|
package/dist/content/index.d.ts
CHANGED
package/dist/content/index.js
CHANGED
package/dist/content/provider.js
CHANGED
|
@@ -16,10 +16,11 @@ export class FragmentNotFoundError extends Error {
|
|
|
16
16
|
this.cause = cause;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
import { getCachedSiteConfig } from './cache.js';
|
|
19
20
|
export async function loadRenderInput(provider, route = '/', options = {}) {
|
|
20
21
|
const normalized = normalizeRoute(route);
|
|
21
22
|
const [siteConfig, page] = await Promise.all([
|
|
22
|
-
provider
|
|
23
|
+
getCachedSiteConfig(provider),
|
|
23
24
|
provider.getPage(normalized).catch((error) => {
|
|
24
25
|
if (error instanceof PageNotFoundError)
|
|
25
26
|
return null;
|
package/dist/next/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { headers } from 'next/headers.js';
|
|
2
2
|
import { FragmentNotFoundError, PageNotFoundError, } from '../content/provider.js';
|
|
3
|
+
import { getCachedManifest, getCachedSiteConfig } from '../content/cache.js';
|
|
3
4
|
import { S3ContentProvider } from '../providers/s3.js';
|
|
4
5
|
import { GRADIAL_RENDER_INPUT_HEADER, getPendingRenderInput } from '../runtime/page.js';
|
|
5
6
|
import { getUncachedEdgeConfigValue } from './edge-config.js';
|
|
@@ -36,15 +37,23 @@ export async function getRoutes(config = {}) {
|
|
|
36
37
|
return routes.map((route) => route.path).sort();
|
|
37
38
|
}
|
|
38
39
|
export async function getRenderInput(route = '/', config = {}) {
|
|
40
|
+
const startTime = Date.now();
|
|
39
41
|
const runtimeInput = await getPageRuntimeRenderInput();
|
|
40
42
|
if (runtimeInput) {
|
|
41
43
|
return runtimeInput;
|
|
42
44
|
}
|
|
45
|
+
const providerStart = Date.now();
|
|
43
46
|
const provider = await resolveProviderWithFallback(config);
|
|
47
|
+
console.log(`[ACI SSR] Provider resolved in ${Date.now() - providerStart}ms`);
|
|
48
|
+
// Cache shared resources (manifest, siteConfig) but not per-page content
|
|
49
|
+
// Page-level caching is handled by CDN
|
|
50
|
+
const fetchStart = Date.now();
|
|
44
51
|
const [page, siteConfig] = await Promise.all([
|
|
45
52
|
provider.getPage(route),
|
|
46
|
-
provider
|
|
53
|
+
getCachedSiteConfig(provider),
|
|
47
54
|
]);
|
|
55
|
+
console.log(`[ACI SSR] Content fetched in ${Date.now() - fetchStart}ms`);
|
|
56
|
+
console.log(`[ACI SSR] Total getRenderInput for ${route}: ${Date.now() - startTime}ms`);
|
|
48
57
|
return {
|
|
49
58
|
route,
|
|
50
59
|
domain: siteConfig.domain || 'www.aci.local',
|
|
@@ -146,7 +155,7 @@ export async function resolveReleaseId(config = {}) {
|
|
|
146
155
|
}
|
|
147
156
|
async function resolveProvider(config) {
|
|
148
157
|
const releaseId = await resolveReleaseId(config);
|
|
149
|
-
|
|
158
|
+
const provider = new S3ContentProvider({
|
|
150
159
|
bucket: config.bucket,
|
|
151
160
|
keyPrefix: releaseKey(config, releaseId),
|
|
152
161
|
region: config.region,
|
|
@@ -155,6 +164,9 @@ async function resolveProvider(config) {
|
|
|
155
164
|
sessionToken: config.sessionToken,
|
|
156
165
|
endpoint: config.endpoint,
|
|
157
166
|
});
|
|
167
|
+
// Pre-warm the manifest with cached version
|
|
168
|
+
await getCachedManifest(provider);
|
|
169
|
+
return provider;
|
|
158
170
|
}
|
|
159
171
|
async function resolveProviderWithFallback(config) {
|
|
160
172
|
if (process.env.ACI_CONTENT_PROVIDER === 'file') {
|
|
@@ -253,3 +265,5 @@ function joinKey(...parts) {
|
|
|
253
265
|
.filter(Boolean)
|
|
254
266
|
.join('/');
|
|
255
267
|
}
|
|
268
|
+
// Note: React cache() would be redundant here since getCachedSiteConfig already
|
|
269
|
+
// returns the same Promise for the same provider instance within a request
|
package/dist/providers/s3.js
CHANGED
|
@@ -60,6 +60,7 @@ export class S3ContentProvider {
|
|
|
60
60
|
// S3 fetch with AWS SigV4 signing
|
|
61
61
|
// ---------------------------------------------------------------------------
|
|
62
62
|
export async function getS3JSON(key, config) {
|
|
63
|
+
const startTime = Date.now();
|
|
63
64
|
const url = s3ObjectURL(config, key);
|
|
64
65
|
const signedHeaders = signedS3Headers('GET', url, config);
|
|
65
66
|
const res = await fetch(url, {
|
|
@@ -67,6 +68,8 @@ export async function getS3JSON(key, config) {
|
|
|
67
68
|
headers: signedHeaders,
|
|
68
69
|
cache: 'no-store'
|
|
69
70
|
});
|
|
71
|
+
const duration = Date.now() - startTime;
|
|
72
|
+
console.log(`[ACI S3] GET ${key} - ${res.status} in ${duration}ms`);
|
|
70
73
|
if (!res.ok) {
|
|
71
74
|
throw new Error(`S3 GET ${key} failed with status ${res.status}: ${await res.text()}`);
|
|
72
75
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gradial/aci",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -106,12 +106,6 @@
|
|
|
106
106
|
"./cli/validate-content": "./src/cli/validate-content.mjs",
|
|
107
107
|
"./cli/verify-renderer": "./src/cli/verify-renderer.mjs"
|
|
108
108
|
},
|
|
109
|
-
"scripts": {
|
|
110
|
-
"build": "tsc -p tsconfig.json",
|
|
111
|
-
"build:cli": "cd ../.. && ./scripts/build-cli.sh",
|
|
112
|
-
"compile-registry": "tsx src/cli/compile-registry.mjs",
|
|
113
|
-
"prepack": "npm run build"
|
|
114
|
-
},
|
|
115
109
|
"overrides": {
|
|
116
110
|
"postcss": "^8.5.10"
|
|
117
111
|
},
|
|
@@ -141,5 +135,10 @@
|
|
|
141
135
|
"tsx": "^4.20.0",
|
|
142
136
|
"typescript": "^5.9.3",
|
|
143
137
|
"zod": "^4.0.0"
|
|
138
|
+
},
|
|
139
|
+
"scripts": {
|
|
140
|
+
"build": "tsc -p tsconfig.json",
|
|
141
|
+
"build:cli": "cd ../.. && ./scripts/build-cli.sh",
|
|
142
|
+
"compile-registry": "tsx src/cli/compile-registry.mjs"
|
|
144
143
|
}
|
|
145
|
-
}
|
|
144
|
+
}
|
|
File without changes
|