@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.
- package/package.json +48 -0
- package/src/cms/kvBlockSource.test.ts +67 -0
- package/src/cms/kvBlockSource.ts +54 -0
- package/src/daemon/auth.ts +204 -0
- package/src/daemon/fs.ts +238 -0
- package/src/daemon/index.ts +8 -0
- package/src/daemon/middleware.ts +156 -0
- package/src/daemon/tunnel.ts +129 -0
- package/src/daemon/volumes.ts +366 -0
- package/src/daemon/watch.ts +272 -0
- package/src/hooks/DecoPageRenderer.tsx +685 -0
- package/src/hooks/DecoRootLayout.tsx +111 -0
- package/src/hooks/NavigationProgress.tsx +21 -0
- package/src/hooks/PreviewProviders.tsx +26 -0
- package/src/hooks/StableOutlet.tsx +30 -0
- package/src/hooks/index.ts +9 -0
- package/src/index.ts +30 -0
- package/src/routes/adminRoutes.ts +114 -0
- package/src/routes/cmsRoute.ts +706 -0
- package/src/routes/components.tsx +57 -0
- package/src/routes/index.ts +24 -0
- package/src/routes/pageUrl.test.ts +70 -0
- package/src/routes/pageUrl.ts +54 -0
- package/src/routes/withSiteGlobals.test.ts +206 -0
- package/src/routes/withSiteGlobals.ts +222 -0
- package/src/sdk/cookiePassthrough.ts +58 -0
- package/src/sdk/createInvoke.ts +57 -0
- package/src/sdk/kvHydration.test.ts +171 -0
- package/src/sdk/kvHydration.ts +176 -0
- package/src/sdk/router.ts +92 -0
- package/src/sdk/useHydrated.ts +19 -0
- package/src/sdk/workerEntry.test.ts +106 -0
- package/src/sdk/workerEntry.ts +1967 -0
- package/src/setupFastDeploy.ts +13 -0
- package/src/vite/plugin.js +646 -0
- package/src/vite/plugin.test.js +113 -0
- package/tsconfig.json +7 -0
|
@@ -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
|
+
}
|