@sequenceholdings/artifact-studio 0.2.6 → 0.3.1

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 (190) hide show
  1. package/VERSION-PIN.md +12 -1
  2. package/dist/api.d.ts +11 -1
  3. package/dist/api.js +26 -9
  4. package/dist/build.d.ts +2 -2
  5. package/dist/build.js +4 -0
  6. package/dist/cli.d.ts +1 -0
  7. package/dist/cli.js +134 -13
  8. package/dist/dev-link.d.ts +1 -1
  9. package/dist/dev-link.js +2 -1
  10. package/dist/gateway/admission.d.ts +56 -0
  11. package/dist/gateway/admission.js +134 -0
  12. package/dist/gateway/authorization-cache.d.ts +52 -0
  13. package/dist/gateway/authorization-cache.js +111 -0
  14. package/dist/gateway/control-admission.d.ts +48 -0
  15. package/dist/gateway/control-admission.js +127 -0
  16. package/dist/gateway/control.d.ts +94 -0
  17. package/dist/gateway/control.js +116 -0
  18. package/dist/gateway/hosted-proxy.d.ts +36 -0
  19. package/dist/gateway/hosted-proxy.js +344 -0
  20. package/dist/gateway/release-admission.d.ts +23 -0
  21. package/dist/gateway/release-admission.js +259 -0
  22. package/dist/gateway/server.d.ts +38 -0
  23. package/dist/gateway/server.js +382 -0
  24. package/dist/gateway/session.d.ts +72 -0
  25. package/dist/gateway/session.js +144 -0
  26. package/dist/gateway/usage.d.ts +70 -0
  27. package/dist/gateway/usage.js +116 -0
  28. package/dist/gateway-main.d.ts +1 -0
  29. package/dist/gateway-main.js +114 -0
  30. package/dist/git-clone.d.ts +23 -1
  31. package/dist/git-clone.js +32 -15
  32. package/dist/manifest-next.d.ts +217 -0
  33. package/dist/manifest-next.js +132 -0
  34. package/dist/manifest-static.d.ts +76 -0
  35. package/dist/manifest-static.js +124 -0
  36. package/dist/manifest.d.ts +118 -13
  37. package/dist/manifest.js +12 -119
  38. package/dist/next/bridge.d.ts +47 -0
  39. package/dist/next/bridge.js +236 -0
  40. package/dist/next/browser-logs.d.ts +7 -0
  41. package/dist/next/browser-logs.js +51 -0
  42. package/dist/next/browser.d.ts +6 -0
  43. package/dist/next/browser.js +57 -0
  44. package/dist/next/protocol.d.ts +73 -0
  45. package/dist/next/protocol.js +56 -0
  46. package/dist/next/release.d.ts +15 -0
  47. package/dist/next/release.js +46 -0
  48. package/dist/next/requests.d.ts +15 -0
  49. package/dist/next/requests.js +59 -0
  50. package/dist/next/routes.d.ts +4 -0
  51. package/dist/next/routes.js +21 -0
  52. package/dist/next/streams.d.ts +16 -0
  53. package/dist/next/streams.js +132 -0
  54. package/dist/next/theme.d.ts +10 -0
  55. package/dist/next/theme.js +45 -0
  56. package/dist/next/transport.d.ts +15 -0
  57. package/dist/next/transport.js +13 -0
  58. package/dist/next-build.d.ts +29 -0
  59. package/dist/next-build.js +129 -0
  60. package/dist/next-cache/adapter-runtime.d.ts +51 -0
  61. package/dist/next-cache/adapter-runtime.js +73 -0
  62. package/dist/next-cache/component-handler.d.ts +3 -0
  63. package/dist/next-cache/component-handler.js +10 -0
  64. package/dist/next-cache/components.d.ts +20 -0
  65. package/dist/next-cache/components.js +113 -0
  66. package/dist/next-cache/config.d.ts +9 -0
  67. package/dist/next-cache/config.js +105 -0
  68. package/dist/next-cache/incremental-handler.d.ts +18 -0
  69. package/dist/next-cache/incremental-handler.js +17 -0
  70. package/dist/next-cache/incremental.d.ts +27 -0
  71. package/dist/next-cache/incremental.js +99 -0
  72. package/dist/next-cache/index.d.ts +18 -0
  73. package/dist/next-cache/index.js +16 -0
  74. package/dist/next-cache/memory-store.d.ts +27 -0
  75. package/dist/next-cache/memory-store.js +118 -0
  76. package/dist/next-cache/redis-scripts.d.ts +3 -0
  77. package/dist/next-cache/redis-scripts.js +85 -0
  78. package/dist/next-cache/redis-store.d.ts +18 -0
  79. package/dist/next-cache/redis-store.js +56 -0
  80. package/dist/next-cache/runtime.d.ts +29 -0
  81. package/dist/next-cache/runtime.js +63 -0
  82. package/dist/next-cache/serialization.d.ts +39 -0
  83. package/dist/next-cache/serialization.js +333 -0
  84. package/dist/next-cache/store.d.ts +54 -0
  85. package/dist/next-cache/store.js +45 -0
  86. package/dist/next-cache/valkey-transport.d.ts +52 -0
  87. package/dist/next-cache/valkey-transport.js +189 -0
  88. package/dist/next-dev-control.d.ts +48 -0
  89. package/dist/next-dev-control.js +63 -0
  90. package/dist/next-dev-process.d.ts +17 -0
  91. package/dist/next-dev-process.js +110 -0
  92. package/dist/next-dev.d.ts +12 -0
  93. package/dist/next-dev.js +267 -0
  94. package/dist/next-image-config.d.ts +9 -0
  95. package/dist/next-image-config.js +101 -0
  96. package/dist/next-project.d.ts +22 -0
  97. package/dist/next-project.js +64 -0
  98. package/dist/next-public-fonts-local.d.ts +7 -0
  99. package/dist/next-public-fonts-local.js +64 -0
  100. package/dist/next-public-fonts.d.ts +13 -0
  101. package/dist/next-public-fonts.js +49 -0
  102. package/dist/next-source-hash.d.ts +14 -0
  103. package/dist/next-source-hash.js +18 -0
  104. package/dist/next-source.d.ts +35 -0
  105. package/dist/next-source.js +155 -0
  106. package/dist/next.d.ts +9 -0
  107. package/dist/next.js +5 -0
  108. package/dist/project.d.ts +3 -0
  109. package/dist/project.js +9 -1
  110. package/dist/runtime-contracts.d.ts +229 -0
  111. package/dist/runtime-contracts.js +224 -0
  112. package/dist/sdk.d.ts +2 -2
  113. package/dist/server/broker-contract.d.ts +68 -0
  114. package/dist/server/broker-contract.js +33 -0
  115. package/dist/server/client.d.ts +81 -0
  116. package/dist/server/client.js +159 -0
  117. package/dist/server/logger.d.ts +26 -0
  118. package/dist/server/logger.js +24 -0
  119. package/dist/server/release-forms.d.ts +13 -0
  120. package/dist/server/release-forms.js +124 -0
  121. package/dist/server/request-context.d.ts +75 -0
  122. package/dist/server/request-context.js +107 -0
  123. package/dist/server/runtime-config.d.ts +54 -0
  124. package/dist/server/runtime-config.js +64 -0
  125. package/dist/server.d.ts +8 -0
  126. package/dist/server.js +4 -0
  127. package/dist/templates/nextjs/.artifactignore +14 -0
  128. package/dist/templates/nextjs/README.md +157 -0
  129. package/dist/templates/nextjs/app/about/page.tsx +26 -0
  130. package/dist/templates/nextjs/app/actions.ts +18 -0
  131. package/dist/templates/nextjs/app/api/health/route.ts +6 -0
  132. package/dist/templates/nextjs/app/components/counter.tsx +19 -0
  133. package/dist/templates/nextjs/app/components/greeting-form.tsx +38 -0
  134. package/dist/templates/nextjs/app/globals.css +4 -0
  135. package/dist/templates/nextjs/app/layout.tsx +27 -0
  136. package/dist/templates/nextjs/app/page.tsx +51 -0
  137. package/dist/templates/nextjs/app/providers.tsx +107 -0
  138. package/dist/templates/nextjs/artifact.bundle.yml +55 -0
  139. package/dist/templates/nextjs/gitignore +9 -0
  140. package/dist/templates/nextjs/next.config.mjs +27 -0
  141. package/dist/templates/nextjs/package.json +32 -0
  142. package/dist/templates/nextjs/pnpm-workspace.yaml +22 -0
  143. package/dist/templates/nextjs/postcss.config.mjs +5 -0
  144. package/dist/templates/nextjs/tsconfig.json +20 -0
  145. package/dist/templates/react-vite/package.json +1 -1
  146. package/dist/trusted-install.js +62 -2
  147. package/dist/tunnel/broker.d.ts +75 -0
  148. package/dist/tunnel/broker.js +463 -0
  149. package/dist/tunnel/client.d.ts +22 -0
  150. package/dist/tunnel/client.js +177 -0
  151. package/dist/tunnel/index.d.ts +6 -0
  152. package/dist/tunnel/index.js +4 -0
  153. package/dist/tunnel/io.d.ts +33 -0
  154. package/dist/tunnel/io.js +108 -0
  155. package/dist/tunnel/peer.d.ts +25 -0
  156. package/dist/tunnel/peer.js +147 -0
  157. package/dist/tunnel/protocol.d.ts +78 -0
  158. package/dist/tunnel/protocol.js +210 -0
  159. package/dist/tunnel/redis-auth.d.ts +37 -0
  160. package/dist/tunnel/redis-auth.js +92 -0
  161. package/dist/tunnel/redis-ownership.d.ts +38 -0
  162. package/dist/tunnel/redis-ownership.js +100 -0
  163. package/dist/tunnel/redis-socket.d.ts +13 -0
  164. package/dist/tunnel/redis-socket.js +48 -0
  165. package/dist/tunnel/redis-types.d.ts +75 -0
  166. package/dist/tunnel/redis-types.js +9 -0
  167. package/dist/tunnel/redis.d.ts +56 -0
  168. package/dist/tunnel/redis.js +404 -0
  169. package/dist/tunnel/transport.d.ts +17 -0
  170. package/dist/tunnel/transport.js +1 -0
  171. package/package.json +67 -4
  172. package/templates/nextjs/.artifactignore +14 -0
  173. package/templates/nextjs/README.md +157 -0
  174. package/templates/nextjs/app/about/page.tsx +26 -0
  175. package/templates/nextjs/app/actions.ts +18 -0
  176. package/templates/nextjs/app/api/health/route.ts +6 -0
  177. package/templates/nextjs/app/components/counter.tsx +19 -0
  178. package/templates/nextjs/app/components/greeting-form.tsx +38 -0
  179. package/templates/nextjs/app/globals.css +4 -0
  180. package/templates/nextjs/app/layout.tsx +27 -0
  181. package/templates/nextjs/app/page.tsx +51 -0
  182. package/templates/nextjs/app/providers.tsx +107 -0
  183. package/templates/nextjs/artifact.bundle.yml +55 -0
  184. package/templates/nextjs/gitignore +9 -0
  185. package/templates/nextjs/next.config.mjs +27 -0
  186. package/templates/nextjs/package.json +32 -0
  187. package/templates/nextjs/pnpm-workspace.yaml +22 -0
  188. package/templates/nextjs/postcss.config.mjs +5 -0
  189. package/templates/nextjs/tsconfig.json +20 -0
  190. package/templates/react-vite/package.json +1 -1
@@ -0,0 +1,113 @@
1
+ import { z } from 'zod';
2
+ import { CacheAdapterRuntime, nextWriteTicket } from './adapter-runtime.js';
3
+ import { createCacheStream, deserializeCacheValue, readCacheStream, serializeCacheValue } from './serialization.js';
4
+ import { normalizeCacheTags } from './store.js';
5
+ const metadataSchema = z.object({
6
+ tags: z.array(z.string()), stale: z.number().finite().nonnegative(),
7
+ timestamp: z.number().finite().nonnegative(), expire: z.number().finite(),
8
+ revalidate: z.number().finite(),
9
+ });
10
+ const recordSchema = metadataSchema.extend({ format: z.literal('component-v1'), bytes: z.instanceof(Uint8Array) });
11
+ function discardStream(value) {
12
+ void value.cancel().catch(() => { });
13
+ }
14
+ async function awaitEntry({ pending, timeoutMs }) {
15
+ let timer;
16
+ try {
17
+ return await Promise.race([pending, new Promise((_, reject) => {
18
+ timer = setTimeout(() => reject(new Error('Pending Next cache entry timed out')), timeoutMs);
19
+ })]);
20
+ }
21
+ catch (error) {
22
+ void pending.then((entry) => discardStream(entry.value), () => { });
23
+ throw error;
24
+ }
25
+ finally {
26
+ clearTimeout(timer);
27
+ }
28
+ }
29
+ export function createComponentCacheHandler(options) {
30
+ const runtime = new CacheAdapterRuntime(options);
31
+ // Process-wide and bounded to 512 keys, so it holds write tickets only, never cached payloads.
32
+ const tickets = new Map();
33
+ const pendingSets = new Map();
34
+ async function persist({ key, pending, lookup }) {
35
+ let stream;
36
+ try {
37
+ const entry = await awaitEntry({ pending, timeoutMs: runtime.streamTimeoutMs });
38
+ stream = entry.value;
39
+ if (!lookup) {
40
+ discardStream(entry.value);
41
+ return;
42
+ }
43
+ const metadata = metadataSchema.parse(entry);
44
+ const ttlMs = Math.min(runtime.maxEntryAgeMs, Math.ceil(metadata.timestamp + metadata.expire * 1000 - runtime.now()));
45
+ // Do not let an older render consume a newer generation's write ticket.
46
+ if (metadata.timestamp < lookup.observedAt || ttlMs <= 0) {
47
+ discardStream(entry.value);
48
+ return;
49
+ }
50
+ const tags = normalizeCacheTags(metadata.tags);
51
+ const bytes = await readCacheStream({ stream: entry.value, maxBytes: runtime.maxEntryBytes, timeoutMs: runtime.streamTimeoutMs });
52
+ const payload = serializeCacheValue({ value: { ...metadata, format: 'component-v1', bytes }, limits: { maxBytes: runtime.maxEntryBytes } });
53
+ await runtime.store.commit({ namespace: runtime.namespace, key: `component:${key}`, ticket: lookup.ticket, payload, tags, ttlMs });
54
+ }
55
+ catch {
56
+ if (stream && !stream.locked)
57
+ discardStream(stream);
58
+ runtime.report('write');
59
+ }
60
+ }
61
+ return {
62
+ async get(key, softTags) {
63
+ try {
64
+ await pendingSets.get(key);
65
+ const lookup = await runtime.read({ key: `component:${key}`, tags: normalizeCacheTags(softTags) });
66
+ if (!lookup)
67
+ return undefined;
68
+ // Payload-free: a hit that is never re-rendered must not pin its serialized entry here. A ticket
69
+ // left by an earlier hit is replaced once the store has moved past it (invalidation, another
70
+ // commit, or age), so the first regeneration after an invalidation can actually commit.
71
+ const retained = tickets.get(key);
72
+ const next = nextWriteTicket({ retained, lookup, now: runtime.now() });
73
+ if (next !== retained) {
74
+ if (!retained && tickets.size >= 512)
75
+ tickets.delete(tickets.keys().next().value ?? '');
76
+ tickets.set(key, next);
77
+ }
78
+ if (!lookup.entry)
79
+ return undefined;
80
+ const record = recordSchema.parse(deserializeCacheValue({ serialized: lookup.entry.payload, limits: { maxBytes: runtime.maxEntryBytes } }));
81
+ if (record.expire <= 0 || runtime.now() >= record.timestamp + record.expire * 1000)
82
+ return undefined;
83
+ return { tags: record.tags, stale: record.stale, timestamp: record.timestamp,
84
+ expire: record.expire, revalidate: lookup.entry.stale ? -1 : record.revalidate,
85
+ value: createCacheStream({ bytes: record.bytes }) };
86
+ }
87
+ catch {
88
+ runtime.report('read');
89
+ return undefined;
90
+ }
91
+ },
92
+ async set(key, pendingEntry) {
93
+ const lookup = tickets.get(key);
94
+ tickets.delete(key);
95
+ if (pendingSets.has(key) || pendingSets.size >= 128) {
96
+ await persist({ key, pending: pendingEntry });
97
+ return;
98
+ }
99
+ const pending = persist({ key, pending: pendingEntry, lookup });
100
+ pendingSets.set(key, pending);
101
+ try {
102
+ await pending;
103
+ }
104
+ finally {
105
+ if (pendingSets.get(key) === pending)
106
+ pendingSets.delete(key);
107
+ }
108
+ },
109
+ async refreshTags() { },
110
+ async getExpiration() { return Infinity; }, // Next passes soft tags into get().
111
+ async updateTags(tags, durations) { await runtime.invalidate({ tags, durations }); },
112
+ };
113
+ }
@@ -0,0 +1,9 @@
1
+ export { artifactNextCacheBindingSchema, verifyArtifactNextCacheReadiness } from './runtime.js';
2
+ type Config = Record<string, unknown>;
3
+ type ConfigInput<T extends object> = T | ((phase: string, context: unknown) => T | Promise<T>);
4
+ /** Always compiled into standalone output; runtime mode is selected only at boot. */
5
+ export declare function withArtifactNextConfig<T extends object>(input: ConfigInput<T>): (phase: string, context: unknown) => Promise<T & Config>;
6
+ /** Runs only inside the isolated compile sandbox, never in the control-plane worker. */
7
+ export declare function installArtifactNextCacheConfig({ projectRoot }: {
8
+ projectRoot: string;
9
+ }): Promise<void>;
@@ -0,0 +1,105 @@
1
+ import { readFile, readdir, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { load as parseYaml } from 'js-yaml';
5
+ import { isNextArtifactManifest, parseArtifactStudioManifest } from '../manifest.js';
6
+ import { resolveArtifactNextImages } from '../next-image-config.js';
7
+ export { artifactNextCacheBindingSchema, verifyArtifactNextCacheReadiness } from './runtime.js';
8
+ const MARKER = '// Generated by Atlas Artifact Studio; original configuration is preserved.';
9
+ function configured(config, egress) {
10
+ const deploymentId = process.env.ARTIFACT_NEXT_BUILD_ID;
11
+ if (deploymentId !== undefined && (deploymentId.length < 1 || deploymentId.length > 128 || /[^A-Za-z0-9_-]/.test(deploymentId)))
12
+ throw new Error('Invalid platform build identifier');
13
+ if (Reflect.get(config, 'deploymentId') !== undefined && Reflect.get(config, 'deploymentId') !== deploymentId)
14
+ throw new Error('Artifact Studio owns the Next deploymentId configuration');
15
+ for (const [name, supported] of Object.entries({ basePath: '', assetPrefix: '', distDir: '.next', output: 'standalone' })) {
16
+ const value = Reflect.get(config, name);
17
+ if (value !== undefined && value !== supported)
18
+ throw new Error(`Artifact Studio requires ${name}=${JSON.stringify(supported)} for managed routing and standalone packaging`);
19
+ }
20
+ const incremental = fileURLToPath(new URL('./incremental-handler.js', import.meta.url));
21
+ const components = fileURLToPath(new URL('./component-handler.js', import.meta.url));
22
+ const existing = Reflect.get(config, 'cacheHandler');
23
+ const handlers = Reflect.get(config, 'cacheHandlers');
24
+ if (existing !== undefined && existing !== incremental)
25
+ throw new Error('Artifact Studio owns the Next cacheHandler configuration');
26
+ if (handlers !== undefined && (typeof handlers !== 'object' || handlers === null
27
+ || Object.entries(handlers).some(([key, value]) => !['default', 'remote'].includes(key) || value !== components))) {
28
+ throw new Error('Artifact Studio owns the Next cacheHandlers configuration');
29
+ }
30
+ if (!Array.isArray(egress) || !egress.every((host) => typeof host === 'string'))
31
+ throw new Error('Invalid Artifact image egress policy');
32
+ const images = resolveArtifactNextImages({ images: Reflect.get(config, 'images'), egressHosts: egress });
33
+ return { ...config, output: 'standalone', cacheMaxMemorySize: 0,
34
+ ...(deploymentId ? { deploymentId } : {}),
35
+ cacheHandler: incremental, cacheHandlers: { default: components, remote: components },
36
+ images: { ...images, customCacheHandler: true } };
37
+ }
38
+ /**
39
+ * seq-studio and the hosted build supply the application's egress policy. A plain local
40
+ * `next build` or `next dev` reads the same declaration from the application's manifest.
41
+ */
42
+ async function allowedEgress(config) {
43
+ const supplied = process.env.ARTIFACT_NEXT_ALLOWED_EGRESS;
44
+ if (supplied !== undefined)
45
+ return JSON.parse(supplied);
46
+ const images = Reflect.get(config, 'images');
47
+ const patterns = typeof images === 'object' && images !== null ? Reflect.get(images, 'remotePatterns') : undefined;
48
+ if (process.env.K_SERVICE || !Array.isArray(patterns) || patterns.length === 0)
49
+ return [];
50
+ return declaredEgress({ projectRoot: process.cwd() });
51
+ }
52
+ /** The nearest artifact.bundle.yml, when its app_root is this project; the manifest may sit above app_root. */
53
+ async function declaredEgress({ projectRoot }) {
54
+ for (let directory = projectRoot;; directory = dirname(directory)) {
55
+ const text = await readFile(join(directory, 'artifact.bundle.yml'), 'utf8').catch(() => undefined);
56
+ if (text !== undefined) {
57
+ const manifest = parseArtifactStudioManifest(parseYaml(text));
58
+ return isNextArtifactManifest(manifest) && resolve(directory, manifest.artifact.app_root) === projectRoot ? manifest.server.egress : [];
59
+ }
60
+ if (dirname(directory) === directory)
61
+ return [];
62
+ }
63
+ }
64
+ /** Always compiled into standalone output; runtime mode is selected only at boot. */
65
+ export function withArtifactNextConfig(input) {
66
+ return async (phase, context) => {
67
+ // Build workers/local dev processes receive disposable storage; hosted runtime
68
+ // must explicitly provide disabled/hosted and cannot fall back to local memory.
69
+ if (!process.env.K_SERVICE && !process.env.ARTIFACT_NEXT_CACHE_MODE) {
70
+ if (phase === 'phase-development-server' || phase === 'phase-production-server')
71
+ process.env.ARTIFACT_NEXT_CACHE_MODE = 'local';
72
+ else if (phase === 'phase-production-build')
73
+ process.env.ARTIFACT_NEXT_CACHE_MODE = 'build';
74
+ }
75
+ const config = typeof input === 'function' ? await input(phase, context) : input;
76
+ if (!config || typeof config !== 'object' || Array.isArray(config))
77
+ throw new Error('Next configuration must return an object');
78
+ return configured(config, await allowedEgress(config));
79
+ };
80
+ }
81
+ /** Runs only inside the isolated compile sandbox, never in the control-plane worker. */
82
+ export async function installArtifactNextCacheConfig({ projectRoot }) {
83
+ const names = await readdir(projectRoot);
84
+ const configs = names.filter((name) => /^next\.config\.(?:mjs|js|ts)$/.test(name));
85
+ if (configs.length > 1)
86
+ throw new Error('An Artifact Next application must have exactly one Next configuration file');
87
+ const original = configs[0];
88
+ const reserved = names.filter((name) => name.startsWith('.sequence-next-'));
89
+ if (original && (await readFile(join(projectRoot, original), 'utf8')).startsWith(MARKER)) {
90
+ const saved = reserved.length === 1 && /^\.sequence-next-user\.config\.(?:mjs|js|ts)$/.test(reserved[0]) ? reserved[0] : undefined;
91
+ if ((reserved.length === 0 || saved) && await readFile(join(projectRoot, original), 'utf8') === wrapperSource(saved))
92
+ return;
93
+ }
94
+ if (reserved.length)
95
+ throw new Error('Source contains reserved Artifact Next configuration files');
96
+ const extension = original?.split('.').at(-1) ?? 'mjs';
97
+ const saved = `.sequence-next-user.config.${extension}`;
98
+ if (original)
99
+ await rename(join(projectRoot, original), join(projectRoot, saved));
100
+ const wrapper = extension === 'ts' ? 'next.config.ts' : 'next.config.mjs';
101
+ await writeFile(join(projectRoot, wrapper), wrapperSource(original ? saved : undefined), { flag: 'wx' });
102
+ }
103
+ function wrapperSource(saved) {
104
+ return `${MARKER}\nimport { withArtifactNextConfig } from '@sequenceholdings/artifact-studio/next-cache/config';\n${saved ? `import userConfig from './${saved}';\n` : 'const userConfig = {};\n'}export default withArtifactNextConfig(userConfig);\n`;
105
+ }
@@ -0,0 +1,18 @@
1
+ import type { IncrementalCacheContext, NextIncrementalCacheHandler } from './incremental.js';
2
+ export { verifyArtifactNextCacheReadiness } from './config.js';
3
+ declare class ArtifactRuntimeIncrementalHandler implements NextIncrementalCacheHandler {
4
+ private readonly handler;
5
+ constructor(context?: {
6
+ revalidatedTags?: string[];
7
+ });
8
+ get(key: string, context?: IncrementalCacheContext): Promise<{
9
+ value: unknown;
10
+ lastModified: number;
11
+ } | null>;
12
+ set(key: string, value: unknown, context?: IncrementalCacheContext): Promise<void>;
13
+ revalidateTag(tags: string | string[], durations?: {
14
+ expire?: number;
15
+ }): Promise<void>;
16
+ resetRequestCache(): void;
17
+ }
18
+ export { ArtifactRuntimeIncrementalHandler as default };
@@ -0,0 +1,17 @@
1
+ import { getArtifactNextRuntimeCache } from './runtime.js';
2
+ // The trusted container launcher imports this public subpath for readiness.
3
+ // Keep it in the handler dependency graph so Next traces it into standalone.
4
+ export { verifyArtifactNextCacheReadiness } from './config.js';
5
+ class ArtifactRuntimeIncrementalHandler {
6
+ handler;
7
+ constructor(context = {}) {
8
+ const Handler = getArtifactNextRuntimeCache().IncrementalCacheHandler;
9
+ this.handler = new Handler(context);
10
+ }
11
+ get(key, context) { return this.handler.get(key, context); }
12
+ set(key, value, context) { return this.handler.set(key, value, context); }
13
+ revalidateTag(tags, durations) { return this.handler.revalidateTag(tags, durations); }
14
+ resetRequestCache() { this.handler.resetRequestCache(); }
15
+ }
16
+ // Next's configuration loader requires a default export from handler modules.
17
+ export { ArtifactRuntimeIncrementalHandler as default };
@@ -0,0 +1,27 @@
1
+ import { type NextCacheOptions } from './adapter-runtime.js';
2
+ export interface IncrementalCacheContext {
3
+ tags?: string[];
4
+ softTags?: string[];
5
+ kind?: string;
6
+ /** Supplied by Next for pages and routes on set(). */
7
+ cacheControl?: {
8
+ revalidate?: number | false;
9
+ expire?: number;
10
+ };
11
+ }
12
+ export interface NextIncrementalCacheHandler {
13
+ get(key: string, context?: IncrementalCacheContext): Promise<{
14
+ value: unknown;
15
+ lastModified: number;
16
+ } | null>;
17
+ set(key: string, value: unknown, context?: IncrementalCacheContext): Promise<void>;
18
+ revalidateTag(tags: string | string[], durations?: {
19
+ expire?: number;
20
+ }): Promise<void>;
21
+ resetRequestCache(): void;
22
+ }
23
+ export type NextIncrementalCacheHandlerConstructor = new (context?: {
24
+ revalidatedTags?: string[];
25
+ }) => NextIncrementalCacheHandler;
26
+ /** The constructor is returned for a platform-generated Next cacheHandler shim. */
27
+ export declare function createIncrementalCacheHandler(options: NextCacheOptions): NextIncrementalCacheHandlerConstructor;
@@ -0,0 +1,99 @@
1
+ import { z } from 'zod';
2
+ import { CacheAdapterRuntime, nextWriteTicket } from './adapter-runtime.js';
3
+ import { deserializeCacheValue, serializeCacheValue } from './serialization.js';
4
+ import { normalizeCacheTags } from './store.js';
5
+ /** Next's per-route CacheControl, recorded with a page or route so a soft invalidation can stay SWR. */
6
+ const cacheControlSchema = z.object({
7
+ revalidate: z.union([z.number().finite().positive(), z.literal(false)]),
8
+ expire: z.number().finite().positive().optional(),
9
+ });
10
+ const storedEntrySchema = z.object({
11
+ format: z.literal('incremental-v1'), value: z.unknown(), lastModified: z.number().finite(),
12
+ cacheControl: cacheControlSchema.optional(),
13
+ });
14
+ const taggedValueSchema = z.object({
15
+ kind: z.string().optional(),
16
+ tags: z.array(z.string()).optional(),
17
+ headers: z.record(z.string(), z.unknown()).optional(),
18
+ }).passthrough();
19
+ // Keeps a synthesized SWR timestamp clear of the revalidate and expire boundaries.
20
+ const STALE_WINDOW_MARGIN_MS = 1_000;
21
+ /**
22
+ * lastModified for an entry the store reports as soft-invalidated (stale but not expired).
23
+ * Next 16.3 reads -1 as "stale" for FETCH entries, but as "expired: block on regeneration" for
24
+ * APP_PAGE and APP_ROUTE. For those, report a time just past the route's revalidate window and
25
+ * still inside expire, which Next serves stale while regenerating in the background. Pages with
26
+ * no revalidate window (revalidate: false) or no room before expire can only regenerate blocking.
27
+ */
28
+ function softStaleLastModified({ record, now }) {
29
+ const kind = taggedValueSchema.safeParse(record.value).data?.kind;
30
+ const control = record.cacheControl;
31
+ if ((kind !== 'APP_PAGE' && kind !== 'APP_ROUTE') || !control || control.revalidate === false)
32
+ return -1;
33
+ const revalidateMs = control.revalidate * 1000;
34
+ if (control.expire !== undefined && control.expire * 1000 - revalidateMs <= 2 * STALE_WINDOW_MARGIN_MS)
35
+ return -1;
36
+ const lastModified = now - revalidateMs - STALE_WINDOW_MARGIN_MS;
37
+ return lastModified > 0 ? lastModified : -1;
38
+ }
39
+ /** The constructor is returned for a platform-generated Next cacheHandler shim. */
40
+ export function createIncrementalCacheHandler(options) {
41
+ const runtime = new CacheAdapterRuntime(options);
42
+ return class ArtifactIncrementalCacheHandler {
43
+ // Next creates an IncrementalCache/handler for each request. Tickets are never
44
+ // shared across requests, and no user/cache payload is kept process-globally.
45
+ tickets = new Map();
46
+ revalidatedTags;
47
+ constructor(context = {}) {
48
+ this.revalidatedTags = new Set(context.revalidatedTags ?? []);
49
+ }
50
+ async get(key, context = {}) {
51
+ try {
52
+ const tags = normalizeCacheTags([...(context.tags ?? []), ...(context.softTags ?? [])]);
53
+ const lookup = await runtime.read({ key: `isr:${key}`, tags });
54
+ if (!lookup)
55
+ return null;
56
+ // Payload-free, like the component handler: set() only needs the ticket and lookup time. A ticket
57
+ // the store has moved past (e.g. revalidateTag earlier in this request) is replaced, not kept.
58
+ const retained = this.tickets.get(key);
59
+ const next = nextWriteTicket({ retained, lookup, now: runtime.now() });
60
+ if (next !== retained && (retained || this.tickets.size < 512))
61
+ this.tickets.set(key, next);
62
+ if (!lookup.entry || tags.some((tag) => this.revalidatedTags.has(tag)))
63
+ return null;
64
+ const record = storedEntrySchema.parse(deserializeCacheValue({ serialized: lookup.entry.payload, limits: { maxBytes: runtime.maxEntryBytes } }));
65
+ // Soft staleness is not hard expiration: pages and routes must stay stale-while-revalidate.
66
+ return { value: record.value, lastModified: lookup.entry.stale ? softStaleLastModified({ record, now: runtime.now() }) : record.lastModified };
67
+ }
68
+ catch {
69
+ runtime.report('read');
70
+ return null;
71
+ }
72
+ }
73
+ async set(key, value, context = {}) {
74
+ const lookup = this.tickets.get(key);
75
+ this.tickets.delete(key);
76
+ // No get means no proof the render preceded invalidation. Skip caching
77
+ // rather than assigning a fresh epoch to possibly stale computed output.
78
+ if (!lookup)
79
+ return;
80
+ try {
81
+ const parsed = taggedValueSchema.safeParse(value);
82
+ const valueTags = parsed.success ? parsed.data.tags ?? [] : [];
83
+ const header = parsed.success ? parsed.data.headers?.['x-next-cache-tags'] : undefined;
84
+ const tags = normalizeCacheTags([...(context.tags ?? []), ...valueTags, ...(typeof header === 'string' ? header.split(',') : [])]);
85
+ const cacheControl = cacheControlSchema.safeParse(context.cacheControl).data;
86
+ const payload = serializeCacheValue({ value: { format: 'incremental-v1', value, lastModified: runtime.now(), ...(cacheControl ? { cacheControl } : {}) }, limits: { maxBytes: runtime.maxEntryBytes } });
87
+ await runtime.store.commit({ namespace: runtime.namespace, key: `isr:${key}`, ticket: lookup.ticket,
88
+ payload, tags, ttlMs: runtime.maxEntryAgeMs });
89
+ }
90
+ catch {
91
+ runtime.report('write');
92
+ }
93
+ }
94
+ async revalidateTag(tags, durations) {
95
+ await runtime.invalidate({ tags: typeof tags === 'string' ? [tags] : tags, durations });
96
+ }
97
+ resetRequestCache() { this.tickets.clear(); }
98
+ };
99
+ }
@@ -0,0 +1,18 @@
1
+ import type { NextCacheOptions } from './adapter-runtime.js';
2
+ export { createComponentCacheHandler, type NextComponentCacheEntry, type NextComponentCacheHandler } from './components.js';
3
+ export { createIncrementalCacheHandler, type IncrementalCacheContext, type NextIncrementalCacheHandler, type NextIncrementalCacheHandlerConstructor } from './incremental.js';
4
+ export { LocalNextCacheStore } from './memory-store.js';
5
+ export { RedisNextCacheStore } from './redis-store.js';
6
+ export { ValkeyScriptExecutor, ValkeyUnavailableError, type ValkeyConnection, type RedisScriptExecutor } from './valkey-transport.js';
7
+ export type { NextCacheOptions } from './adapter-runtime.js';
8
+ export type { NextCacheNamespace, NextCacheStore, CacheWriteTicket, CacheLookup, CacheCommit, CacheLocation } from './store.js';
9
+ /**
10
+ * Platform wiring for both Next cache APIs. Generated shims default-export the
11
+ * constructor/object, and next.config must set cacheMaxMemorySize: 0.
12
+ * This caches explicit application-wide Next entries, never SDK user requests.
13
+ * User-bound SDK fetches must use no-store and remain outside use-cache scopes.
14
+ */
15
+ export declare function createNextCacheHandlers(options: NextCacheOptions): {
16
+ IncrementalCacheHandler: import("./incremental.js").NextIncrementalCacheHandlerConstructor;
17
+ cacheComponents: import("./components.js").NextComponentCacheHandler;
18
+ };
@@ -0,0 +1,16 @@
1
+ import { createComponentCacheHandler } from './components.js';
2
+ import { createIncrementalCacheHandler } from './incremental.js';
3
+ export { createComponentCacheHandler } from './components.js';
4
+ export { createIncrementalCacheHandler } from './incremental.js';
5
+ export { LocalNextCacheStore } from './memory-store.js';
6
+ export { RedisNextCacheStore } from './redis-store.js';
7
+ export { ValkeyScriptExecutor, ValkeyUnavailableError } from './valkey-transport.js';
8
+ /**
9
+ * Platform wiring for both Next cache APIs. Generated shims default-export the
10
+ * constructor/object, and next.config must set cacheMaxMemorySize: 0.
11
+ * This caches explicit application-wide Next entries, never SDK user requests.
12
+ * User-bound SDK fetches must use no-store and remain outside use-cache scopes.
13
+ */
14
+ export function createNextCacheHandlers(options) {
15
+ return { IncrementalCacheHandler: createIncrementalCacheHandler(options), cacheComponents: createComponentCacheHandler(options) };
16
+ }
@@ -0,0 +1,27 @@
1
+ import { type CacheCommit, type CacheLocation, type CacheLookup, type NextCacheNamespace, type NextCacheStore } from './store.js';
2
+ /** Explicitly disposable, isolated local cache. Never a distributed-store fallback. */
3
+ export declare class LocalNextCacheStore implements NextCacheStore {
4
+ private readonly metadata;
5
+ private readonly entries;
6
+ private readonly now;
7
+ private readonly maxEntries;
8
+ private readonly maxBytes;
9
+ private bytes;
10
+ constructor({ purpose, now, maxEntries, maxBytes }: {
11
+ purpose: 'test' | 'local-development';
12
+ now?: () => number;
13
+ maxEntries?: number;
14
+ maxBytes?: number;
15
+ });
16
+ private getMetadata;
17
+ private remove;
18
+ read(input: CacheLocation & {
19
+ tags: string[];
20
+ }): Promise<CacheLookup>;
21
+ commit(input: CacheCommit): Promise<boolean>;
22
+ invalidate(input: {
23
+ namespace: NextCacheNamespace;
24
+ tags: string[];
25
+ expireAfterMs?: number;
26
+ }): Promise<void>;
27
+ }
@@ -0,0 +1,118 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { cacheEntryKey, cacheNamespacePrefix, MAX_CACHE_WRITE_AGE_MS, MAX_NAMESPACE_TAGS, normalizeCacheTags, validateCacheCommit, validateExpiration, } from './store.js';
3
+ /** Explicitly disposable, isolated local cache. Never a distributed-store fallback. */
4
+ export class LocalNextCacheStore {
5
+ metadata = new Map();
6
+ entries = new Map();
7
+ now;
8
+ maxEntries;
9
+ maxBytes;
10
+ bytes = 0;
11
+ constructor({ purpose, now = Date.now, maxEntries = 1024, maxBytes = 64 * 1024 * 1024 }) {
12
+ if (purpose !== 'test' && purpose !== 'local-development')
13
+ throw new Error('Local cache cannot serve a hosted deployment');
14
+ if (![maxEntries, maxBytes].every((value) => Number.isSafeInteger(value) && value > 0))
15
+ throw new Error('Invalid local cache capacity');
16
+ this.now = now;
17
+ this.maxEntries = maxEntries;
18
+ this.maxBytes = maxBytes;
19
+ }
20
+ getMetadata(namespace) {
21
+ const key = cacheNamespacePrefix(namespace);
22
+ let metadata = this.metadata.get(key);
23
+ if (!metadata) {
24
+ if (this.metadata.size >= 256)
25
+ this.metadata.delete(this.metadata.keys().next().value ?? '');
26
+ metadata = { incarnation: randomUUID(), epoch: 0, tags: new Map() };
27
+ this.metadata.set(key, metadata);
28
+ }
29
+ return metadata;
30
+ }
31
+ remove(key) {
32
+ const entry = this.entries.get(key);
33
+ if (entry)
34
+ this.bytes -= Buffer.byteLength(entry.payload);
35
+ this.entries.delete(key);
36
+ }
37
+ async read(input) {
38
+ const tags = normalizeCacheTags(input.tags);
39
+ const key = cacheEntryKey(input);
40
+ const metadata = this.getMetadata(input.namespace);
41
+ let entry = this.entries.get(key);
42
+ if (entry && entry.expiresAt <= this.now()) {
43
+ this.remove(key);
44
+ entry = undefined;
45
+ }
46
+ const result = { ticket: {
47
+ incarnation: metadata.incarnation, epoch: metadata.epoch,
48
+ version: entry?.version ?? '', startedAt: this.now(),
49
+ } };
50
+ if (!entry || entry.incarnation !== metadata.incarnation)
51
+ return result;
52
+ let stale = false;
53
+ for (const tag of [...tags, ...entry.tags]) {
54
+ const history = metadata.tags.get(tag);
55
+ if (!history)
56
+ continue;
57
+ if (entry.epoch < history.floor)
58
+ return result;
59
+ for (const event of history.events) {
60
+ if (event.epoch <= entry.epoch)
61
+ continue;
62
+ if (event.expireAt <= this.now())
63
+ return result;
64
+ stale = true;
65
+ }
66
+ }
67
+ // Refresh LRU position only for usable records.
68
+ this.entries.delete(key);
69
+ this.entries.set(key, entry);
70
+ result.entry = { payload: entry.payload, stale };
71
+ return result;
72
+ }
73
+ async commit(input) {
74
+ validateCacheCommit(input);
75
+ const key = cacheEntryKey(input);
76
+ const metadata = this.metadata.get(cacheNamespacePrefix(input.namespace));
77
+ let previous = this.entries.get(key);
78
+ if (previous && previous.expiresAt <= this.now()) {
79
+ this.remove(key);
80
+ previous = undefined;
81
+ }
82
+ if (!metadata || metadata.incarnation !== input.ticket.incarnation || metadata.epoch !== input.ticket.epoch
83
+ || (previous?.version ?? '') !== input.ticket.version || this.now() < input.ticket.startedAt
84
+ || this.now() - input.ticket.startedAt > MAX_CACHE_WRITE_AGE_MS)
85
+ return false;
86
+ const bytes = Buffer.byteLength(input.payload);
87
+ if (bytes > this.maxBytes)
88
+ return false;
89
+ this.remove(key);
90
+ this.entries.set(key, { ...input.ticket, version: randomUUID(), tags: [...input.tags],
91
+ payload: input.payload, expiresAt: this.now() + input.ttlMs });
92
+ this.bytes += bytes;
93
+ while (this.entries.size > this.maxEntries || this.bytes > this.maxBytes) {
94
+ this.remove(this.entries.keys().next().value ?? '');
95
+ }
96
+ return true;
97
+ }
98
+ async invalidate(input) {
99
+ validateExpiration(input.expireAfterMs);
100
+ const tags = normalizeCacheTags(input.tags);
101
+ if (!tags.length)
102
+ return;
103
+ const metadata = this.getMetadata(input.namespace);
104
+ if (metadata.tags.size + tags.length > MAX_NAMESPACE_TAGS) {
105
+ metadata.incarnation = randomUUID();
106
+ metadata.epoch = 0;
107
+ metadata.tags.clear();
108
+ }
109
+ const epoch = ++metadata.epoch;
110
+ for (const tag of tags) {
111
+ const history = metadata.tags.get(tag) ?? { floor: 0, events: [] };
112
+ history.events.push({ epoch, expireAt: this.now() + (input.expireAfterMs ?? 0) });
113
+ if (history.events.length > 16)
114
+ history.floor = history.events.shift()?.epoch ?? history.floor;
115
+ metadata.tags.set(tag, history);
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,3 @@
1
+ export declare const NEXT_CACHE_READ_SCRIPT = "\nlocal clock = redis.call('TIME')\nlocal now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)\nlocal function decode(value)\n if not value then return nil end\n local ok, parsed = pcall(cjson.decode, value)\n if ok and type(parsed) == 'table' then return parsed end\n return nil\nend\nlocal function initialize(nonce, retention)\n local incarnation = redis.call('HGET', KEYS[1], 'incarnation')\n if not incarnation then\n redis.call('DEL', KEYS[1])\n redis.call('HSET', KEYS[1], 'incarnation', nonce, 'epoch', '0')\n incarnation = nonce\n end\n redis.call('PEXPIRE', KEYS[1], retention)\n return incarnation\nend\n\nlocal incarnation = initialize(ARGV[1], ARGV[2])\nlocal epoch = tonumber(redis.call('HGET', KEYS[1], 'epoch'))\nlocal entry = decode(redis.call('GET', KEYS[2]))\nlocal ticket = {incarnation=incarnation, epoch=epoch, version=entry and entry.version or '', startedAt=now}\nlocal result = {ticket=ticket}\nif not entry or entry.incarnation ~= incarnation or entry.expiresAt <= now then\n return cjson.encode(result)\nend\nlocal tags = decode(ARGV[3])\nfor _, tag in ipairs(entry.tags) do table.insert(tags, tag) end\nlocal stale = false\nfor _, tag in ipairs(tags) do\n local raw = redis.call('HGET', KEYS[1], 'tag:' .. tag)\n local state = decode(raw)\n if raw and not state then return cjson.encode(result) end\n if state then\n if entry.epoch < state.floor then return cjson.encode(result) end\n for _, event in ipairs(state.events) do\n if event.epoch > entry.epoch then\n if event.expireAt >= 0 and event.expireAt <= now then return cjson.encode(result) end\n stale = true\n end\n end\n end\nend\nresult.entry = {payload=entry.payload, stale=stale}\nreturn cjson.encode(result)\n";
2
+ export declare const NEXT_CACHE_COMMIT_SCRIPT = "\nlocal clock = redis.call('TIME')\nlocal now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)\nlocal function decode(value)\n if not value then return nil end\n local ok, parsed = pcall(cjson.decode, value)\n if ok and type(parsed) == 'table' then return parsed end\n return nil\nend\nlocal function initialize(nonce, retention)\n local incarnation = redis.call('HGET', KEYS[1], 'incarnation')\n if not incarnation then\n redis.call('DEL', KEYS[1])\n redis.call('HSET', KEYS[1], 'incarnation', nonce, 'epoch', '0')\n incarnation = nonce\n end\n redis.call('PEXPIRE', KEYS[1], retention)\n return incarnation\nend\n\nlocal ticket = decode(ARGV[1])\nif redis.call('HGET', KEYS[1], 'incarnation') ~= ticket.incarnation then return 0 end\nif tonumber(redis.call('HGET', KEYS[1], 'epoch')) ~= ticket.epoch then return 0 end\nif now - ticket.startedAt > tonumber(ARGV[7]) or now < ticket.startedAt then return 0 end\nlocal existing = decode(redis.call('GET', KEYS[2]))\nif (existing and existing.version or '') ~= ticket.version then return 0 end\nlocal entry = {\n incarnation=ticket.incarnation, epoch=ticket.epoch, version=ARGV[2],\n tags=decode(ARGV[3]), payload=ARGV[4], expiresAt=now+tonumber(ARGV[5])\n}\nredis.call('SET', KEYS[2], cjson.encode(entry), 'PX', ARGV[5])\nredis.call('PEXPIRE', KEYS[1], ARGV[6])\nreturn 1\n";
3
+ export declare const NEXT_CACHE_INVALIDATE_SCRIPT = "\nlocal clock = redis.call('TIME')\nlocal now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)\nlocal function decode(value)\n if not value then return nil end\n local ok, parsed = pcall(cjson.decode, value)\n if ok and type(parsed) == 'table' then return parsed end\n return nil\nend\nlocal function initialize(nonce, retention)\n local incarnation = redis.call('HGET', KEYS[1], 'incarnation')\n if not incarnation then\n redis.call('DEL', KEYS[1])\n redis.call('HSET', KEYS[1], 'incarnation', nonce, 'epoch', '0')\n incarnation = nonce\n end\n redis.call('PEXPIRE', KEYS[1], retention)\n return incarnation\nend\n\nlocal tags = decode(ARGV[3])\nif #tags == 0 then return 1 end\n-- Bound metadata growth. Rotating its incarnation safely invalidates all entries.\nif redis.call('HLEN', KEYS[1]) + #tags > tonumber(ARGV[5]) + 2 then redis.call('DEL', KEYS[1]) end\ninitialize(ARGV[1], ARGV[2])\nlocal epoch = redis.call('HINCRBY', KEYS[1], 'epoch', 1)\nlocal expireAt = tonumber(ARGV[4]) < 0 and -1 or now + tonumber(ARGV[4])\nfor _, tag in ipairs(tags) do\n local state = decode(redis.call('HGET', KEYS[1], 'tag:' .. tag)) or {floor=0, events={}}\n table.insert(state.events, {epoch=epoch, expireAt=expireAt})\n if #state.events > 16 then\n local removed = table.remove(state.events, 1)\n state.floor = removed.epoch\n end\n redis.call('HSET', KEYS[1], 'tag:' .. tag, cjson.encode(state))\nend\nreturn 1\n";