@valbuild/cli 0.137.0 → 0.138.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.
@@ -0,0 +1,365 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import zlib from "zlib";
5
+ import type { BuildTarget, PublishArtifact } from "@valbuild/tanstack-build";
6
+ import type { VendorLayer } from "@valbuild/tanstack-build/node";
7
+
8
+ /**
9
+ * `val publish`'s build: the site, built from the checkout, as the artifacts
10
+ * the publish API takes (valbuild/home, docs/app-mode.md, "Briefing:
11
+ * val-publish" and "Flow B").
12
+ *
13
+ * The same builder the Studio runs in a tab -- `@valbuild/tanstack-build` --
14
+ * plus the half a tab cannot run: the project's dependency layer, built from
15
+ * its own `node_modules` (`@valbuild/tanstack-build/node`). So a CI checkout
16
+ * and a Studio build of the same content produce the same site, and the layer
17
+ * is the one thing only this side can make.
18
+ *
19
+ * Against content's build target, never the loader's: content is the only
20
+ * address a publisher has, and it answers what the platform expects.
21
+ *
22
+ * Written to a directory, and then published by the upload path `val publish
23
+ * --artifacts` has always had: one path for bytes to leave by, whoever built
24
+ * them.
25
+ */
26
+
27
+ /** What `val publish` hands on to the upload. */
28
+ export type BuiltArtifacts =
29
+ | {
30
+ status: "built";
31
+ /** A directory of artifacts, laid out as `--artifacts` reads them. */
32
+ dir: string;
33
+ buildHash: string;
34
+ linksOwnCss: boolean;
35
+ /**
36
+ * The layer by name, when content already holds it and it was not
37
+ * written; `null` when the project declares nothing beyond the base set.
38
+ * `undefined` when the layer artifact is in `dir`, which names it.
39
+ */
40
+ layerRev?: string | null;
41
+ warnings: string[];
42
+ }
43
+ | { status: "error"; message: string };
44
+
45
+ type TanstackBuild = typeof import("@valbuild/tanstack-build");
46
+ type TanstackBuildNode = typeof import("@valbuild/tanstack-build/node");
47
+
48
+ /**
49
+ * The builder, loaded only when a build is asked for: it brings rolldown's
50
+ * native binary with it, and `val validate` has no use for one.
51
+ */
52
+ async function loadBuilder(): Promise<
53
+ | { status: "ok"; build: TanstackBuild; node: TanstackBuildNode }
54
+ | { status: "error"; message: string }
55
+ > {
56
+ try {
57
+ const [build, node] = await Promise.all([
58
+ import("@valbuild/tanstack-build"),
59
+ import("@valbuild/tanstack-build/node"),
60
+ ]);
61
+ return { status: "ok", build, node };
62
+ } catch (e) {
63
+ return {
64
+ status: "error",
65
+ message:
66
+ "`val publish` builds the site with @valbuild/tanstack-build and rolldown, and " +
67
+ `they could not be loaded: ${e instanceof Error ? e.message : String(e)}\n\n` +
68
+ "Install them in the project:\n\n" +
69
+ " npm install --save-dev @valbuild/tanstack-build rolldown @tanstack/router-generator @tanstack/router-plugin\n\n" +
70
+ "or publish a build made elsewhere with --artifacts <dir>.",
71
+ };
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Never the project's own files, at any depth: installed packages. Dot
77
+ * directories (`.git`, `.tanstack`, `.val`, `.output`) are tooling too.
78
+ */
79
+ const IGNORED_DIRS = new Set(["node_modules"]);
80
+
81
+ /**
82
+ * Build output, at the project's root only: `src/routes/build/` is a route
83
+ * called "build", not a previous build.
84
+ */
85
+ const ROOT_OUTPUT_DIRS = new Set(["dist", "build"]);
86
+
87
+ /** Lockfiles are JSON, and nothing imports them. */
88
+ const ROOT_LOCKFILES = new Set(["package-lock.json"]);
89
+
90
+ const CODE_FILE = /\.(tsx?|jsx?|mjs|cjs|css)$/;
91
+
92
+ /**
93
+ * JSON is the project's too: `tsconfig.json` and `tsr.config.json` are read
94
+ * by the build, a `.val.json` holds a `.jsonValues()` entry no import points
95
+ * at, and a module may import one -- the starter's `val.server.ts` imports
96
+ * `.prettierrc.json`, which is also why a dot FILE is read.
97
+ */
98
+ const JSON_FILE = /\.json$/;
99
+
100
+ /**
101
+ * The project's source as it is STORED, which is what the Studio builds from:
102
+ * every file this build read, less the stylesheets.
103
+ *
104
+ * This build compiles them, because it can run what a stylesheet asks for -- a
105
+ * Tailwind `@plugin` is a module resolved and executed from `node_modules`. A
106
+ * build in the browser cannot, and refuses the stylesheet rather than ship a
107
+ * page that is quietly unstyled; the starter's `src/styles.css` has
108
+ * `@plugin "@tailwindcss/typography"`. So a stored stylesheet made every
109
+ * Studio publish after the first one fail. Without it, the Studio's build has
110
+ * no CSS of its own and ships the live site's (`liveStylesheet` in the
111
+ * Studio), which is right: a Studio save changes content and uploaded files,
112
+ * never a stylesheet. It is also what the platform's own publish has always
113
+ * stored.
114
+ */
115
+ export function withoutStylesheets(
116
+ sources: Record<string, string>,
117
+ ): Record<string, string> {
118
+ return Object.fromEntries(
119
+ Object.entries(sources).filter(([key]) => !key.endsWith(".css")),
120
+ );
121
+ }
122
+
123
+ /**
124
+ * The project's own files: its source as text, and the binaries it imports
125
+ * (an image, a font) base64, as `BuildInput.assets` takes them. `public/` is
126
+ * neither: it is served as is, and read by `readPublicFiles`.
127
+ */
128
+ export function readProjectFiles(
129
+ root: string,
130
+ isAsset: (path: string) => boolean,
131
+ ): { sources: Record<string, string>; assets: Record<string, string> } {
132
+ const sources: Record<string, string> = {};
133
+ const assets: Record<string, string> = {};
134
+ const walk = (dir: string) => {
135
+ let entries: fs.Dirent[];
136
+ try {
137
+ entries = fs.readdirSync(dir, { withFileTypes: true });
138
+ } catch {
139
+ return;
140
+ }
141
+ for (const entry of entries) {
142
+ const full = path.join(dir, entry.name);
143
+ if (entry.isDirectory()) {
144
+ if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name))
145
+ continue;
146
+ if (dir === root && ROOT_OUTPUT_DIRS.has(entry.name)) continue;
147
+ if (dir === root && entry.name === "public") continue;
148
+ walk(full);
149
+ continue;
150
+ }
151
+ const key = path.relative(root, full).split(path.sep).join("/");
152
+ if (CODE_FILE.test(entry.name)) {
153
+ sources[key] = fs.readFileSync(full, "utf8");
154
+ } else if (JSON_FILE.test(entry.name)) {
155
+ if (!ROOT_LOCKFILES.has(key)) {
156
+ sources[key] = fs.readFileSync(full, "utf8");
157
+ }
158
+ } else if (isAsset(key)) {
159
+ assets[key] = fs.readFileSync(full).toString("base64");
160
+ }
161
+ }
162
+ };
163
+ walk(root);
164
+ return { sources, assets };
165
+ }
166
+
167
+ /** Files under `public/`, base64, keyed `public/<path>` as the build takes them. */
168
+ export function readPublicFiles(root: string): Record<string, string> {
169
+ const out: Record<string, string> = {};
170
+ const base = path.join(root, "public");
171
+ const walk = (dir: string) => {
172
+ let entries: fs.Dirent[];
173
+ try {
174
+ entries = fs.readdirSync(dir, { withFileTypes: true });
175
+ } catch {
176
+ return;
177
+ }
178
+ for (const entry of entries) {
179
+ const full = path.join(dir, entry.name);
180
+ if (entry.isDirectory()) walk(full);
181
+ else {
182
+ const key = path.relative(root, full).split(path.sep).join("/");
183
+ out[key] = fs.readFileSync(full).toString("base64");
184
+ }
185
+ }
186
+ };
187
+ walk(base);
188
+ return out;
189
+ }
190
+
191
+ /** The project's build-time variables: `PUBLIC_*` reach the browser, by the builder's rule. */
192
+ function publicEnv(env: NodeJS.ProcessEnv): Record<string, string> {
193
+ const out: Record<string, string> = {};
194
+ for (const [key, value] of Object.entries(env)) {
195
+ if (key.startsWith("PUBLIC_") && value !== undefined) out[key] = value;
196
+ }
197
+ return out;
198
+ }
199
+
200
+ /**
201
+ * The target, with this project's layer as built here rather than as content
202
+ * last stored it: a build has to link against the layer it ships with.
203
+ */
204
+ export function targetWithLayer(
205
+ target: BuildTarget,
206
+ layer: VendorLayer | null,
207
+ ): BuildTarget {
208
+ return {
209
+ ...target,
210
+ project: {
211
+ ...target.project,
212
+ rev: layer?.rev ?? null,
213
+ modules: layer?.modules ?? {},
214
+ css: layer?.css ?? {},
215
+ workerOnly: layer?.workerOnly ?? [],
216
+ },
217
+ };
218
+ }
219
+
220
+ function writeArtifact(dir: string, key: string, body: string | Buffer) {
221
+ const file = path.join(dir, ...key.split("/"));
222
+ fs.mkdirSync(path.dirname(file), { recursive: true });
223
+ fs.writeFileSync(file, body);
224
+ }
225
+
226
+ export async function buildArtifacts(options: {
227
+ root: string;
228
+ target: BuildTarget;
229
+ /** The project, as the platform names it; only for the wiring's record. */
230
+ project: string;
231
+ /** Content, which is the platform as far as this publisher knows. */
232
+ contentHost: string;
233
+ /** The commit this checkout is at, and the branch it publishes. */
234
+ git: { commit: string; branch: string } | null;
235
+ env?: NodeJS.ProcessEnv;
236
+ log?: (line: string) => void;
237
+ /** Where to write; a fresh temporary directory by default. */
238
+ outDir?: string;
239
+ }): Promise<BuiltArtifacts> {
240
+ const log = options.log ?? (() => {});
241
+ const loaded = await loadBuilder();
242
+ if (loaded.status === "error") return loaded;
243
+ const { build: builder, node } = loaded;
244
+ const { root, target } = options;
245
+ const warnings: string[] = [];
246
+
247
+ try {
248
+ /*
249
+ * Wired for the platform at publish time, as the Studio's builds are: the
250
+ * wired `val.server.ts` reads its files from a module the BUILD emits, so
251
+ * no checkout can carry it. `rebakeGit` would be the tab's spelling of the
252
+ * same thing for a record that is already wired.
253
+ */
254
+ const { sources: onDisk, assets } = readProjectFiles(root, builder.isAsset);
255
+ const wiring =
256
+ builder.isValProject(onDisk) && !builder.isWired(onDisk)
257
+ ? builder.wireUp(onDisk, {
258
+ project: options.project,
259
+ loader: options.contentHost,
260
+ ...(options.git ? { git: options.git } : {}),
261
+ })
262
+ : null;
263
+ // Less the one about the layer, which is the thing this goes on to build.
264
+ warnings.push(
265
+ ...(wiring?.warnings ?? []).filter(
266
+ (warning) => !warning.includes("dependency layer built for it"),
267
+ ),
268
+ );
269
+ const sources = wiring?.files ?? onDisk;
270
+ const projectSource = withoutStylesheets(sources);
271
+
272
+ // The dependency layer: from what the project imports, of what it declares.
273
+ log("Building the dependency layer");
274
+ const manifest: { dependencies?: Record<string, string> } = (() => {
275
+ try {
276
+ return JSON.parse(sources["package.json"] ?? "{}");
277
+ } catch {
278
+ return {};
279
+ }
280
+ })();
281
+ const deps = node.layerDepsOf(manifest, sources);
282
+ const css = await node.collectDependencyCss(
283
+ root,
284
+ node.cssSpecifiersOf(sources),
285
+ );
286
+ const layer = await node.buildVendorLayer(root, deps, css, {
287
+ base: target.base,
288
+ // A project on the platform layers its own copies; only the platform's
289
+ // own checkout carries pinned ones.
290
+ resolvePlatformModule: () => null,
291
+ // No built base layer here: the export audit is skipped, not failed.
292
+ baseLayerDir: null,
293
+ });
294
+
295
+ log("Building the site");
296
+ const fileBased = await node.isFileBased(root);
297
+ const files = fileBased
298
+ ? await node.generateRouteTree(root, sources)
299
+ : sources;
300
+ /*
301
+ * The project's copy of the splitter first, then the one beside this CLI
302
+ * -- where a package manager puts an optional peer the project installed
303
+ * but did not hoist. Neither is a build that does not split, which costs
304
+ * size and not behaviour.
305
+ */
306
+ const splitter =
307
+ (await node.routeSplitter(root)) ??
308
+ (typeof __dirname === "string"
309
+ ? await node.routeSplitter(__dirname)
310
+ : null);
311
+ if (splitter === null) {
312
+ warnings.push(
313
+ "Routes are not code-split: @tanstack/router-plugin is not installed.",
314
+ );
315
+ }
316
+ const built = await builder.buildUserApp({
317
+ files,
318
+ target: targetWithLayer(target, layer),
319
+ env: publicEnv(options.env ?? process.env),
320
+ publicFiles: readPublicFiles(root),
321
+ assets,
322
+ // The project's own files, without the generated tree or the
323
+ // stylesheets: what the next publish -- the Studio's included -- starts
324
+ // from. See `withoutStylesheets`.
325
+ projectSource,
326
+ loadCssModule: node.cssModuleLoader(root),
327
+ ...(splitter ? { routeSplitter: splitter } : {}),
328
+ rsc: target.project.rsc,
329
+ });
330
+ const artifacts: PublishArtifact[] = await builder.publishArtifacts(built, {
331
+ projectSource,
332
+ });
333
+
334
+ const dir =
335
+ options.outDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "val-publish-"));
336
+ for (const artifact of artifacts) {
337
+ writeArtifact(dir, artifact.key, artifact.body);
338
+ }
339
+ /*
340
+ * The layer: sent when content does not hold this one, named when it
341
+ * does. The rev is a digest of the built chunks, so "content has this" is
342
+ * a fact rather than a hope.
343
+ */
344
+ let layerRev: string | null | undefined;
345
+ if (layer === null) layerRev = null;
346
+ else if (layer.rev === target.project.rev) layerRev = layer.rev;
347
+ else writeArtifact(dir, "layer", zlib.gzipSync(JSON.stringify(layer)));
348
+
349
+ return {
350
+ status: "built",
351
+ dir,
352
+ buildHash: built.hash,
353
+ linksOwnCss: built.linksOwnCss,
354
+ ...(layerRev !== undefined ? { layerRev } : {}),
355
+ warnings,
356
+ };
357
+ } catch (e) {
358
+ return {
359
+ status: "error",
360
+ message: `The site could not be built: ${
361
+ e instanceof Error ? e.message.split("\n")[0] : String(e)
362
+ }`,
363
+ };
364
+ }
365
+ }
@@ -0,0 +1,118 @@
1
+ import { execFileSync } from "child_process";
2
+ import path from "path";
3
+
4
+ /**
5
+ * `val publish` with no `--artifacts` builds the checkout, and publishes it.
6
+ *
7
+ * What a connected project's CI does on every push to its branch: the runner
8
+ * has a checkout and `node_modules`, content has the build target, and nothing
9
+ * else is needed. So this drives the whole command -- the build target asked
10
+ * of content, the dependency layer built from `node_modules`, the route tree
11
+ * generated, the site built, the artifacts declared and uploaded -- against
12
+ * the fake content service, twice: once with no layer held, once with the
13
+ * layer the first run sent.
14
+ *
15
+ * The build runs in a child process because `@rolldown/browser` is ESM-only
16
+ * and jest runs CommonJS here. See the probe beside this file.
17
+ */
18
+ type Declared = {
19
+ buildHash: string;
20
+ commit: string | null;
21
+ branch: string | null;
22
+ layerRev: string | null;
23
+ linksOwnCss: boolean | null;
24
+ keys: string[];
25
+ };
26
+ type Run = {
27
+ result: { status: string; artifacts?: number };
28
+ declared: Declared[];
29
+ buildTargetCalls: number;
30
+ uploadedLayerRev: string | null;
31
+ sourceHasRoute: boolean;
32
+ sourceHasStylesheet: boolean;
33
+ cssHasRule: boolean;
34
+ clientHasPage: boolean;
35
+ clientHasLogo: boolean;
36
+ buildRouteBuilt: boolean;
37
+ checkoutUntouched: boolean;
38
+ };
39
+
40
+ let probe: { fresh: Run; held: Run };
41
+
42
+ beforeAll(() => {
43
+ const out = execFileSync(
44
+ path.join(__dirname, "..", "..", "node_modules", ".bin", "tsx"),
45
+ [path.join(__dirname, "__fixtures__", "buildPublishProbe.ts")],
46
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
47
+ );
48
+ // tsx's WASI warnings can share stdout; take the last line that parses.
49
+ const line = out
50
+ .trim()
51
+ .split("\n")
52
+ .reverse()
53
+ .find((candidate) => candidate.startsWith("{"));
54
+ probe = JSON.parse(line!) as typeof probe;
55
+ }, 300_000);
56
+
57
+ test("asks content what to build against, and publishes what it built", () => {
58
+ const { fresh } = probe;
59
+ expect(fresh.result.status).toBe("live");
60
+ expect(fresh.buildTargetCalls).toBe(1);
61
+ expect(fresh.declared).toHaveLength(1);
62
+ const [declared] = fresh.declared;
63
+ expect(declared!.commit).toBe("1234567890abcdef1234567890abcdef12345678");
64
+ expect(declared!.branch).toBe("main");
65
+ expect(declared!.keys).toEqual(
66
+ expect.arrayContaining([
67
+ "server",
68
+ "client",
69
+ "css",
70
+ "source",
71
+ "public/robots.txt",
72
+ ]),
73
+ );
74
+ expect(fresh.clientHasPage).toBe(true);
75
+ });
76
+
77
+ test("the stored source is the project's own files", () => {
78
+ expect(probe.fresh.sourceHasRoute).toBe(true);
79
+ });
80
+
81
+ test("a stylesheet is built, and not stored as source", () => {
82
+ // The Studio builds from the stored source, in a browser that cannot run a
83
+ // Tailwind `@plugin`; a stored stylesheet failed every Studio publish after
84
+ // the first. Without one it ships the live site's CSS, compiled here.
85
+ expect(probe.fresh.cssHasRule).toBe(true);
86
+ expect(probe.fresh.sourceHasStylesheet).toBe(false);
87
+ });
88
+
89
+ test("an image the project imports reaches the build", () => {
90
+ expect(probe.fresh.clientHasLogo).toBe(true);
91
+ });
92
+
93
+ test("a folder called `build` below the root is source, not output", () => {
94
+ expect(probe.fresh.buildRouteBuilt).toBe(true);
95
+ });
96
+
97
+ test("a layer content does not hold is built and sent, and named by its own rev", () => {
98
+ const { fresh } = probe;
99
+ expect(fresh.declared[0]!.keys).toContain("layer");
100
+ expect(fresh.uploadedLayerRev).toEqual(expect.any(String));
101
+ expect(fresh.declared[0]!.layerRev).toBe(fresh.uploadedLayerRev);
102
+ });
103
+
104
+ test("a layer content already holds is named, not sent again", () => {
105
+ const { fresh, held } = probe;
106
+ expect(held.result.status).toBe("live");
107
+ expect(held.declared[0]!.keys).not.toContain("layer");
108
+ expect(held.declared[0]!.layerRev).toBe(fresh.uploadedLayerRev);
109
+ // The same checkout is the same build, and so the same publish.
110
+ expect(held.declared[0]!.buildHash).toBe(fresh.declared[0]!.buildHash);
111
+ });
112
+
113
+ test("the checkout is left as it was found", () => {
114
+ // The route tree is generated on disk and put back; the build is written
115
+ // to a temporary directory, not into `.val/publish`.
116
+ expect(probe.fresh.checkoutUntouched).toBe(true);
117
+ expect(probe.held.checkoutUntouched).toBe(true);
118
+ });
@@ -2,6 +2,7 @@ import fs from "fs";
2
2
  import { getJson, postJson } from "./contentHost";
3
3
  import {
4
4
  ArtifactsResponse,
5
+ BuildTargetResponse,
5
6
  DeclareBody,
6
7
  DeclareResponse,
7
8
  PromoteResponse,
@@ -9,6 +10,7 @@ import {
9
10
  UploadSlot,
10
11
  VerifyResponse,
11
12
  parseArtifacts,
13
+ parseBuildTarget,
12
14
  parseDeclare,
13
15
  parsePromote,
14
16
  parseStatus,
@@ -16,6 +18,8 @@ import {
16
18
  } from "@valbuild/shared/internal";
17
19
 
18
20
  export type PublishClient = {
21
+ /** What to build against: the platform's base and this project's layer. */
22
+ buildTarget(): Promise<BuildTargetResponse>;
19
23
  declare(body: DeclareBody): Promise<DeclareResponse>;
20
24
  confirmArtifacts(publishId: string): Promise<ArtifactsResponse>;
21
25
  verify(publishId: string): Promise<VerifyResponse>;
@@ -38,6 +42,10 @@ export function createPublishClient(options: {
38
42
  `${host}/v1/publish/${encodeURIComponent(publishId)}${step ? `/${step}` : ""}`;
39
43
 
40
44
  return {
45
+ buildTarget: async () =>
46
+ parseBuildTarget(
47
+ await getJson({ url: `${host}/v1/build-target`, headers, fetchImpl }),
48
+ ),
41
49
  declare: async (body) =>
42
50
  parseDeclare(
43
51
  await postJson({ url: `${host}/v1/publish`, headers, body, fetchImpl }),
@@ -1,6 +1,6 @@
1
1
  import crypto from "crypto";
2
2
  import http from "http";
3
- import { PublishProblem } from "@valbuild/shared/internal";
3
+ import { BuildTargetResponse, PublishProblem } from "@valbuild/shared/internal";
4
4
 
5
5
  /**
6
6
  * A local stand-in for the publish API on content.val.build.
@@ -50,6 +50,50 @@ export type FakeContentOptions = {
50
50
  * worth being able to drive from both sides.
51
51
  */
52
52
  head?: string;
53
+ /** What `GET /v1/build-target` answers. {@link FAKE_BUILD_TARGET} by default. */
54
+ buildTarget?: BuildTargetResponse;
55
+ };
56
+
57
+ /**
58
+ * The smallest platform a build accepts: the base set, and no project layer
59
+ * yet -- a project that has never published one.
60
+ */
61
+ export const FAKE_BUILD_TARGET: BuildTargetResponse = {
62
+ base: {
63
+ rev: "base",
64
+ shellRev: "shell",
65
+ rscShellRev: "rsc",
66
+ // The platform's base set, as its vendor package declares it.
67
+ modules: {
68
+ react: "react",
69
+ "react/jsx-runtime": "jsx-runtime",
70
+ "react/jsx-dev-runtime": "jsx-dev-runtime",
71
+ "react-dom": "react-dom",
72
+ "react-dom/client": "react-dom-client",
73
+ "react-dom/server": "react-dom-server",
74
+ "@tanstack/react-router": "tanstack-react-router",
75
+ "@tanstack/react-start": "tanstack-react-start",
76
+ "@tanstack/react-start/server-rpc": "tanstack-react-start-server-rpc",
77
+ "@tanstack/react-start/client-rpc": "tanstack-react-start-client-rpc",
78
+ "@tanstack/router-core": "tanstack-router-core",
79
+ "@tanstack/start-client-core": "tanstack-start-client-core",
80
+ "@tanstack/start-storage-context": "tanstack-start-storage-context",
81
+ "@tanstack/history": "tanstack-history",
82
+ "@tanstack/store": "tanstack-store",
83
+ "@tanstack/react-store": "tanstack-react-store",
84
+ },
85
+ rscModules: {},
86
+ paths: {
87
+ baseFromProject: "../vendor/",
88
+ projectVendorDir: "pvendor",
89
+ rscVendorBase: "../vendor-rsc/",
90
+ rscRuntimeSpecifier: "@tanstack/react-start/rsc",
91
+ rscRuntimePath: "./runtime.js",
92
+ flightServer: "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge",
93
+ serverFnBase: "/_serverFn/",
94
+ },
95
+ },
96
+ project: { rev: null, rsc: false, modules: {}, css: {}, workerOnly: [] },
53
97
  };
54
98
 
55
99
  export type FakeContentService = {
@@ -166,10 +210,6 @@ export async function startFakeContentService(
166
210
  return;
167
211
  }
168
212
 
169
- if (parts[1] !== "publish") {
170
- send(res, 404, { statusCode: 404, message: "No such route" });
171
- return;
172
- }
173
213
  if (!authorization?.startsWith("Bearer val_pt_")) {
174
214
  send(res, 401, {
175
215
  statusCode: 401,
@@ -188,6 +228,21 @@ export async function startFakeContentService(
188
228
  return;
189
229
  }
190
230
 
231
+ // GET /v1/build-target - what a build links against.
232
+ if (
233
+ parts.length === 2 &&
234
+ parts[1] === "build-target" &&
235
+ req.method === "GET"
236
+ ) {
237
+ send(res, 200, options.buildTarget ?? FAKE_BUILD_TARGET);
238
+ return;
239
+ }
240
+
241
+ if (parts[1] !== "publish") {
242
+ send(res, 404, { statusCode: 404, message: "No such route" });
243
+ return;
244
+ }
245
+
191
246
  // POST /v1/publish - declare.
192
247
  if (parts.length === 2 && req.method === "POST") {
193
248
  const body = parseJson(await readBody(req));