@gradial/aci 0.1.7 → 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 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
+ });
@@ -1,3 +1,4 @@
1
1
  export * from './provider.js';
2
2
  export * from './routes.js';
3
3
  export * from './types.js';
4
+ export * from './cache.js';
@@ -1,3 +1,4 @@
1
1
  export * from './provider.js';
2
2
  export * from './routes.js';
3
3
  export * from './types.js';
4
+ export * from './cache.js';
@@ -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.getSiteConfig(),
23
+ getCachedSiteConfig(provider),
23
24
  provider.getPage(normalized).catch((error) => {
24
25
  if (error instanceof PageNotFoundError)
25
26
  return null;
@@ -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.getSiteConfig(),
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
- return new S3ContentProvider({
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
@@ -36,23 +36,14 @@ export class FileContentProvider {
36
36
  async getFragment(id) {
37
37
  const manifest = await this.manifest();
38
38
  const entry = manifest.fragments[id];
39
- if (entry) {
40
- try {
41
- return await this.readJSON(entry.payloadRef);
42
- }
43
- catch (error) {
44
- throw new FragmentNotFoundError(id, error);
45
- }
39
+ if (!entry) {
40
+ throw new FragmentNotFoundError(id);
46
41
  }
47
- // Raw content directory stores fragments as flat files
48
- const contentRoot = process.env.ACI_CONTENT_DIR || path.join(path.resolve(process.cwd()), '.content');
49
- const rawPath = path.join(contentRoot, 'fragments', `${id}.json`);
50
42
  try {
51
- const raw = await fs.readFile(rawPath, 'utf8');
52
- return JSON.parse(raw);
43
+ return await this.readJSON(entry.payloadRef);
53
44
  }
54
- catch {
55
- throw new FragmentNotFoundError(id);
45
+ catch (error) {
46
+ throw new FragmentNotFoundError(id, error);
56
47
  }
57
48
  }
58
49
  async manifest() {
@@ -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.7",
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