@sequenceholdings/artifact-studio 0.2.5 → 0.3.0

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 (187) hide show
  1. package/README.md +35 -0
  2. package/VERSION-PIN.md +14 -3
  3. package/dist/api.d.ts +11 -1
  4. package/dist/api.js +26 -9
  5. package/dist/atlas-ui-css-plugin.d.ts +11 -7
  6. package/dist/atlas-ui-css-plugin.js +38 -10
  7. package/dist/build.d.ts +2 -2
  8. package/dist/build.js +14 -6
  9. package/dist/cli.d.ts +1 -1
  10. package/dist/cli.js +108 -37
  11. package/dist/dev-link.d.ts +1 -1
  12. package/dist/dev-link.js +2 -1
  13. package/dist/gateway/admission.d.ts +55 -0
  14. package/dist/gateway/admission.js +132 -0
  15. package/dist/gateway/control.d.ts +91 -0
  16. package/dist/gateway/control.js +113 -0
  17. package/dist/gateway/hosted-proxy.d.ts +36 -0
  18. package/dist/gateway/hosted-proxy.js +347 -0
  19. package/dist/gateway/release-admission.d.ts +23 -0
  20. package/dist/gateway/release-admission.js +259 -0
  21. package/dist/gateway/server.d.ts +29 -0
  22. package/dist/gateway/server.js +298 -0
  23. package/dist/gateway/session.d.ts +71 -0
  24. package/dist/gateway/session.js +143 -0
  25. package/dist/gateway/usage.d.ts +37 -0
  26. package/dist/gateway/usage.js +46 -0
  27. package/dist/gateway-main.d.ts +1 -0
  28. package/dist/gateway-main.js +84 -0
  29. package/dist/git-clone.d.ts +23 -1
  30. package/dist/git-clone.js +32 -15
  31. package/dist/manifest-next.d.ts +217 -0
  32. package/dist/manifest-next.js +120 -0
  33. package/dist/manifest-static.d.ts +76 -0
  34. package/dist/manifest-static.js +124 -0
  35. package/dist/manifest.d.ts +124 -19
  36. package/dist/manifest.js +12 -118
  37. package/dist/next/bridge.d.ts +47 -0
  38. package/dist/next/bridge.js +229 -0
  39. package/dist/next/browser-logs.d.ts +7 -0
  40. package/dist/next/browser-logs.js +51 -0
  41. package/dist/next/browser.d.ts +6 -0
  42. package/dist/next/browser.js +57 -0
  43. package/dist/next/protocol.d.ts +73 -0
  44. package/dist/next/protocol.js +56 -0
  45. package/dist/next/release.d.ts +15 -0
  46. package/dist/next/release.js +46 -0
  47. package/dist/next/requests.d.ts +15 -0
  48. package/dist/next/requests.js +59 -0
  49. package/dist/next/routes.d.ts +4 -0
  50. package/dist/next/routes.js +21 -0
  51. package/dist/next/streams.d.ts +16 -0
  52. package/dist/next/streams.js +132 -0
  53. package/dist/next/theme.d.ts +10 -0
  54. package/dist/next/theme.js +45 -0
  55. package/dist/next/transport.d.ts +15 -0
  56. package/dist/next/transport.js +13 -0
  57. package/dist/next-build.d.ts +29 -0
  58. package/dist/next-build.js +129 -0
  59. package/dist/next-cache/adapter-runtime.d.ts +51 -0
  60. package/dist/next-cache/adapter-runtime.js +73 -0
  61. package/dist/next-cache/component-handler.d.ts +3 -0
  62. package/dist/next-cache/component-handler.js +10 -0
  63. package/dist/next-cache/components.d.ts +20 -0
  64. package/dist/next-cache/components.js +113 -0
  65. package/dist/next-cache/config.d.ts +9 -0
  66. package/dist/next-cache/config.js +78 -0
  67. package/dist/next-cache/incremental-handler.d.ts +18 -0
  68. package/dist/next-cache/incremental-handler.js +17 -0
  69. package/dist/next-cache/incremental.d.ts +27 -0
  70. package/dist/next-cache/incremental.js +99 -0
  71. package/dist/next-cache/index.d.ts +18 -0
  72. package/dist/next-cache/index.js +16 -0
  73. package/dist/next-cache/memory-store.d.ts +27 -0
  74. package/dist/next-cache/memory-store.js +118 -0
  75. package/dist/next-cache/redis-scripts.d.ts +3 -0
  76. package/dist/next-cache/redis-scripts.js +85 -0
  77. package/dist/next-cache/redis-store.d.ts +18 -0
  78. package/dist/next-cache/redis-store.js +56 -0
  79. package/dist/next-cache/runtime.d.ts +29 -0
  80. package/dist/next-cache/runtime.js +63 -0
  81. package/dist/next-cache/serialization.d.ts +39 -0
  82. package/dist/next-cache/serialization.js +333 -0
  83. package/dist/next-cache/store.d.ts +54 -0
  84. package/dist/next-cache/store.js +45 -0
  85. package/dist/next-cache/valkey-transport.d.ts +52 -0
  86. package/dist/next-cache/valkey-transport.js +189 -0
  87. package/dist/next-dev-control.d.ts +48 -0
  88. package/dist/next-dev-control.js +63 -0
  89. package/dist/next-dev-process.d.ts +17 -0
  90. package/dist/next-dev-process.js +110 -0
  91. package/dist/next-dev.d.ts +12 -0
  92. package/dist/next-dev.js +267 -0
  93. package/dist/next-image-config.d.ts +9 -0
  94. package/dist/next-image-config.js +101 -0
  95. package/dist/next-project.d.ts +22 -0
  96. package/dist/next-project.js +64 -0
  97. package/dist/next-public-fonts-local.d.ts +7 -0
  98. package/dist/next-public-fonts-local.js +64 -0
  99. package/dist/next-public-fonts.d.ts +13 -0
  100. package/dist/next-public-fonts.js +49 -0
  101. package/dist/next-source-hash.d.ts +14 -0
  102. package/dist/next-source-hash.js +18 -0
  103. package/dist/next-source.d.ts +35 -0
  104. package/dist/next-source.js +155 -0
  105. package/dist/next.d.ts +9 -0
  106. package/dist/next.js +5 -0
  107. package/dist/project.d.ts +3 -0
  108. package/dist/project.js +9 -1
  109. package/dist/runtime-contracts.d.ts +229 -0
  110. package/dist/runtime-contracts.js +224 -0
  111. package/dist/sdk.d.ts +420 -1
  112. package/dist/sdk.js +35 -0
  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 +67 -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 +74 -0
  122. package/dist/server/request-context.js +103 -0
  123. package/dist/server/runtime-config.d.ts +53 -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 +151 -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/next.config.mjs +27 -0
  140. package/dist/templates/nextjs/package.json +32 -0
  141. package/dist/templates/nextjs/pnpm-workspace.yaml +8 -0
  142. package/dist/templates/nextjs/postcss.config.mjs +5 -0
  143. package/dist/templates/nextjs/tsconfig.json +20 -0
  144. package/dist/templates/react-vite/artifact.bundle.yml +0 -1
  145. package/dist/templates/react-vite/package.json +1 -1
  146. package/dist/tunnel/broker.d.ts +75 -0
  147. package/dist/tunnel/broker.js +451 -0
  148. package/dist/tunnel/client.d.ts +22 -0
  149. package/dist/tunnel/client.js +177 -0
  150. package/dist/tunnel/index.d.ts +6 -0
  151. package/dist/tunnel/index.js +4 -0
  152. package/dist/tunnel/io.d.ts +33 -0
  153. package/dist/tunnel/io.js +108 -0
  154. package/dist/tunnel/peer.d.ts +25 -0
  155. package/dist/tunnel/peer.js +147 -0
  156. package/dist/tunnel/protocol.d.ts +69 -0
  157. package/dist/tunnel/protocol.js +196 -0
  158. package/dist/tunnel/redis-ownership.d.ts +38 -0
  159. package/dist/tunnel/redis-ownership.js +100 -0
  160. package/dist/tunnel/redis-socket.d.ts +13 -0
  161. package/dist/tunnel/redis-socket.js +48 -0
  162. package/dist/tunnel/redis-types.d.ts +70 -0
  163. package/dist/tunnel/redis-types.js +9 -0
  164. package/dist/tunnel/redis.d.ts +53 -0
  165. package/dist/tunnel/redis.js +391 -0
  166. package/dist/tunnel/transport.d.ts +17 -0
  167. package/dist/tunnel/transport.js +1 -0
  168. package/package.json +68 -4
  169. package/templates/nextjs/.artifactignore +14 -0
  170. package/templates/nextjs/README.md +151 -0
  171. package/templates/nextjs/app/about/page.tsx +26 -0
  172. package/templates/nextjs/app/actions.ts +18 -0
  173. package/templates/nextjs/app/api/health/route.ts +6 -0
  174. package/templates/nextjs/app/components/counter.tsx +19 -0
  175. package/templates/nextjs/app/components/greeting-form.tsx +38 -0
  176. package/templates/nextjs/app/globals.css +4 -0
  177. package/templates/nextjs/app/layout.tsx +27 -0
  178. package/templates/nextjs/app/page.tsx +51 -0
  179. package/templates/nextjs/app/providers.tsx +107 -0
  180. package/templates/nextjs/artifact.bundle.yml +55 -0
  181. package/templates/nextjs/next.config.mjs +27 -0
  182. package/templates/nextjs/package.json +32 -0
  183. package/templates/nextjs/pnpm-workspace.yaml +8 -0
  184. package/templates/nextjs/postcss.config.mjs +5 -0
  185. package/templates/nextjs/tsconfig.json +20 -0
  186. package/templates/react-vite/artifact.bundle.yml +0 -1
  187. package/templates/react-vite/package.json +1 -1
@@ -0,0 +1,267 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import { deleteJson } from './api.js';
8
+ import { createScrubbedChildEnv } from './child-environment.js';
9
+ import { acquireDevLock } from './dev-lock.js';
10
+ import { hashLinkedSources, rebuildLinkedDep, resolveLinkedDeps, syncLinkedDist } from './dev-link.js';
11
+ import { readArtifactStudioSource } from './project.js';
12
+ import { createNextChildEnv, validateNextProject } from './next-project.js';
13
+ import { materializeNextPublicFonts } from './next-public-fonts-local.js';
14
+ import { isTransientNextControlFailure, requestNextDevLease, renewNextDevLease, writeNextRuntimeFile } from './next-dev-control.js';
15
+ import { openNextPreview, selectNextDevPort, startNextDevProcess } from './next-dev-process.js';
16
+ import { connectLocalTunnel } from './tunnel/index.js';
17
+ /** Shutdown never waits longer than this on Atlas; an unconfirmed session still expires with its lease. */
18
+ const SESSION_REVOCATION_TIMEOUT_MS = 10_000;
19
+ export async function runNextDevelopment({ root, manifest, projectId, baseUrl, environment, previewKey, port: requestedPort, links, getToken }) {
20
+ const { appRoot, nextBin } = await validateNextProject({ root, manifest, installed: true });
21
+ if (!nextBin)
22
+ throw new Error('Missing project-local Next.js executable. Run pnpm install.');
23
+ await materializeNextPublicFonts({ appRoot, fonts: manifest.build.public_fonts });
24
+ const port = await selectNextDevPort({ requested: requestedPort });
25
+ const source = await readArtifactStudioSource(root);
26
+ const initialManifest = await readFile(join(root, 'artifact.bundle.yml'), 'utf8');
27
+ let dependenciesHash = await nextDependencyHash({ root, appRoot });
28
+ const releaseLock = await acquireDevLock({ dir: root, previewKey });
29
+ const privateRoot = await mkdtemp(join(tmpdir(), 'seq-artifact-next-dev-'));
30
+ const runtimeFile = join(privateRoot, 'runtime.json');
31
+ const controller = new AbortController();
32
+ const onSignal = () => controller.abort();
33
+ process.once('SIGINT', onSignal);
34
+ process.once('SIGTERM', onSignal);
35
+ let lease;
36
+ let child;
37
+ let status = 'starting';
38
+ let exitCode = 0;
39
+ const tasks = [];
40
+ let sessionMutation = Promise.resolve();
41
+ function serialSessionMutation(operation) {
42
+ const pending = sessionMutation.then(operation);
43
+ sessionMutation = pending.catch(() => { });
44
+ return pending;
45
+ }
46
+ try {
47
+ const linked = resolveLinkedDeps({ links, resolver: createRequire(join(appRoot, 'package.json')).resolve });
48
+ const linkedEnv = createScrubbedChildEnv({ homeDir: privateRoot });
49
+ let linkedHash;
50
+ const refreshLinked = async () => {
51
+ if (!linked.length)
52
+ return;
53
+ const hash = await hashLinkedSources(linked);
54
+ if (hash === linkedHash)
55
+ return;
56
+ for (const dep of linked) {
57
+ console.log(`[seq-studio] rebuilding linked ${dep.name}`);
58
+ await rebuildLinkedDep(dep, controller.signal, linkedEnv);
59
+ await syncLinkedDist(dep);
60
+ }
61
+ linkedHash = hash;
62
+ // Webpack observes source changes itself. Linked dependencies are copied
63
+ // into node_modules, which is excluded from its watcher, so restart after
64
+ // a completed package rebuild while retaining the same tunnel session.
65
+ if (child)
66
+ await child.stop();
67
+ };
68
+ await refreshLinked();
69
+ lease = await requestNextDevLease({ baseUrl, token: await getToken(), projectId, environment,
70
+ body: { manifest, sourceHash: source.sourceHash, previewKey }, signal: controller.signal,
71
+ });
72
+ await writeNextRuntimeFile({ path: runtimeFile, projectId, lease });
73
+ const env = createNextChildEnv({ homeDir: privateRoot, publicEnv: manifest.build.public_env, publicOrigin: lease.session.publicOrigin, egressHosts: manifest.server.egress, runtimeFile });
74
+ const startChild = () => startNextDevProcess({ appRoot, nextBin, port, env, signal: controller.signal,
75
+ onStatus: (value) => { status = value; },
76
+ });
77
+ child = startChild();
78
+ await child.ready;
79
+ console.log(`[seq-studio] Next.js running on loopback port ${port}; source stays on this machine.`);
80
+ console.log(`[seq-studio] owner-only Atlas preview: ${lease.session.previewUrl}`);
81
+ console.log('[seq-studio] Fast Refresh is active. Press Ctrl+C to stop and revoke the preview.');
82
+ const initialLease = lease;
83
+ let opened = false;
84
+ tasks.push(runTunnelConnections({
85
+ lease: initialLease, environment, port, signal: controller.signal,
86
+ refreshLease: (previous) => serialSessionMutation(async () => {
87
+ const renewed = await requestNextDevLease({ baseUrl, token: await getToken(), projectId, environment,
88
+ sessionId: previous.session.id, body: { epoch: previous.tunnel.epoch, reconnect: true, status: 'reconnecting' },
89
+ signal: controller.signal,
90
+ });
91
+ if (renewed.tunnel.epoch <= previous.tunnel.epoch || renewed.session.publicOrigin !== previous.session.publicOrigin || renewed.runtime.environment !== previous.runtime.environment) {
92
+ throw new Error('Atlas returned a stale or changed tunnel lease. Start artifact dev again.');
93
+ }
94
+ await writeNextRuntimeFile({ path: runtimeFile, projectId, lease: renewed });
95
+ lease = renewed;
96
+ return renewed;
97
+ }), onStatus: (value) => { status = value; }, onConnected: () => {
98
+ if (!opened) {
99
+ opened = true;
100
+ openNextPreview(initialLease.session.previewUrl);
101
+ }
102
+ },
103
+ }));
104
+ tasks.push(runDevelopmentMaintenance({ signal: controller.signal, tick: () => serialSessionMutation(async () => {
105
+ if (!lease)
106
+ return;
107
+ try {
108
+ await renewNextDevLease({ baseUrl, token: await getToken(), projectId, lease, status, signal: controller.signal });
109
+ }
110
+ catch (error) {
111
+ // Authorization/revocation failures stop immediately. Network failures
112
+ // may recover only within the last server-authorized lease; the gateway
113
+ // independently verifies expiry and permissions on incoming requests.
114
+ if (!isTransientNextControlFailure(error) || Date.now() >= Date.parse(lease.session.expiresAt))
115
+ throw error;
116
+ console.warn('[seq-studio] Atlas is temporarily unavailable; retrying within the current preview lease.');
117
+ }
118
+ }), intervalMs: 30_000 }));
119
+ if (linked.length)
120
+ tasks.push(runDevelopmentMaintenance({ signal: controller.signal, tick: refreshLinked, intervalMs: 1500 }));
121
+ tasks.push(runDevelopmentMaintenance({ signal: controller.signal, intervalMs: 1500, tick: async () => {
122
+ if (await readFile(join(root, 'artifact.bundle.yml'), 'utf8') !== initialManifest) {
123
+ throw new Error('artifact.bundle.yml changed. Restart artifact dev to authorize the updated runtime configuration and capabilities.');
124
+ }
125
+ const updated = await nextDependencyHash({ root, appRoot });
126
+ if (updated !== dependenciesHash) {
127
+ await validateNextProject({ root, manifest, installed: true });
128
+ dependenciesHash = updated;
129
+ console.log('[seq-studio] Dependencies changed; restarting the local Next.js server.');
130
+ await child?.stop();
131
+ }
132
+ } }));
133
+ tasks.push(superviseNextProcess({ initial: child, start: startChild, signal: controller.signal, onChild: (value) => { child = value; } }));
134
+ await Promise.race(tasks);
135
+ }
136
+ catch (error) {
137
+ if (!controller.signal.aborted) {
138
+ console.error(`[seq-studio] ${error instanceof Error ? error.message : String(error)}`);
139
+ exitCode = 1;
140
+ }
141
+ }
142
+ finally {
143
+ // Aborting cancels in-flight lease renewals and reconnects (they share controller.signal),
144
+ // so allSettled returns promptly; revocation below then gets its own bounded attempt.
145
+ controller.abort();
146
+ await child?.stop();
147
+ await Promise.allSettled(tasks);
148
+ if (lease) {
149
+ try {
150
+ await deleteJson({ baseUrl, token: await getToken(), path: `/api/artifact-studio/projects/${encodeURIComponent(projectId)}/next/dev-sessions/${encodeURIComponent(lease.session.id)}`,
151
+ signal: AbortSignal.timeout(SESSION_REVOCATION_TIMEOUT_MS) });
152
+ }
153
+ catch {
154
+ console.warn('[seq-studio] Could not confirm session revocation. Its lease will expire; disconnect it in Atlas if needed.');
155
+ }
156
+ }
157
+ process.removeListener('SIGINT', onSignal);
158
+ process.removeListener('SIGTERM', onSignal);
159
+ releaseLock();
160
+ await rm(privateRoot, { recursive: true, force: true });
161
+ }
162
+ return exitCode;
163
+ }
164
+ async function nextDependencyHash({ root, appRoot }) {
165
+ const hash = createHash('sha256');
166
+ for (const path of [...new Set([join(root, 'package.json'), join(root, 'pnpm-lock.yaml'), join(appRoot, 'package.json')])]) {
167
+ hash.update(await readFile(path));
168
+ hash.update('\0');
169
+ }
170
+ return hash.digest('hex');
171
+ }
172
+ async function superviseNextProcess({ initial, start, signal, onChild }) {
173
+ let current = initial;
174
+ let failures = 0;
175
+ while (!signal.aborted) {
176
+ const started = Date.now();
177
+ try {
178
+ await current.closed;
179
+ }
180
+ catch (error) {
181
+ if (signal.aborted)
182
+ return;
183
+ failures = Date.now() - started > 60_000 ? 1 : failures + 1;
184
+ if (failures > 3)
185
+ throw error;
186
+ console.warn('[seq-studio] Next.js stopped unexpectedly; restarting the local server.');
187
+ }
188
+ if (signal.aborted)
189
+ return;
190
+ try {
191
+ await delay(1000, undefined, { signal });
192
+ }
193
+ catch {
194
+ return;
195
+ }
196
+ current = start();
197
+ onChild(current);
198
+ // The process's closed promise handles failed startups on the next loop.
199
+ await current.ready.catch(() => { });
200
+ }
201
+ }
202
+ async function runDevelopmentMaintenance({ signal, tick, intervalMs }) {
203
+ while (!signal.aborted) {
204
+ try {
205
+ await delay(intervalMs, undefined, { signal });
206
+ }
207
+ catch {
208
+ return;
209
+ }
210
+ if (!signal.aborted)
211
+ await tick();
212
+ }
213
+ }
214
+ async function runTunnelConnections({ lease: initialLease, environment, port, refreshLease, signal, onStatus, onConnected }) {
215
+ let lease = initialLease;
216
+ let attempt = 0;
217
+ while (!signal.aborted) {
218
+ try {
219
+ const connection = await connectLocalTunnel({
220
+ url: lease.tunnel.url, token: lease.tunnel.token, sessionId: lease.session.id, epoch: lease.tunnel.epoch,
221
+ localPort: port, publicOrigin: lease.session.publicOrigin, signal, allowInsecureLoopback: environment === 'local',
222
+ });
223
+ attempt = 0;
224
+ onStatus('ready');
225
+ onConnected();
226
+ const closed = await connection.closed;
227
+ if (signal.aborted)
228
+ return;
229
+ if (closed.code === 1008 || closed.code === 4001 || closed.code === 4003) {
230
+ throw new Error('Atlas revoked or replaced this development session. Start artifact dev again.');
231
+ }
232
+ console.warn('[seq-studio] Preview tunnel disconnected; reconnecting without replaying requests.');
233
+ }
234
+ catch (error) {
235
+ if (signal.aborted)
236
+ return;
237
+ if (error instanceof Error && /revoked or replaced/.test(error.message))
238
+ throw error;
239
+ console.warn('[seq-studio] Preview tunnel unavailable; acquiring a fresh connection lease.');
240
+ }
241
+ onStatus('reconnecting');
242
+ attempt += 1;
243
+ try {
244
+ await delay(Math.min(1000 * 2 ** Math.min(attempt, 5), 30_000), undefined, { signal });
245
+ }
246
+ catch {
247
+ return;
248
+ }
249
+ let refreshed = false;
250
+ while (!signal.aborted && !refreshed) {
251
+ try {
252
+ lease = await refreshLease(lease);
253
+ refreshed = true;
254
+ }
255
+ catch (error) {
256
+ if (!isTransientNextControlFailure(error) || Date.now() >= Date.parse(lease.session.expiresAt))
257
+ throw error;
258
+ try {
259
+ await delay(5000, undefined, { signal });
260
+ }
261
+ catch {
262
+ return;
263
+ }
264
+ }
265
+ }
266
+ }
267
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Policy for Next's managed image optimizer. The hosted network remains the
3
+ * authoritative egress boundary; this also prevents accidental local dev SSRF.
4
+ * It does not govern arbitrary application fetches or browser image elements.
5
+ */
6
+ export declare function resolveArtifactNextImages({ images, egressHosts }: {
7
+ images: unknown;
8
+ egressHosts: readonly string[];
9
+ }): Record<string, unknown>;
@@ -0,0 +1,101 @@
1
+ const MAX_IMAGE_RESPONSE_BYTES = 10 * 1024 * 1024;
2
+ const PUBLIC_HOST = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
3
+ const REMOTE_PATTERN_FIELDS = new Set(['protocol', 'hostname', 'port', 'pathname', 'search']);
4
+ function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ function publicHost(value) {
8
+ return typeof value === 'string' && !/[\s\u0000-\u001f\u007f]/.test(value) && PUBLIC_HOST.test(value) &&
9
+ !/(?:^|\.)(?:localhost|local|internal)$/.test(value);
10
+ }
11
+ /**
12
+ * Policy for Next's managed image optimizer. The hosted network remains the
13
+ * authoritative egress boundary; this also prevents accidental local dev SSRF.
14
+ * It does not govern arbitrary application fetches or browser image elements.
15
+ */
16
+ export function resolveArtifactNextImages({ images, egressHosts }) {
17
+ if (images !== undefined && !isRecord(images))
18
+ throw new Error('Invalid Next images configuration');
19
+ if (!Array.isArray(egressHosts) || egressHosts.length > 64 || !egressHosts.every(publicHost)) {
20
+ throw new Error('Image optimization requires a valid exact-host Artifact server.egress policy');
21
+ }
22
+ const source = images ?? {};
23
+ assertManagedOptimizer(source);
24
+ const responseLimit = source.maximumResponseBody ?? MAX_IMAGE_RESPONSE_BYTES;
25
+ if (typeof responseLimit !== 'number' || !Number.isSafeInteger(responseLimit) ||
26
+ responseLimit < 1 || responseLimit > MAX_IMAGE_RESPONSE_BYTES) {
27
+ throw new Error('images.maximumResponseBody must be between 1 byte and 10 MiB');
28
+ }
29
+ const patterns = source.remotePatterns ?? [];
30
+ if (!Array.isArray(patterns) || patterns.length > 64)
31
+ throw new Error('images.remotePatterns must contain at most 64 explicit patterns');
32
+ return {
33
+ ...source,
34
+ remotePatterns: patterns.map((pattern, index) => resolveRemotePattern({ pattern, index, egressHosts })),
35
+ maximumRedirects: 0,
36
+ maximumResponseBody: responseLimit,
37
+ dangerouslyAllowLocalIP: false,
38
+ dangerouslyAllowSVG: false,
39
+ };
40
+ }
41
+ function assertManagedOptimizer(images) {
42
+ if ((images.loader !== undefined && images.loader !== 'default') ||
43
+ (images.loaderFile !== undefined && images.loaderFile !== '') ||
44
+ (images.unoptimized !== undefined && images.unoptimized !== false)) {
45
+ throw new Error('Artifact images must use the managed default optimizer; custom loaders and images.unoptimized are unsupported');
46
+ }
47
+ if (images.path !== undefined && images.path !== '/_next/image' && images.path !== '/_next/image/') {
48
+ throw new Error('Artifact images must use the managed /_next/image optimizer path');
49
+ }
50
+ if (images.domains !== undefined && (!Array.isArray(images.domains) || images.domains.length > 0)) {
51
+ throw new Error('Use exact HTTPS images.remotePatterns instead of the legacy images.domains setting');
52
+ }
53
+ if (images.dangerouslyAllowLocalIP === true || images.dangerouslyAllowSVG === true) {
54
+ throw new Error('Artifact images cannot enable dangerouslyAllowLocalIP or dangerouslyAllowSVG; use local public SVG files directly');
55
+ }
56
+ if (images.maximumRedirects !== undefined && images.maximumRedirects !== 0) {
57
+ // Next does not re-check redirected image destinations against remotePatterns.
58
+ throw new Error('images.maximumRedirects must be 0; declare and use the final approved HTTPS image URL');
59
+ }
60
+ }
61
+ function resolveRemotePattern({ pattern, index, egressHosts }) {
62
+ const label = `images.remotePatterns[${index}]`;
63
+ const value = remotePatternFields({ pattern, label });
64
+ if (value.protocol !== 'https' || !publicHost(value.hostname) ||
65
+ (value.port !== undefined && value.port !== '' && value.port !== '443')) {
66
+ throw new Error(`${label} requires an exact public HTTPS hostname on port 443; wildcard, IP and local hosts are unsupported`);
67
+ }
68
+ if (!egressHosts.includes(value.hostname)) {
69
+ throw new Error(`${label} hostname must be declared in server.egress; run seq-studio artifact dev/build to supply the application's image policy`);
70
+ }
71
+ const pathname = remotePatternPathname({ pathname: value.pathname ?? '/**', label });
72
+ const search = remotePatternSearch({ search: value.search, label });
73
+ return { protocol: 'https', hostname: value.hostname, port: '', pathname, ...(search === undefined ? {} : { search }) };
74
+ }
75
+ /** A URL pattern is reduced to its matchable fields; object patterns may carry only those fields. */
76
+ function remotePatternFields({ pattern, label }) {
77
+ if (pattern instanceof URL) {
78
+ if (pattern.username || pattern.password || pattern.hash)
79
+ throw new Error(`${label} cannot contain URL credentials or fragments`);
80
+ return { protocol: pattern.protocol.replace(/:$/, ''), hostname: pattern.hostname,
81
+ port: pattern.port, pathname: pattern.pathname, search: pattern.search };
82
+ }
83
+ if (!isRecord(pattern) || Object.keys(pattern).some((key) => !REMOTE_PATTERN_FIELDS.has(key))) {
84
+ throw new Error(`${label} must be a URL or a supported remote pattern object`);
85
+ }
86
+ return pattern;
87
+ }
88
+ function remotePatternPathname({ pathname, label }) {
89
+ if (typeof pathname !== 'string' || !pathname.startsWith('/') || pathname.length > 2048 || /[\\\u0000-\u001f\u007f]/.test(pathname)) {
90
+ throw new Error(`${label}.pathname must be an absolute image path pattern`);
91
+ }
92
+ return pathname;
93
+ }
94
+ function remotePatternSearch({ search, label }) {
95
+ if (search === undefined)
96
+ return undefined;
97
+ if (typeof search !== 'string' || (search !== '' && !search.startsWith('?')) || search.length > 2048 || /[\u0000-\u001f\u007f]/.test(search)) {
98
+ throw new Error(`${label}.search must be empty or an exact query string beginning with ?`);
99
+ }
100
+ return search;
101
+ }
@@ -0,0 +1,22 @@
1
+ import { type ArtifactStudioNextManifest } from './manifest.js';
2
+ export declare function validateNextProject({ root, manifest, installed, nodeVersion }: {
3
+ root: string;
4
+ manifest: ArtifactStudioNextManifest;
5
+ installed: boolean;
6
+ nodeVersion?: string;
7
+ }): Promise<{
8
+ appRoot: string;
9
+ nextBin?: string;
10
+ }>;
11
+ export declare function validateNextPublicOrigin({ value, environment }: {
12
+ value: string;
13
+ environment: string;
14
+ }): string;
15
+ export declare function createNextChildEnv({ homeDir, publicEnv, publicOrigin, egressHosts, runtimeFile, source }: {
16
+ homeDir: string;
17
+ publicEnv: Record<string, string>;
18
+ publicOrigin: string;
19
+ egressHosts?: readonly string[];
20
+ runtimeFile?: string;
21
+ source?: NodeJS.ProcessEnv;
22
+ }): NodeJS.ProcessEnv;
@@ -0,0 +1,64 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import { join, resolve } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { createScrubbedChildEnv } from './child-environment.js';
7
+ import { ARTIFACT_NEXT_APPROVED_PACKAGES } from './manifest.js';
8
+ const packageSchema = z.object({
9
+ dependencies: z.record(z.string(), z.string()).default({}),
10
+ devDependencies: z.record(z.string(), z.string()).default({}),
11
+ });
12
+ export async function validateNextProject({ root, manifest, installed, nodeVersion = process.versions.node }) {
13
+ const appRoot = resolve(root, manifest.artifact.app_root);
14
+ const pkg = packageSchema.parse(JSON.parse(await readFile(join(appRoot, 'package.json'), 'utf8')));
15
+ const dependencies = { ...pkg.devDependencies, ...pkg.dependencies };
16
+ for (const [dependency, approved] of Object.entries(ARTIFACT_NEXT_APPROVED_PACKAGES)) {
17
+ if (dependencies[dependency] !== approved) {
18
+ throw new Error(`The application must pin its own approved ${dependency}@${approved} dependency in package.json. Platform React aliases and version ranges are not used for Next.js 16.3.`);
19
+ }
20
+ }
21
+ if (!existsSync(join(appRoot, 'app')) && !existsSync(join(appRoot, 'src', 'app'))) {
22
+ throw new Error('Next.js artifacts require an App Router app/ or src/app/ directory.');
23
+ }
24
+ if (existsSync(join(appRoot, 'pages')) || existsSync(join(appRoot, 'src', 'pages'))) {
25
+ throw new Error('Artifact Studio supports App Router applications; move Pages Router routes into app/.');
26
+ }
27
+ if (!installed)
28
+ return { appRoot };
29
+ if (!nodeVersion.startsWith('24.'))
30
+ throw new Error(`Next.js artifact development requires Node.js 24 (current: ${nodeVersion}).`);
31
+ // Do not silently resolve a parent's or the CLI's copy of Next/React.
32
+ for (const [dependency, approved] of Object.entries(ARTIFACT_NEXT_APPROVED_PACKAGES)) {
33
+ const packagePath = join(appRoot, 'node_modules', dependency, 'package.json');
34
+ if (!existsSync(packagePath))
35
+ throw new Error(`Missing local ${dependency}. Run pnpm install in ${appRoot}.`);
36
+ const parsed = z.object({ version: z.string() }).parse(JSON.parse(await readFile(packagePath, 'utf8')));
37
+ if (parsed.version !== approved)
38
+ throw new Error(`Unsupported installed ${dependency}@${parsed.version}; approved version is ${approved}. Run pnpm install with the approved toolchain.`);
39
+ }
40
+ const nextBin = createRequire(join(appRoot, 'package.json')).resolve('next/dist/bin/next');
41
+ return { appRoot, nextBin };
42
+ }
43
+ export function validateNextPublicOrigin({ value, environment }) {
44
+ const url = new URL(value);
45
+ const local = environment === 'local' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
46
+ if ((url.protocol !== 'https:' && !(local && url.protocol === 'http:')) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
47
+ throw new Error('Atlas returned an invalid application origin; an exact credential-free HTTPS origin is required.');
48
+ }
49
+ return url.origin;
50
+ }
51
+ export function createNextChildEnv({ homeDir, publicEnv, publicOrigin, egressHosts = [], runtimeFile, source }) {
52
+ for (const name of Object.keys(publicEnv)) {
53
+ if (!/^NEXT_PUBLIC_[A-Z0-9_]+$/.test(name))
54
+ throw new Error('Build environment names must start with NEXT_PUBLIC_.');
55
+ }
56
+ return createScrubbedChildEnv({ homeDir, source, additionalEnv: {
57
+ ...publicEnv,
58
+ NODE_ENV: 'development',
59
+ NEXT_TELEMETRY_DISABLED: '1',
60
+ ARTIFACT_STUDIO_DEV_ORIGIN: publicOrigin,
61
+ ARTIFACT_NEXT_ALLOWED_EGRESS: JSON.stringify(egressHosts),
62
+ ...(runtimeFile ? { SEQUENCE_ARTIFACT_RUNTIME_FILE: runtimeFile } : {}),
63
+ } });
64
+ }
@@ -0,0 +1,7 @@
1
+ import { type ArtifactNextPublicFont } from './manifest-next.js';
2
+ /** Materializes only declared files; an existing unmatching file is never replaced. */
3
+ export declare function materializeNextPublicFonts({ appRoot, fonts, fetcher }: {
4
+ appRoot: string;
5
+ fonts: readonly ArtifactNextPublicFont[];
6
+ fetcher?: typeof fetch;
7
+ }): Promise<void>;
@@ -0,0 +1,64 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, mkdir, open, realpath } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { artifactNextBuildConfigSchema } from './manifest-next.js';
5
+ import { fetchNextPublicFont, NEXT_PUBLIC_FONT_MAX_BYTES, NEXT_PUBLIC_FONTS_MAX_BYTES, validateNextPublicFontBytes } from './next-public-fonts.js';
6
+ /** Materializes only declared files; an existing unmatching file is never replaced. */
7
+ export async function materializeNextPublicFonts({ appRoot, fonts, fetcher }) {
8
+ artifactNextBuildConfigSchema.parse({ public_fonts: fonts });
9
+ if (!fonts.length)
10
+ return;
11
+ const canonicalRoot = await realpath(appRoot);
12
+ for (const path of ['public', 'public/fonts']) {
13
+ const directory = join(canonicalRoot, path);
14
+ await mkdir(directory).catch((error) => { if (!hasCode(error, 'EEXIST'))
15
+ throw error; });
16
+ const info = await lstat(directory);
17
+ if (!info.isDirectory() || info.isSymbolicLink() || await realpath(directory) !== directory)
18
+ throw new Error('Declared public font directories cannot use symlinks');
19
+ }
20
+ let total = 0;
21
+ for (const font of fonts) {
22
+ const path = join(canonicalRoot, font.path);
23
+ const existing = await readExistingFont(path);
24
+ const bytes = existing ?? await fetchNextPublicFont({ font, fetcher });
25
+ validateNextPublicFontBytes({ font, bytes });
26
+ total += bytes.length;
27
+ if (total > NEXT_PUBLIC_FONTS_MAX_BYTES)
28
+ throw new Error('Declared public fonts exceed 8 MiB combined');
29
+ if (!existing) {
30
+ const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o644);
31
+ try {
32
+ await handle.writeFile(bytes);
33
+ }
34
+ finally {
35
+ await handle.close();
36
+ }
37
+ }
38
+ }
39
+ }
40
+ async function readExistingFont(path) {
41
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW).catch((error) => {
42
+ if (hasCode(error, 'ENOENT'))
43
+ return undefined;
44
+ throw error;
45
+ });
46
+ if (!handle)
47
+ return undefined;
48
+ try {
49
+ const stat = await handle.stat();
50
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > NEXT_PUBLIC_FONT_MAX_BYTES)
51
+ throw new Error('Existing public font must be a regular file under 2 MiB without hardlinks');
52
+ const bytes = Buffer.alloc(stat.size + 1);
53
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
54
+ if (bytesRead !== stat.size)
55
+ throw new Error('Public font changed while reading; retry development startup');
56
+ return bytes.subarray(0, bytesRead);
57
+ }
58
+ finally {
59
+ await handle.close();
60
+ }
61
+ }
62
+ function hasCode(error, code) {
63
+ return error instanceof Error && 'code' in error && error.code === code;
64
+ }
@@ -0,0 +1,13 @@
1
+ import { type ArtifactNextPublicFont } from './manifest-next.js';
2
+ export declare const NEXT_PUBLIC_FONT_MAX_BYTES: number;
3
+ export declare const NEXT_PUBLIC_FONTS_MAX_BYTES: number;
4
+ /** Format and digest checks apply equally to committed files and fetched inputs. */
5
+ export declare function validateNextPublicFontBytes({ font, bytes }: {
6
+ font: ArtifactNextPublicFont;
7
+ bytes: Buffer;
8
+ }): void;
9
+ /** No author headers, fetch options, alternate host, redirect, or code execution. */
10
+ export declare function fetchNextPublicFont({ font, fetcher }: {
11
+ font: ArtifactNextPublicFont;
12
+ fetcher?: typeof fetch;
13
+ }): Promise<Buffer>;
@@ -0,0 +1,49 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { artifactNextPublicFontSchema } from './manifest-next.js';
3
+ export const NEXT_PUBLIC_FONT_MAX_BYTES = 2 * 1024 * 1024;
4
+ export const NEXT_PUBLIC_FONTS_MAX_BYTES = 8 * 1024 * 1024;
5
+ /** Format and digest checks apply equally to committed files and fetched inputs. */
6
+ export function validateNextPublicFontBytes({ font, bytes }) {
7
+ artifactNextPublicFontSchema.parse(font);
8
+ if (bytes.length > NEXT_PUBLIC_FONT_MAX_BYTES)
9
+ throw new Error(`Public font ${font.path} exceeds 2 MiB`);
10
+ if (bytes.length < 48 || bytes.toString('ascii', 0, 4) !== 'wOF2' || bytes.readUInt32BE(8) !== bytes.length || bytes.readUInt16BE(12) === 0 || bytes.readUInt16BE(14) !== 0) {
11
+ throw new Error(`Public font ${font.path} is not a valid WOFF2 input`);
12
+ }
13
+ if (createHash('sha256').update(bytes).digest('hex') !== font.sha256)
14
+ throw new Error(`Public font ${font.path} does not match its reviewed SHA-256 pin`);
15
+ }
16
+ /** No author headers, fetch options, alternate host, redirect, or code execution. */
17
+ export async function fetchNextPublicFont({ font, fetcher = fetch }) {
18
+ artifactNextPublicFontSchema.parse(font);
19
+ const response = await fetcher(font.url, { redirect: 'error', credentials: 'omit', signal: AbortSignal.timeout(15_000), headers: { accept: 'font/woff2,application/octet-stream' } });
20
+ if (response.status !== 200 || !response.body) {
21
+ await response.body?.cancel();
22
+ throw new Error(`Public font retrieval returned ${response.status}; no redirects or alternate sources are allowed`);
23
+ }
24
+ const length = response.headers.get('content-length');
25
+ if (length && (!/^\d+$/.test(length) || Number(length) > NEXT_PUBLIC_FONT_MAX_BYTES)) {
26
+ await response.body.cancel();
27
+ throw new Error(`Public font ${font.path} exceeds 2 MiB`);
28
+ }
29
+ const reader = response.body.getReader();
30
+ const chunks = [];
31
+ let total = 0;
32
+ try {
33
+ while (true) {
34
+ const { done, value } = await reader.read();
35
+ if (done)
36
+ break;
37
+ total += value.length;
38
+ if (total > NEXT_PUBLIC_FONT_MAX_BYTES)
39
+ throw new Error(`Public font ${font.path} exceeds 2 MiB`);
40
+ chunks.push(Buffer.from(value));
41
+ }
42
+ }
43
+ finally {
44
+ await reader.cancel().catch(() => { });
45
+ }
46
+ const bytes = Buffer.concat(chunks, total);
47
+ validateNextPublicFontBytes({ font, bytes });
48
+ return bytes;
49
+ }
@@ -0,0 +1,14 @@
1
+ export interface HashableNextSourceFile {
2
+ path: string;
3
+ content: string;
4
+ encoding: 'utf8' | 'base64';
5
+ }
6
+ /**
7
+ * Identity of validated Next source bytes, independent of their wire encoding.
8
+ * Callers must first validate canonical, unique paths and encoded contents.
9
+ * Manifest identity comes from artifact.bundle.yml bytes in the source files,
10
+ * not a parsed object whose schema defaults can change across CLI versions.
11
+ */
12
+ export declare function computeNextSourceHash({ files }: {
13
+ files: readonly HashableNextSourceFile[];
14
+ }): string;
@@ -0,0 +1,18 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * Identity of validated Next source bytes, independent of their wire encoding.
4
+ * Callers must first validate canonical, unique paths and encoded contents.
5
+ * Manifest identity comes from artifact.bundle.yml bytes in the source files,
6
+ * not a parsed object whose schema defaults can change across CLI versions.
7
+ */
8
+ export function computeNextSourceHash({ files }) {
9
+ const digest = createHash('sha256');
10
+ // Explicit code-unit ordering is consistent across operating-system locales.
11
+ const ordered = [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
12
+ for (const file of ordered) {
13
+ const bytes = Buffer.from(file.content, file.encoding);
14
+ digest.update(`${Buffer.byteLength(file.path)}:${file.path}:${bytes.length}:`);
15
+ digest.update(bytes);
16
+ }
17
+ return digest.digest('hex');
18
+ }