@decocms/tanstack 7.0.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,13 @@
1
+ import { setFastDeployKVGetter } from "@decocms/blocks-admin";
2
+ import { getFastDeployKV } from "./sdk/kvHydration";
3
+
4
+ /**
5
+ * Reconnects packages/blocks-admin's decofile write-through to this
6
+ * package's Cloudflare KV reader. Call once at site startup (from the
7
+ * site's own setup.ts, alongside createSiteSetup()) — without this call,
8
+ * handleDecofileReload's KV write-through silently no-ops (same as it
9
+ * does today for sites that never configure fast-deploy).
10
+ */
11
+ export function setupTanstackFastDeploy(): void {
12
+ setFastDeployKVGetter(getFastDeployKV);
13
+ }
@@ -0,0 +1,646 @@
1
+ /**
2
+ * Deco Vite plugin — server-only stubs for TanStack Start storefronts.
3
+ *
4
+ * Replaces server-only modules with lightweight client stubs so they
5
+ * are eliminated from the browser bundle. This consolidates stubs that
6
+ * every Deco site previously had to copy into its own vite.config.ts.
7
+ *
8
+ * blocks.gen.ts handling:
9
+ * The CMS block registry can be 10MB+. Inlining it as a JS object literal
10
+ * causes Vite's SSR module runner to hang on dynamic imports (transport
11
+ * serialization bottleneck) and is slow to parse even with static imports
12
+ * (V8 full JS parser). Instead, generate-blocks.ts writes a .json data
13
+ * file, and this plugin intercepts the .ts import to return JSON.parse(...)
14
+ * — V8's JSON parser is 2-10x faster than the JS parser for large data.
15
+ *
16
+ * meta.gen handling:
17
+ * The admin schema bundle (`server/admin/meta.gen.json`) is server-only;
18
+ * the client receives pre-resolved blocks via the SSR payload. Stubbing
19
+ * it on the client cuts a typically-large module out of the browser bundle.
20
+ * Match is done by substring on the import id, so any path style works.
21
+ *
22
+ * manualChunks:
23
+ * `@decocms/tanstack` and `@decocms/apps` are intentionally NOT split
24
+ * into their own chunks. They have circular re-exports that produce a
25
+ * load-order crash when chunked separately. Rollup's default bundling
26
+ * (group with importer or vendor catch-all) avoids that.
27
+ *
28
+ * Usage:
29
+ * ```ts
30
+ * import { decoVitePlugin } from "@decocms/tanstack/vite";
31
+ * export default defineConfig({ plugins: [decoVitePlugin(), ...] });
32
+ * ```
33
+ */
34
+ import { exec, execFileSync } from "node:child_process";
35
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
36
+ import path from "node:path";
37
+
38
+ /**
39
+ * Resolve a per-build identifier for cache-key versioning.
40
+ *
41
+ * The returned string is injected into the worker bundle as the
42
+ * `__DECO_BUILD_HASH__` global via Vite `define`. `createDecoWorkerEntry`
43
+ * appends it (or `env.BUILD_HASH` if explicitly set) as `__v=<hash>` on
44
+ * every Cache API key, so each new deploy gets its own cache namespace
45
+ * — old edge-cached HTML referencing dead asset filenames stops being
46
+ * served the moment the new worker is live.
47
+ *
48
+ * Resolution order:
49
+ * 1. WORKERS_CI_COMMIT_SHA — Cloudflare Workers Builds default env var
50
+ * (the production deploy path-of-record). Sliced to 12 chars.
51
+ * 2. `git rev-parse --short=12 HEAD` — local `wrangler deploy` from a
52
+ * developer laptop. Try/catch so missing git or shallow clones don't
53
+ * fail the build.
54
+ * 3. `Date.now().toString(36)` — last-resort fallback so the cache-bust
55
+ * invariant never silently regresses to "always the same key".
56
+ *
57
+ * For dev (`command !== "build"`), the value is the literal `"dev"`.
58
+ *
59
+ * @returns {string}
60
+ */
61
+ function resolveBuildHash() {
62
+ const ciSha = process.env.WORKERS_CI_COMMIT_SHA;
63
+ if (ciSha?.trim()) return ciSha.trim().slice(0, 12);
64
+
65
+ try {
66
+ const sha = execFileSync("git", ["rev-parse", "--short=12", "HEAD"], {
67
+ encoding: "utf-8",
68
+ stdio: ["ignore", "pipe", "ignore"],
69
+ }).trim();
70
+ if (sha) return sha;
71
+ } catch {
72
+ // git absent, not a repo, or shallow clone w/o history — fall through.
73
+ }
74
+
75
+ return Date.now().toString(36);
76
+ }
77
+
78
+ // Bare-specifier stubs resolved by ID before Vite touches them.
79
+ /** @type {Record<string, string>} */
80
+ const CLIENT_STUBS = {
81
+ "react-dom/server": "\0stub:react-dom-server",
82
+ "react-dom/server.browser": "\0stub:react-dom-server",
83
+ "node:stream": "\0stub:node-stream",
84
+ "node:stream/web": "\0stub:node-stream-web",
85
+ "node:async_hooks": "\0stub:node-async-hooks",
86
+ "tanstack-start-injected-head-scripts:v": "\0stub:tanstack-head-scripts",
87
+ };
88
+
89
+ // SSR-only stubs. Same mechanism as CLIENT_STUBS but applied to the worker
90
+ // SSR build instead of the browser build.
91
+ /** @type {Record<string, string>} */
92
+ const SSR_STUBS = {
93
+ // `@opentelemetry/resources` (transitively pulled in by sdk-logs /
94
+ // sdk-metrics / exporter-* OTel packages — five copies in node_modules due
95
+ // to OTel monorepo peer-dep version pinning) statically imports bare `fs`
96
+ // inside its node-platform machine-id detectors. We never call those
97
+ // detectors — `instrumentWorker` builds the OTel Resource from explicit
98
+ // attributes only — but Vite's CF Workers SSR resolver still walks the
99
+ // re-export barrel and chokes on the bare `fs` specifier (workerd's
100
+ // `nodejs_compat` only exposes the prefixed `node:fs`, not the legacy
101
+ // bare form). Stub it; the static import resolves and the unreachable
102
+ // detector code is never executed.
103
+ fs: "\0stub:bare-fs",
104
+ };
105
+
106
+ // Minimal stub source for each virtual module.
107
+ /** @type {Record<string, string>} */
108
+ const STUB_SOURCE = {
109
+ "\0stub:react-dom-server": [
110
+ "const noop = () => '';",
111
+ "export const renderToString = noop;",
112
+ "export const renderToStaticMarkup = noop;",
113
+ "export const renderToReadableStream = noop;",
114
+ "export const resume = noop;",
115
+ "export const version = '19.0.0';",
116
+ "export default { renderToString: noop, renderToStaticMarkup: noop, renderToReadableStream: noop, resume: noop, version: '19.0.0' };",
117
+ ].join("\n"),
118
+
119
+ "\0stub:node-stream":
120
+ "export class PassThrough {}; export class Readable {}; export class Writable {}; export default { PassThrough, Readable, Writable };",
121
+
122
+ "\0stub:node-stream-web":
123
+ "export const ReadableStream = globalThis.ReadableStream; export const WritableStream = globalThis.WritableStream; export const TransformStream = globalThis.TransformStream; export default { ReadableStream, WritableStream, TransformStream };",
124
+
125
+ "\0stub:node-async-hooks": [
126
+ "class _ALS { getStore() { return undefined; } run(_store, fn, ...args) { return fn(...args); } enterWith() {} disable() {} }",
127
+ "export const AsyncLocalStorage = _ALS;",
128
+ "export const AsyncResource = class {};",
129
+ "export function executionAsyncId() { return 0; }",
130
+ "export function createHook() { return { enable() {}, disable() {} }; }",
131
+ "export default { AsyncLocalStorage: _ALS, AsyncResource, executionAsyncId, createHook };",
132
+ ].join("\n"),
133
+
134
+ "\0stub:tanstack-head-scripts": "export const injectedHeadScripts = undefined;",
135
+
136
+ // The admin schema bundle is server-only — the client receives pre-resolved
137
+ // blocks via the SSR payload. Stubbing it on the client cuts a large module
138
+ // (typically 0.5-5 MB) out of the browser bundle.
139
+ "\0stub:meta-gen": "export default {};",
140
+
141
+ // Bare `fs` shim — see SSR_STUBS comment above for the rationale. Surfaces
142
+ // just enough of `import { promises as fs } from 'fs'` to satisfy static
143
+ // module resolution; method calls would throw, but the OTel detector code
144
+ // path is unreachable from `instrumentWorker`.
145
+ "\0stub:bare-fs": "export const promises = {}; export default { promises };",
146
+ };
147
+
148
+ /** @returns {import("vite").PluginOption} */
149
+ export function decoVitePlugin() {
150
+ /** @type {import("vite").Plugin} */
151
+ const plugin = {
152
+ name: "deco-server-only-stubs",
153
+ enforce: "pre",
154
+
155
+ resolveId(id, importer, options) {
156
+ // SSR-only stubs — must be checked first since the client guard below
157
+ // returns undefined for everything that hasn't matched yet on SSR.
158
+ if (options?.ssr && SSR_STUBS[id]) return SSR_STUBS[id];
159
+ // Server builds keep the real modules.
160
+ if (options?.ssr) return undefined;
161
+ // Bare-specifier exact-match stubs (react-dom/server, node:stream, etc.).
162
+ if (CLIENT_STUBS[id]) return CLIENT_STUBS[id];
163
+ // meta.gen.{json,ts} — the admin schema bundle. Server-only; client
164
+ // receives pre-resolved blocks. Matches both file extensions so the
165
+ // plugin works whether `setup.ts` imports the .json directly (current)
166
+ // or a future variant routes through a generated .ts wrapper.
167
+ // Requires `importer` so we don't accidentally stub the entry module.
168
+ if (importer && (id.endsWith("meta.gen.json") || id.endsWith("meta.gen.ts"))) {
169
+ return "\0stub:meta-gen";
170
+ }
171
+ return undefined;
172
+ },
173
+
174
+ load(id, options) {
175
+ // blocks.gen.ts — the CMS block registry (can be 10MB+).
176
+ if (id.endsWith("blocks.gen.ts")) {
177
+ // Client: stub — the browser receives pre-resolved sections.
178
+ if (!options?.ssr) {
179
+ return "export const blocks = {};";
180
+ }
181
+
182
+ // SSR: read .json sibling and emit JSON.parse(...) wrapper.
183
+ // This avoids the Vite SSR module runner hanging on large dynamic
184
+ // imports and lets V8 use its fast JSON parser (~2-10x vs object literal).
185
+ const jsonPath = id.replace(/\.ts$/, ".json");
186
+ if (existsSync(jsonPath)) {
187
+ const raw = readFileSync(jsonPath, "utf-8");
188
+ return `export const blocks = JSON.parse(${JSON.stringify(raw)});`;
189
+ }
190
+
191
+ // Fallback: if .json doesn't exist yet (pre-generate-blocks), let
192
+ // Vite load the .ts file normally (may contain inline data for
193
+ // backward-compatible sites that haven't regenerated).
194
+ }
195
+
196
+ // Virtual module stubs.
197
+ return STUB_SOURCE[id];
198
+ },
199
+
200
+ configureServer(server) {
201
+ // Watch `.deco/blocks/**/*.json` and regenerate `blocks.gen.json` when
202
+ // CMS content changes (manual edit, sync-decofile, daemon PATCH).
203
+ // After regen, we POST the new blocks to the dev server's own
204
+ // /.decofile endpoint — this calls setBlocks() inside the workerd SSR
205
+ // runtime without any module invalidation (which breaks TanStack
206
+ // Start/Router state).
207
+ //
208
+ // Generator is loaded lazily via tsImport (same pattern as the daemon
209
+ // below) so we don't depend on the consumer's TS loader.
210
+ const cwd = process.cwd();
211
+ const blocksDir = path.resolve(cwd, ".deco/blocks");
212
+ const outFile = path.resolve(cwd, "src/server/cms/blocks.gen.ts");
213
+ const jsonFile = outFile.replace(/\.ts$/, ".json");
214
+
215
+ // Lazily load the block generator module (generateBlocks for the cold-start
216
+ // bootstrap, readBlockDelta for live edits). Same tsImport pattern as the
217
+ // daemon loader below — keeps `tsx` scoped to this single import instead of
218
+ // registering a global hook.
219
+ let genModule;
220
+ const loadGenModule = () => {
221
+ if (genModule) return Promise.resolve(genModule);
222
+ return import("tsx/esm/api")
223
+ .then(({ tsImport }) =>
224
+ tsImport("@decocms/blocks-cli/generate-blocks", import.meta.url),
225
+ )
226
+ .then((mod) => {
227
+ genModule = mod;
228
+ return mod;
229
+ });
230
+ };
231
+
232
+ // In-memory copy of the merged decofile, seeded once from the full
233
+ // generator run and patched with cheap deltas thereafter. Lets us keep
234
+ // `blocks.gen.json` on disk fresh WITHOUT re-reading + re-parsing all
235
+ // ~hundreds of `.deco/blocks/*.json` files on every edit (that whole-dir
236
+ // re-read is what pegged the event loop for seconds and tripped the
237
+ // Studio liveness probe).
238
+ /** @type {Record<string, unknown> | null} */
239
+ let mergedBlocks = null;
240
+ const ensureMerged = async () => {
241
+ if (mergedBlocks) return { merged: mergedBlocks, result: null };
242
+ const { generateBlocks } = await loadGenModule();
243
+ const result = await generateBlocks({ blocksDir, outFile, silent: true });
244
+ mergedBlocks = result.empty ? {} : result.blocks;
245
+ return { merged: mergedBlocks, result };
246
+ };
247
+
248
+ // Live block edits are applied as a DELTA. The Studio daemon (or a manual
249
+ // edit) writes a single `.deco/blocks/*.json` file; re-reading and
250
+ // re-merging the whole directory on every write blocks the Node/Vite event
251
+ // loop for seconds. Instead we read only the changed file(s) and:
252
+ // 1. patch the in-memory merged map and rewrite `blocks.gen.json` — this
253
+ // keeps the on-disk snapshot authoritative, because Vite's
254
+ // `full-reload` re-evaluates the SSR entry, which re-seeds
255
+ // setBlocks() from `blocks.gen.json`. A stale file here renders stale
256
+ // content on the auto-reload (only a later manual refresh, which does
257
+ // NOT re-evaluate, would show the edit).
258
+ // 2. POST a delta envelope to `/.decofile` so the live isolate's
259
+ // in-memory snapshot is updated too (covers reloads that don't
260
+ // re-evaluate) — merged via applyDelta(), no module invalidation.
261
+ let regenTimer = null;
262
+ let regenInFlight = false;
263
+ let regenQueued = false;
264
+ /** @type {Map<string, boolean>} block filename -> isDelete (last event wins). */
265
+ let pendingBlocks = new Map();
266
+ const runDelta = async () => {
267
+ if (regenInFlight) {
268
+ regenQueued = true;
269
+ return;
270
+ }
271
+ if (pendingBlocks.size === 0) return;
272
+ regenInFlight = true;
273
+ const batch = pendingBlocks;
274
+ pendingBlocks = new Map();
275
+ try {
276
+ const { readBlockDelta } = await loadGenModule();
277
+ const start = Date.now();
278
+ const files = [...batch.entries()].map(([name, isDelete]) => ({
279
+ name,
280
+ isDelete,
281
+ }));
282
+ const delta = readBlockDelta({ blocksDir, files, silent: true });
283
+ const keys = Object.keys(delta);
284
+ if (keys.length === 0) return;
285
+
286
+ // Patch the merged map + rewrite blocks.gen.json so an SSR re-eval on
287
+ // reload reads fresh content. ensureMerged() seeds from a single full
288
+ // run the first time (reading current disk, which already includes the
289
+ // change); afterwards it's O(changed keys).
290
+ const { merged } = await ensureMerged();
291
+ for (const [name, value] of Object.entries(delta)) {
292
+ if (value === null) delete merged[name];
293
+ else merged[name] = value;
294
+ }
295
+ try {
296
+ writeFileSync(jsonFile, JSON.stringify(merged));
297
+ } catch (writeErr) {
298
+ console.warn("[deco] failed to write blocks.gen.json:", writeErr?.message ?? writeErr);
299
+ }
300
+
301
+ // POST a delta envelope ({ blocks: {...} }) so handleDecofileReload
302
+ // merges it over the current snapshot instead of replacing the whole
303
+ // decofile. setBlocks() runs inside the workerd SSR runtime.
304
+ const addr = server.httpServer?.address();
305
+ const port = typeof addr === "object" && addr ? addr.port : 5173;
306
+ const res = await fetch(`http://localhost:${port}/.decofile`, {
307
+ method: "POST",
308
+ headers: { "Content-Type": "application/json" },
309
+ body: JSON.stringify({ blocks: delta }),
310
+ });
311
+ const ms = Date.now() - start;
312
+ if (res.ok) {
313
+ console.log(`[deco] applied ${keys.length}-block delta in ${ms}ms`);
314
+ server.hot?.send({ type: "full-reload", path: "*" });
315
+ } else {
316
+ console.warn(`[deco] block delta reload failed: ${res.status}`);
317
+ }
318
+ } catch (err) {
319
+ console.warn("[deco] failed to apply block delta:", err?.message ?? err);
320
+ } finally {
321
+ regenInFlight = false;
322
+ if (regenQueued) {
323
+ regenQueued = false;
324
+ scheduleRegen();
325
+ }
326
+ }
327
+ };
328
+ const scheduleRegen = () => {
329
+ if (regenTimer) clearTimeout(regenTimer);
330
+ regenTimer = setTimeout(() => {
331
+ regenTimer = null;
332
+ runDelta();
333
+ }, 150);
334
+ };
335
+
336
+ // chokidar (Vite's watcher) needs the directory added explicitly because
337
+ // `.deco/` lives outside the module graph it walks by default.
338
+ if (existsSync(blocksDir)) {
339
+ server.watcher.add(blocksDir);
340
+ }
341
+ const handleBlocksDirEvent = (file, isDelete) => {
342
+ if (!file.endsWith(".json")) return;
343
+ if (!file.startsWith(blocksDir + path.sep) && file !== blocksDir) return;
344
+ // Block files are flat inside `.deco/blocks/`, so the basename is the
345
+ // encoded-key + ".json" filename that readBlockDelta expects.
346
+ pendingBlocks.set(path.basename(file), isDelete);
347
+ scheduleRegen();
348
+ };
349
+ server.watcher.on("add", (file) => handleBlocksDirEvent(file, false));
350
+ server.watcher.on("change", (file) => handleBlocksDirEvent(file, false));
351
+ server.watcher.on("unlink", (file) => handleBlocksDirEvent(file, true));
352
+
353
+ // Cold-start bootstrap: always regenerate `blocks.gen.json` from source
354
+ // on dev startup. We deliberately do NOT gate this on mtime. A fresh git
355
+ // checkout/clone (e.g. Studio's preview sandbox) rewrites every file's
356
+ // mtime to the checkout time, so the committed artifact can be CONTENT-
357
+ // stale yet mtime-"fresh" — the old `source.mtime > artifact.mtime` gate
358
+ // then skipped regen and served the stale snapshot (the double-render
359
+ // bug in branch previews). Regen is cheap (tens of ms for a typical
360
+ // decofile) and fire-and-forget, so it never blocks startup. No POST
361
+ // /.decofile here — the SSR runtime isn't listening yet, and `setup.ts`
362
+ // reads the freshly-written .json via the load() hook on the first
363
+ // request (the watch-driven path above handles live edits, with reload).
364
+ if (existsSync(blocksDir)) {
365
+ ensureMerged()
366
+ .then(({ result }) => {
367
+ if (result && !result.empty) {
368
+ console.log(`[deco] bootstrapped ${result.count} blocks from .deco/blocks`);
369
+ }
370
+ })
371
+ .catch((err) => {
372
+ console.warn("[deco] blocks bootstrap failed:", err?.message ?? err);
373
+ });
374
+ }
375
+
376
+ // --- meta.gen.json auto-regeneration ---
377
+ // When section/loader/app source files change (types, JSDoc, Props),
378
+ // re-run generate-schema.ts so meta.gen.json stays in sync during dev.
379
+ const schemaWatchDirs = ["src"];
380
+ const schemaOutFile = path.resolve(cwd, "src/server/admin/meta.gen.json");
381
+
382
+ // Resolve the site name once from vite define or env.
383
+ const definedSite = server.config.define?.["process.env.DECO_SITE_NAME"];
384
+ const schemaSiteName = definedSite
385
+ ? JSON.parse(definedSite)
386
+ : process.env.DECO_SITE_NAME || "storefront";
387
+
388
+ let schemaTimer = null;
389
+ let schemaInFlight = false;
390
+ let schemaQueued = false;
391
+ const runSchemaGen = () => {
392
+ if (schemaInFlight) {
393
+ schemaQueued = true;
394
+ return;
395
+ }
396
+ schemaInFlight = true;
397
+ const start = Date.now();
398
+ const scriptPath = path.resolve(
399
+ cwd,
400
+ "node_modules/@decocms/blocks-cli/scripts/generate-schema.ts",
401
+ );
402
+ const cmd = `npx tsx ${JSON.stringify(scriptPath)} --site ${schemaSiteName}`;
403
+ exec(cmd, { cwd }, (err) => {
404
+ schemaInFlight = false;
405
+ if (err) {
406
+ console.warn("[deco] schema generation failed:", err.message);
407
+ } else {
408
+ console.log(`[deco] meta.gen.json updated (${Date.now() - start}ms)`);
409
+ // Invalidate the meta.gen.json module so SSR picks up fresh schema
410
+ const mod =
411
+ server.environments?.ssr?.moduleGraph?.getModuleById(schemaOutFile);
412
+ if (mod) {
413
+ server.environments.ssr.moduleGraph.invalidateModule(mod);
414
+ }
415
+ }
416
+ if (schemaQueued) {
417
+ schemaQueued = false;
418
+ scheduleSchemaGen();
419
+ }
420
+ },
421
+ );
422
+ };
423
+ const scheduleSchemaGen = () => {
424
+ if (schemaTimer) clearTimeout(schemaTimer);
425
+ schemaTimer = setTimeout(() => {
426
+ schemaTimer = null;
427
+ runSchemaGen();
428
+ }, 500);
429
+ };
430
+
431
+ const isSchemaSource = (file) => {
432
+ const rel = path.relative(cwd, file);
433
+ return (
434
+ schemaWatchDirs.some((d) => rel.startsWith(d + path.sep)) &&
435
+ (rel.endsWith(".tsx") || rel.endsWith(".ts"))
436
+ );
437
+ };
438
+ server.watcher.on("change", (file) => {
439
+ if (isSchemaSource(file)) scheduleSchemaGen();
440
+ });
441
+ server.watcher.on("add", (file) => {
442
+ if (isSchemaSource(file)) scheduleSchemaGen();
443
+ });
444
+
445
+ // Tunnel + daemon: connect local dev to admin.deco.cx
446
+ // Activated only when both DECO_SITE_NAME and DECO_ENV_NAME are set.
447
+ // Omitting DECO_ENV_NAME runs Vite fully local (no tunnel registration),
448
+ // since DECO_SITE_NAME alone is also consumed by site builds via vite's
449
+ // `define` for `process.env.DECO_SITE_NAME` and shouldn't force a tunnel.
450
+ const siteName = process.env.DECO_SITE_NAME;
451
+ const envName = process.env.DECO_ENV_NAME;
452
+ if (siteName && envName) {
453
+ // Daemon files are .ts and live inside node_modules. Node's
454
+ // experimental strip-types refuses to transpile node_modules, so
455
+ // a plain dynamic `import()` blows up under `vite dev`. Use tsx's
456
+ // ad-hoc loader (`tsImport`) — scoped to this import, doesn't
457
+ // register a global hook.
458
+ const loadDaemon = (specifier) =>
459
+ import("tsx/esm/api").then(({ tsImport }) => tsImport(specifier, import.meta.url));
460
+
461
+ // Add daemon middleware (x-daemon-api interception + auth + volumes + SSE + admin routes)
462
+ loadDaemon("../daemon/middleware.ts")
463
+ .then(({ createDaemonMiddleware }) => {
464
+ server.middlewares.use(createDaemonMiddleware({ site: siteName, server }));
465
+ })
466
+ .catch((err) => {
467
+ console.warn("[deco] Failed to load daemon middleware:", err.message);
468
+ });
469
+
470
+ // Start tunnel after HTTP server is listening (so we know the real port)
471
+ server.httpServer?.once("listening", async () => {
472
+ const addr = server.httpServer?.address();
473
+ const port = typeof addr === "object" && addr ? addr.port : 5173;
474
+ try {
475
+ const { startTunnel } = await loadDaemon("../daemon/tunnel.ts");
476
+ const tunnel = await startTunnel({
477
+ site: siteName,
478
+ env: envName,
479
+ port,
480
+ // Default to the .deco.host relay (matches startTunnel's documented
481
+ // default). Set DECO_HOST=false to opt back into the legacy
482
+ // simpletunnel.deco.site relay.
483
+ decoHost: process.env.DECO_HOST !== "false",
484
+ });
485
+ server.httpServer?.on("close", () => tunnel.close());
486
+ } catch (err) {
487
+ console.warn("[deco] Failed to start tunnel:", err.message);
488
+ }
489
+ });
490
+ }
491
+ },
492
+
493
+ config(_cfg, { command }) {
494
+ /** @type {import("vite").UserConfig} */
495
+ const cfg = {};
496
+
497
+ // Allow tunnel domains through Vite's host check.
498
+ // .deco.studio is the new admin frontend; both real-world Deco sites
499
+ // (casaevideo-storefront, baggagio-tanstack) duplicated this list to
500
+ // include it — bundling it here removes that boilerplate.
501
+ if (process.env.DECO_SITE_NAME) {
502
+ cfg.server = {
503
+ allowedHosts: [".deco.host", ".decocdn.com", ".deco.studio"],
504
+ };
505
+ }
506
+
507
+ // Inject a per-build identifier as `__DECO_BUILD_HASH__` so
508
+ // createDecoWorkerEntry can fall back to it when env.BUILD_HASH is
509
+ // unset (the default on Cloudflare Workers Builds, where there's
510
+ // no GH-Actions step injecting --var BUILD_HASH).
511
+ //
512
+ // Dev gets the literal "dev" so SSR doesn't crash on an undefined
513
+ // identifier; prod gets WORKERS_CI_COMMIT_SHA → git rev-parse →
514
+ // time-based fallback (see resolveBuildHash above).
515
+ const buildHash = command === "build" ? resolveBuildHash() : "dev";
516
+ cfg.define = {
517
+ ...cfg.define,
518
+ __DECO_BUILD_HASH__: JSON.stringify(buildHash),
519
+ };
520
+
521
+ // Only split chunks for production builds — dev uses unbundled ESM.
522
+ if (command !== "build") return cfg;
523
+ return {
524
+ ...cfg,
525
+ build: {
526
+ rollupOptions: {
527
+ output: {
528
+ manualChunks(id) {
529
+ if (id.includes("node_modules/react-dom") || id.includes("node_modules/react/")) {
530
+ return "vendor-react";
531
+ }
532
+
533
+ // TanStack Router — client-side router (always needed)
534
+ if (id.includes("@tanstack/react-router") || id.includes("@tanstack/router-core")) {
535
+ return "vendor-router";
536
+ }
537
+
538
+ // TanStack Start — specific checks before broad catch-all
539
+ // (react-start-client includes "react-start" so must come first)
540
+ if (
541
+ id.includes("@tanstack/react-start-client") ||
542
+ id.includes("@tanstack/start-client-core")
543
+ ) {
544
+ return "vendor-router";
545
+ }
546
+ // Server-only TanStack packages — let Rollup tree-shake
547
+ if (
548
+ id.includes("@tanstack/react-start-server") ||
549
+ id.includes("@tanstack/start-server-core")
550
+ ) {
551
+ return undefined;
552
+ }
553
+ // Remaining @tanstack/start (storage-context, plugin-core, etc.)
554
+ if (id.includes("@tanstack/start")) {
555
+ return "vendor-router";
556
+ }
557
+
558
+ // isbot — server-only (bot detection in resolve.ts)
559
+ if (id.includes("node_modules/isbot")) {
560
+ return undefined;
561
+ }
562
+
563
+ if (id.includes("@tanstack/react-query")) {
564
+ return "vendor-query";
565
+ }
566
+ // Intentionally NOT splitting @decocms/tanstack,
567
+ // @decocms/blocks, @decocms/blocks-admin, or
568
+ // @decocms/apps: they have circular re-exports (e.g. apps
569
+ // imports from runtime/sdk/cachedLoader, admin
570
+ // imports from apps). Splitting them into separate chunks
571
+ // produces a Rollup chunk-load order that crashes at runtime
572
+ // ("undefined is not a function") — both real-world sites
573
+ // worked around this by overriding manualChunks. Letting
574
+ // Rollup bundle them together (or with the importing chunk)
575
+ // is correct.
576
+ },
577
+ },
578
+ },
579
+ },
580
+ };
581
+ },
582
+
583
+ configEnvironment(name, env) {
584
+ if (name === "ssr" || name === "client") {
585
+ env.optimizeDeps = env.optimizeDeps || {};
586
+ env.optimizeDeps.esbuildOptions = env.optimizeDeps.esbuildOptions || {};
587
+ env.optimizeDeps.esbuildOptions.jsx = "automatic";
588
+ env.optimizeDeps.esbuildOptions.jsxImportSource = "react";
589
+ }
590
+
591
+ // Force @decocms/tanstack through the SSR transform pipeline so
592
+ // TanStack Start's compiler can register its createServerFn handlers
593
+ // (loadDeferredSection in routes/cmsRoute.ts, and loadCmsPage /
594
+ // loadCmsHomePage alongside it) in the per-environment serverFnsById
595
+ // manifest. Without this, Vite pre-bundles the package via
596
+ // optimizeDeps before plugins run, the handler never enters the
597
+ // manifest, and every POST /_serverFn/* call from the browser returns
598
+ // HTTP 500 ("Invalid server function ID"). See #197.
599
+ //
600
+ // @decocms/blocks does NOT need this: despite its
601
+ // validateSection.ts / useScript.ts doc comments mentioning
602
+ // `createServerFn`, neither file (nor anything else in runtime)
603
+ // actually calls it — those are a JSDoc usage example for a site's
604
+ // own server-fn file and a deprecation-message suggestion,
605
+ // respectively. Only add a package here once it has a real
606
+ // `createServerFn(...)` call site.
607
+ if (name === "ssr") {
608
+ env.resolve = env.resolve || {};
609
+ const existing = env.resolve.noExternal;
610
+ const additions = ["@decocms/tanstack"];
611
+ if (existing === true) {
612
+ // Already noExternal everything — nothing to add.
613
+ } else if (Array.isArray(existing)) {
614
+ env.resolve.noExternal = [...new Set([...existing, ...additions])];
615
+ } else if (existing) {
616
+ env.resolve.noExternal = [existing, ...additions];
617
+ } else {
618
+ env.resolve.noExternal = additions;
619
+ }
620
+ }
621
+ },
622
+
623
+ generateBundle(_, bundle) {
624
+ // Build a mapping from section key to chunk filename.
625
+ // Sites use this to emit <link rel="modulepreload"> for eager sections.
626
+ const map = {};
627
+ for (const [fileName, chunk] of Object.entries(bundle)) {
628
+ if (chunk.type === "chunk" && chunk.facadeModuleId) {
629
+ const match = chunk.facadeModuleId.match(/\/(sections\/.+\.tsx)$/);
630
+ if (match) {
631
+ map["site/" + match[1]] = fileName;
632
+ }
633
+ }
634
+ }
635
+ if (Object.keys(map).length > 0) {
636
+ this.emitFile({
637
+ type: "asset",
638
+ fileName: "section-chunks.json",
639
+ source: JSON.stringify(map),
640
+ });
641
+ }
642
+ },
643
+ };
644
+
645
+ return plugin;
646
+ }