@01.works/visual-review 0.1.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.
@@ -0,0 +1,406 @@
1
+ import { t as resolveVisualReviewBuildIdentity } from "./build-identity-CgoY3-jy.js";
2
+ import { a as visualReviewSourceIndexFilename, c as writeNextVisualReviewSourceIndex, d as uploadNextSourceMaps, i as nextSourceIndexUrl, u as cleanupNextSourceMapOutput } from "./source-index-BdWeiNHZ.js";
3
+ import { randomBytes } from "node:crypto";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ //#region src/next-config.ts
9
+ const BUILD_ID_LITERAL = "VISUAL_REVIEW_PUBLIC_BUILD_ID";
10
+ const GIT_COMMIT_LITERAL = "VISUAL_REVIEW_PUBLIC_GIT_COMMIT";
11
+ const BUILD_MODE_LITERAL = "VISUAL_REVIEW_PUBLIC_BUILD_MODE";
12
+ const NEXT_BUILD_IDENTITY_RECORD = "VISUAL_REVIEW_INTERNAL_NEXT_BUILD_IDENTITY";
13
+ const NEXT_MARKER_SALT_PATH = "VISUAL_REVIEW_INTERNAL_NEXT_MARKER_SALT_PATH";
14
+ const FULL_GIT_COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u;
15
+ const PUBLIC_BUILD_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
16
+ const DEPLOYMENT_VARIANT = /^[0-9a-f]{32}$/u;
17
+ const GIT_FALLBACK_BUILD_ID = /^(git:([0-9a-f]{40}|[0-9a-f]{64}):deploy:([0-9a-f]{32}))$/u;
18
+ const MAX_NEXT_BUILD_IDENTITY_RECORD_BYTES = 512;
19
+ const trackedMarkerSaltPaths = /* @__PURE__ */ new Set();
20
+ const VISUAL_REVIEW_HOOK = Symbol.for("01works.visual-review.next-source-map-hook");
21
+ const VISUAL_REVIEW_CONFIG = Symbol.for("01works.visual-review.next-config");
22
+ /**
23
+ * Next evaluates next.config again in compiler workers. A Git fallback contains
24
+ * a per-build public nonce, so recomputing it in each process would make the
25
+ * browser literal disagree with the main process upload hook. Next 15/16 copy
26
+ * the main process environment into those workers; hand off only the already
27
+ * public identity and validate it before reuse. Marker salts and upload tokens
28
+ * are deliberately outside this record.
29
+ */
30
+ function resolveNextVisualReviewBuildIdentity(options, env) {
31
+ const privateBuildWorker = env.NEXT_PRIVATE_BUILD_WORKER === "1";
32
+ const nextWorker = env.IS_NEXT_WORKER === "true";
33
+ if (!privateBuildWorker && !nextWorker) {
34
+ delete env[NEXT_BUILD_IDENTITY_RECORD];
35
+ const identity = resolveVisualReviewBuildIdentity(options, {
36
+ cwd: process.cwd(),
37
+ env,
38
+ createDeploymentVariant: () => randomBytes(16).toString("hex")
39
+ });
40
+ const serializedRecord = JSON.stringify({
41
+ schemaVersion: 1,
42
+ buildId: identity.buildId,
43
+ gitCommit: identity.gitCommit
44
+ });
45
+ const record = parseNextBuildIdentityRecord(serializedRecord);
46
+ env[NEXT_BUILD_IDENTITY_RECORD] = serializedRecord;
47
+ return {
48
+ buildId: record.buildId,
49
+ gitCommit: record.gitCommit
50
+ };
51
+ }
52
+ if (!privateBuildWorker || !nextWorker) throw new Error("Visual Review Next.js build worker identity context is invalid");
53
+ const record = parseNextBuildIdentityRecord(env[NEXT_BUILD_IDENTITY_RECORD]);
54
+ const fallbackMatch = GIT_FALLBACK_BUILD_ID.exec(record.buildId);
55
+ const expected = resolveVisualReviewBuildIdentity(options, {
56
+ cwd: process.cwd(),
57
+ env,
58
+ runGit: (arguments_) => {
59
+ if (arguments_[0] === "status") return record.gitCommit === null ? " M .next/visual-review-build-output" : "";
60
+ if (arguments_[0] === "rev-parse" && record.gitCommit !== null) return record.gitCommit;
61
+ throw new Error("Visual Review Next.js worker cannot rediscover Git identity");
62
+ },
63
+ createDeploymentVariant: () => fallbackMatch?.[3] ?? "0".repeat(32)
64
+ });
65
+ if (expected.buildId !== record.buildId || expected.gitCommit !== record.gitCommit) throw new Error("Visual Review Next.js build worker identity does not match the main build");
66
+ return {
67
+ buildId: record.buildId,
68
+ gitCommit: record.gitCommit
69
+ };
70
+ }
71
+ function parseNextBuildIdentityRecord(value) {
72
+ if (typeof value !== "string" || value.length === 0 || Buffer.byteLength(value) > MAX_NEXT_BUILD_IDENTITY_RECORD_BYTES) throw new Error("Visual Review Next.js build worker identity is missing or invalid");
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(value);
76
+ } catch {
77
+ throw new Error("Visual Review Next.js build worker identity is missing or invalid");
78
+ }
79
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Visual Review Next.js build worker identity is missing or invalid");
80
+ const record = parsed;
81
+ if (Object.keys(record).sort().join(",") !== "buildId,gitCommit,schemaVersion" || record.schemaVersion !== 1 || typeof record.buildId !== "string" || !PUBLIC_BUILD_ID.test(record.buildId) || record.gitCommit !== null && (typeof record.gitCommit !== "string" || !FULL_GIT_COMMIT.test(record.gitCommit))) throw new Error("Visual Review Next.js build worker identity is missing or invalid");
82
+ const fallbackMatch = GIT_FALLBACK_BUILD_ID.exec(record.buildId);
83
+ if (/^git:(?:[0-9a-f]{40}|[0-9a-f]{64}):deploy:/u.test(record.buildId) && !fallbackMatch) throw new Error("Visual Review Next.js build worker identity is missing or invalid");
84
+ if (fallbackMatch && (!DEPLOYMENT_VARIANT.test(fallbackMatch[3] ?? "") || fallbackMatch[2] !== record.gitCommit)) throw new Error("Visual Review Next.js build worker identity is missing or invalid");
85
+ return {
86
+ schemaVersion: 1,
87
+ buildId: record.buildId,
88
+ gitCommit: record.gitCommit
89
+ };
90
+ }
91
+ function withVisualReview(nextConfig = {}, options = {}) {
92
+ const identity = resolveNextVisualReviewBuildIdentity(options, process.env);
93
+ const sourceMapUpload = resolveSourceMapUpload(options.sourceMaps, process.env);
94
+ const sourceMetadataEnabled = options.sourceMetadata ?? true;
95
+ const sourceMarkerSalt = sourceMetadataEnabled ? resolveBuildLocalMarkerSalt(process.env) : void 0;
96
+ const injected = {
97
+ [BUILD_ID_LITERAL]: identity.buildId,
98
+ [GIT_COMMIT_LITERAL]: identity.gitCommit ?? "",
99
+ [BUILD_MODE_LITERAL]: sourceMapUpload?.mode ?? configuredReleaseMode(options.sourceMaps) ?? inferReleaseMode(process.env)
100
+ };
101
+ if (typeof nextConfig === "function") return function visualReviewNextConfig(...arguments_) {
102
+ const resolved = nextConfig.apply(this, arguments_);
103
+ return isPromiseLike(resolved) ? resolved.then((config) => configureNext(config, injected, identity, sourceMapUpload, sourceMetadataEnabled, sourceMarkerSalt)) : configureNext(resolved, injected, identity, sourceMapUpload, sourceMetadataEnabled, sourceMarkerSalt);
104
+ };
105
+ return configureNext(nextConfig, injected, identity, sourceMapUpload, sourceMetadataEnabled, sourceMarkerSalt);
106
+ }
107
+ function configureNext(nextConfig, injected, identity, sourceMapUpload, sourceMetadataEnabled, sourceMarkerSalt) {
108
+ const previousHookMetadata = getVisualReviewHookMetadata(nextConfig.compiler?.runAfterProductionCompile);
109
+ const previouslyWrapped = isVisualReviewConfig(nextConfig) || previousHookMetadata !== void 0;
110
+ assertReservedLiterals(nextConfig.env, injected, previouslyWrapped);
111
+ let configured = {
112
+ ...nextConfig,
113
+ env: {
114
+ ...nextConfig.env,
115
+ ...injected
116
+ }
117
+ };
118
+ if (previousHookMetadata) configured = restoreSourceMapConfiguration(configured, previousHookMetadata);
119
+ if (!sourceMapUpload && !sourceMetadataEnabled) return markVisualReviewConfig(configured);
120
+ const hookMetadata = previousHookMetadata ?? captureHookMetadata(nextConfig);
121
+ if (sourceMapUpload && (nextConfig.productionBrowserSourceMaps === false || hookMetadata.productionBrowserSourceMaps.present && hookMetadata.productionBrowserSourceMaps.value === false)) throw new Error("Next productionBrowserSourceMaps=false conflicts with Visual Review source-map upload");
122
+ if (sourceMapUpload && (nextConfig.experimental?.serverSourceMaps === false || hookMetadata.serverSourceMaps.present && hookMetadata.serverSourceMaps.value === false)) throw new Error("Next experimental.serverSourceMaps=false conflicts with Visual Review source-map upload");
123
+ const sourceMetadata = sourceMetadataEnabled ? resolveNextSourceMetadata(identity, nextConfig.basePath, sourceMarkerSalt) : void 0;
124
+ const sourceConfigured = sourceMetadata ? configureNextSourceMetadata(configured, hookMetadata, sourceMetadata) : configured;
125
+ const compiler = sourceConfigured.compiler ?? {};
126
+ return markVisualReviewConfig({
127
+ ...sourceConfigured,
128
+ ...sourceMapUpload ? { productionBrowserSourceMaps: true } : {},
129
+ ...sourceMapUpload ? { experimental: {
130
+ ...sourceConfigured.experimental,
131
+ serverSourceMaps: true
132
+ } } : {},
133
+ compiler: {
134
+ ...compiler,
135
+ runAfterProductionCompile: composeProductionCompileHook(hookMetadata.originalHook, identity, sourceMapUpload, sourceMetadata, nextConfig.basePath, nextConfig.assetPrefix, hookMetadata)
136
+ }
137
+ });
138
+ }
139
+ function composeProductionCompileHook(existingHook, identity, sourceMapUpload, sourceMetadata, basePath, assetPrefix, metadata) {
140
+ const hook = async function visualReviewAfterProductionCompile(metadata) {
141
+ if (sourceMetadata) removeBuildLocalMarkerSalt(sourceMetadata.saltPath);
142
+ if (existingHook) try {
143
+ await existingHook.call(this, metadata);
144
+ } catch (existingError) {
145
+ if (!sourceMapUpload) throw existingError;
146
+ try {
147
+ await cleanupNextSourceMapOutput(metadata.projectDir, metadata.distDir);
148
+ } catch (cleanupError) {
149
+ throw new AggregateError([existingError, cleanupError], "Next.js production compile hook failed and Visual Review could not clean source maps");
150
+ }
151
+ throw existingError;
152
+ }
153
+ if (sourceMetadata) try {
154
+ await writeNextVisualReviewSourceIndex({
155
+ projectDir: metadata.projectDir,
156
+ distDir: metadata.distDir,
157
+ filename: sourceMetadata.filename,
158
+ ...basePath ? { basePath } : {},
159
+ ...assetPrefix ? { assetPrefix } : {}
160
+ });
161
+ } catch (sourceIndexError) {
162
+ if (!sourceMapUpload) throw sourceIndexError;
163
+ try {
164
+ await cleanupNextSourceMapOutput(metadata.projectDir, metadata.distDir);
165
+ } catch (cleanupError) {
166
+ throw new AggregateError([sourceIndexError, cleanupError], "Visual Review source index failed and Next.js source maps could not be cleaned");
167
+ }
168
+ throw sourceIndexError;
169
+ }
170
+ if (!sourceMapUpload) return;
171
+ await uploadNextSourceMaps({
172
+ projectRoot: metadata.projectDir,
173
+ distDir: metadata.distDir,
174
+ token: sourceMapUpload.token,
175
+ ...sourceMapUpload.serviceUrl ? { serviceUrl: sourceMapUpload.serviceUrl } : {},
176
+ buildId: identity.buildId,
177
+ gitCommit: identity.gitCommit,
178
+ mode: sourceMapUpload.mode,
179
+ ...basePath ? { basePath } : {},
180
+ ...assetPrefix ? { assetPrefix } : {}
181
+ });
182
+ };
183
+ Object.defineProperty(hook, VISUAL_REVIEW_HOOK, { value: metadata });
184
+ return hook;
185
+ }
186
+ function getVisualReviewHookMetadata(hook) {
187
+ return hook?.[VISUAL_REVIEW_HOOK];
188
+ }
189
+ function captureHookMetadata(nextConfig) {
190
+ return {
191
+ originalHook: nextConfig.compiler?.runAfterProductionCompile,
192
+ compilerPresent: Object.prototype.hasOwnProperty.call(nextConfig, "compiler"),
193
+ experimentalPresent: Object.prototype.hasOwnProperty.call(nextConfig, "experimental"),
194
+ productionBrowserSourceMaps: propertyState(nextConfig, "productionBrowserSourceMaps"),
195
+ serverSourceMaps: propertyState(nextConfig.experimental, "serverSourceMaps"),
196
+ turbopack: propertyState(nextConfig, "turbopack"),
197
+ webpack: propertyState(nextConfig, "webpack")
198
+ };
199
+ }
200
+ function propertyState(value, key) {
201
+ return {
202
+ present: value !== void 0 && Object.prototype.hasOwnProperty.call(value, key),
203
+ value: value?.[key]
204
+ };
205
+ }
206
+ function restoreSourceMapConfiguration(configured, metadata) {
207
+ if (!metadata) return configured;
208
+ const restored = { ...configured };
209
+ if (metadata.productionBrowserSourceMaps.present) restored.productionBrowserSourceMaps = metadata.productionBrowserSourceMaps.value;
210
+ else delete restored.productionBrowserSourceMaps;
211
+ const experimental = { ...restored.experimental ?? {} };
212
+ if (metadata.serverSourceMaps.present) experimental.serverSourceMaps = metadata.serverSourceMaps.value;
213
+ else delete experimental.serverSourceMaps;
214
+ if (!metadata.experimentalPresent && Object.keys(experimental).length === 0) delete restored.experimental;
215
+ else restored.experimental = experimental;
216
+ const compiler = { ...restored.compiler ?? {} };
217
+ if (metadata.originalHook) compiler.runAfterProductionCompile = metadata.originalHook;
218
+ else delete compiler.runAfterProductionCompile;
219
+ if (!metadata.compilerPresent && Object.keys(compiler).length === 0) delete restored.compiler;
220
+ else restored.compiler = compiler;
221
+ restoreProperty(restored, "turbopack", metadata.turbopack);
222
+ restoreProperty(restored, "webpack", metadata.webpack);
223
+ return restored;
224
+ }
225
+ function restoreProperty(target, key, state) {
226
+ if (state.present) target[key] = state.value;
227
+ else delete target[key];
228
+ }
229
+ function resolveNextSourceMetadata(identity, basePath, markerSalt) {
230
+ if (!markerSalt) throw new Error("Visual Review source marker salt is unavailable");
231
+ const filename = visualReviewSourceIndexFilename(identity.buildId, markerSalt.salt);
232
+ const loaderUrl = new URL("./source-loader.js", import.meta.url);
233
+ return {
234
+ filename,
235
+ indexUrl: nextSourceIndexUrl(basePath, filename),
236
+ loader: loaderUrl.protocol === "file:" ? fileURLToPath(loaderUrl) : resolve(process.cwd(), "packages/review-client/dist/source-loader.js"),
237
+ saltPath: markerSalt.path
238
+ };
239
+ }
240
+ /**
241
+ * Generates the marker salt and persists it outside the project so compiler
242
+ * workers can read it without the secret entering any build output. The file is
243
+ * owner-only and is removed once the production compile hook has run.
244
+ */
245
+ function resolveBuildLocalMarkerSalt(env) {
246
+ const inherited = env[NEXT_MARKER_SALT_PATH];
247
+ if (env.NEXT_PRIVATE_BUILD_WORKER === "1" && env.IS_NEXT_WORKER === "true" && typeof inherited === "string" && inherited.length > 0) return {
248
+ salt: readMarkerSaltFile(inherited),
249
+ path: inherited
250
+ };
251
+ const salt = randomBytes(32).toString("hex");
252
+ const path = join(mkdtempSync(join(tmpdir(), "visual-review-marker-salt-")), "salt");
253
+ writeFileSync(path, salt, {
254
+ encoding: "utf8",
255
+ mode: 384
256
+ });
257
+ env[NEXT_MARKER_SALT_PATH] = path;
258
+ trackBuildLocalMarkerSalt(path);
259
+ return {
260
+ salt,
261
+ path
262
+ };
263
+ }
264
+ function readMarkerSaltFile(saltPath) {
265
+ const salt = readFileSync(saltPath, "utf8").trim();
266
+ if (!/^[0-9a-f]{64}$/u.test(salt)) throw new Error("Visual Review inherited marker salt is invalid");
267
+ return salt;
268
+ }
269
+ /**
270
+ * `next dev` never reaches the production compile hook, and every compiler
271
+ * worker evaluates the config in its own process, so exit is the only point
272
+ * that reliably removes each salt file it created.
273
+ */
274
+ function trackBuildLocalMarkerSalt(saltPath) {
275
+ if (trackedMarkerSaltPaths.size === 0) process.once("exit", () => {
276
+ for (const tracked of trackedMarkerSaltPaths) removeMarkerSaltDirectory(tracked);
277
+ });
278
+ trackedMarkerSaltPaths.add(saltPath);
279
+ }
280
+ function removeBuildLocalMarkerSalt(saltPath) {
281
+ trackedMarkerSaltPaths.delete(saltPath);
282
+ removeMarkerSaltDirectory(saltPath);
283
+ }
284
+ function removeMarkerSaltDirectory(saltPath) {
285
+ try {
286
+ rmSync(dirname(saltPath), {
287
+ recursive: true,
288
+ force: true
289
+ });
290
+ } catch {}
291
+ }
292
+ function configureNextSourceMetadata(configured, metadata, sourceMetadata) {
293
+ const bundler = resolveNextBundler();
294
+ const next = { ...configured };
295
+ restoreProperty(next, "turbopack", metadata.turbopack);
296
+ restoreProperty(next, "webpack", metadata.webpack);
297
+ if (bundler === "turbopack") next.turbopack = appendTurbopackRules(next.turbopack, sourceMetadata);
298
+ else next.webpack = appendWebpackRule(next.webpack, sourceMetadata);
299
+ return next;
300
+ }
301
+ function resolveNextBundler() {
302
+ return process.env.TURBOPACK ? "turbopack" : "webpack";
303
+ }
304
+ function appendWebpackRule(existing, sourceMetadata) {
305
+ return (config, context) => {
306
+ const resolved = existing?.(config, context) ?? config;
307
+ if (!resolved || typeof resolved !== "object" || isPromiseLikeUnknown(resolved)) throw new Error("Next webpack() must return a synchronous config for Visual Review");
308
+ const webpackConfig = resolved;
309
+ const moduleConfig = isRecord(webpackConfig.module) ? webpackConfig.module : {};
310
+ const rules = Array.isArray(moduleConfig.rules) ? moduleConfig.rules : [];
311
+ return {
312
+ ...webpackConfig,
313
+ module: {
314
+ ...moduleConfig,
315
+ rules: [...rules, {
316
+ enforce: "pre",
317
+ exclude: /node_modules/u,
318
+ test: /\.[cm]?[jt]sx?$/u,
319
+ use: [{
320
+ loader: sourceMetadata.loader,
321
+ options: {
322
+ indexUrl: sourceMetadata.indexUrl,
323
+ saltPath: sourceMetadata.saltPath
324
+ }
325
+ }]
326
+ }]
327
+ }
328
+ };
329
+ };
330
+ }
331
+ function appendTurbopackRules(existing, sourceMetadata) {
332
+ const rules = { ...existing?.rules ?? {} };
333
+ for (const extension of [
334
+ "js",
335
+ "jsx",
336
+ "ts",
337
+ "tsx"
338
+ ]) {
339
+ const pattern = `*.${extension}`;
340
+ const rule = {
341
+ condition: { all: [{ not: "foreign" }, "production"] },
342
+ loaders: [{
343
+ loader: sourceMetadata.loader,
344
+ options: {
345
+ compile: true,
346
+ indexUrl: sourceMetadata.indexUrl,
347
+ saltPath: sourceMetadata.saltPath
348
+ }
349
+ }]
350
+ };
351
+ const current = rules[pattern];
352
+ rules[pattern] = current === void 0 ? rule : Array.isArray(current) ? [...current, rule] : [current, rule];
353
+ }
354
+ return {
355
+ ...existing,
356
+ rules
357
+ };
358
+ }
359
+ function isPromiseLikeUnknown(value) {
360
+ return Boolean(value) && typeof value === "object" && typeof value.then === "function";
361
+ }
362
+ function isRecord(value) {
363
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
364
+ }
365
+ function isVisualReviewConfig(config) {
366
+ return Boolean(config[VISUAL_REVIEW_CONFIG]);
367
+ }
368
+ function markVisualReviewConfig(config) {
369
+ Object.defineProperty(config, VISUAL_REVIEW_CONFIG, { value: true });
370
+ return config;
371
+ }
372
+ function resolveSourceMapUpload(option, env) {
373
+ if (option === false || typeof option === "object" && option.enabled === false) return;
374
+ const configured = typeof option === "object" ? option : void 0;
375
+ const token = configured?.token ?? env.VISUAL_REVIEW_SOURCE_MAP_TOKEN;
376
+ if (!(option === true || configured !== void 0 || token !== void 0)) return void 0;
377
+ if (typeof token !== "string" || token.length === 0 || token.length > 4096 || /\s/u.test(token)) throw new Error("Visual Review source-map upload requires VISUAL_REVIEW_SOURCE_MAP_TOKEN in the Node build environment");
378
+ return {
379
+ token,
380
+ mode: configured?.mode ?? inferReleaseMode(env),
381
+ ...configured?.serviceUrl ? { serviceUrl: configured.serviceUrl } : {}
382
+ };
383
+ }
384
+ function configuredReleaseMode(option) {
385
+ return typeof option === "object" ? option.mode : void 0;
386
+ }
387
+ function inferReleaseMode(env) {
388
+ if (env.VERCEL_ENV === "preview") return "preview";
389
+ if (env.VERCEL_ENV === "development") return "development";
390
+ if (env.CONTEXT === "deploy-preview" || env.CONTEXT === "branch-deploy") return "preview";
391
+ if (env.NODE_ENV === "development") return "development";
392
+ return "production";
393
+ }
394
+ function isPromiseLike(value) {
395
+ return typeof value.then === "function";
396
+ }
397
+ function assertReservedLiterals(current, expected, allowVisualReviewOverride = false) {
398
+ if (!current) return;
399
+ for (const key of [
400
+ BUILD_ID_LITERAL,
401
+ GIT_COMMIT_LITERAL,
402
+ BUILD_MODE_LITERAL
403
+ ]) if (!allowVisualReviewOverride && Object.prototype.hasOwnProperty.call(current, key) && current[key] !== expected[key]) throw new Error(`Next env already defines reserved ${key}; configure it through withVisualReview() options`);
404
+ }
405
+ //#endregion
406
+ export { withVisualReview };
package/dist/next.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { VisualReview, type VisualReviewProps } from './react.js';
package/dist/next.js ADDED
@@ -0,0 +1 @@
1
+ "use client";import{VisualReview as e}from"./react.js";export{e as VisualReview};