@decocms/start 2.0.0 → 2.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,366 @@
1
+ /**
2
+ * Volumes API — CRUD for .deco/ files with JSON patch support
3
+ * and WebSocket realtime broadcast of file changes.
4
+ *
5
+ * Ported from: deco-cx/deco daemon/realtime/app.ts (without CRDT)
6
+ */
7
+ import { readdir, readFile, writeFile, mkdir, rm, stat } from "node:fs/promises";
8
+ import { join, resolve, sep, posix } from "node:path";
9
+ import type { IncomingMessage, ServerResponse, Server as HttpServer } from "node:http";
10
+ import { WebSocketServer, WebSocket } from "ws";
11
+ import fjp from "fast-json-patch";
12
+ import type { Operation } from "fast-json-patch";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types — ported from daemon/realtime/types.ts
16
+ // ---------------------------------------------------------------------------
17
+
18
+ interface BaseFilePatch {
19
+ path: string;
20
+ }
21
+
22
+ interface JSONFilePatch extends BaseFilePatch {
23
+ patches: Operation[];
24
+ }
25
+
26
+ interface TextFileSet extends BaseFilePatch {
27
+ content: string | null;
28
+ }
29
+
30
+ type FilePatch = JSONFilePatch | TextFileSet;
31
+
32
+ interface VolumePatchRequest {
33
+ messageId?: string;
34
+ patches: FilePatch[];
35
+ }
36
+
37
+ interface FilePatchResult {
38
+ path: string;
39
+ accepted: boolean;
40
+ content?: string;
41
+ deleted?: boolean;
42
+ }
43
+
44
+ interface VolumePatchResponse {
45
+ results: FilePatchResult[];
46
+ timestamp: number;
47
+ }
48
+
49
+ function isJSONFilePatch(patch: FilePatch): patch is JSONFilePatch {
50
+ return "patches" in patch && Array.isArray((patch as JSONFilePatch).patches);
51
+ }
52
+
53
+ function isTextFileSet(patch: FilePatch): patch is TextFileSet {
54
+ return "content" in patch && !("patches" in patch);
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Helpers
59
+ // ---------------------------------------------------------------------------
60
+
61
+ const toPosix = (p: string) => p.replaceAll(sep, "/");
62
+
63
+ function safePath(base: string, untrusted: string): string | null {
64
+ const resolved = resolve(base, untrusted);
65
+ if (!resolved.startsWith(base + sep) && resolved !== base) return null;
66
+ return resolved;
67
+ }
68
+
69
+ async function readTextFileSafe(path: string): Promise<string | null> {
70
+ try {
71
+ return await readFile(path, "utf-8");
72
+ } catch (err: unknown) {
73
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
74
+ throw err;
75
+ }
76
+ }
77
+
78
+ async function ensureFile(path: string): Promise<void> {
79
+ const dir = join(path, "..");
80
+ await mkdir(dir, { recursive: true });
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // WebSocket realtime sessions
85
+ // ---------------------------------------------------------------------------
86
+
87
+ interface BroadcastMessage {
88
+ path: string;
89
+ timestamp: number;
90
+ deleted?: boolean;
91
+ messageId?: string;
92
+ }
93
+
94
+ interface VolumesState {
95
+ sessions: WebSocket[];
96
+ wss: WebSocketServer;
97
+ timestamp: number;
98
+ }
99
+
100
+ function broadcast(state: VolumesState, msg: BroadcastMessage): void {
101
+ const data = JSON.stringify(msg);
102
+ for (const ws of state.sessions) {
103
+ if (ws.readyState === WebSocket.OPEN) {
104
+ ws.send(data);
105
+ }
106
+ }
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Walk directory (Node.js equivalent of @std/fs/walk)
111
+ // ---------------------------------------------------------------------------
112
+
113
+ async function walkFiles(
114
+ root: string,
115
+ ): Promise<string[]> {
116
+ const results: string[] = [];
117
+ try {
118
+ const entries = await readdir(root, {
119
+ recursive: true,
120
+ withFileTypes: true,
121
+ });
122
+ for (const entry of entries) {
123
+ if (!entry.isFile()) continue;
124
+ const fullPath = join(entry.parentPath, entry.name);
125
+ const rel = toPosix(fullPath.replace(root, ""));
126
+ if (
127
+ rel.includes("/.git/") ||
128
+ rel.includes("/node_modules/") ||
129
+ rel.includes("/.agent-home/") ||
130
+ rel.includes("/.claude/")
131
+ ) {
132
+ continue;
133
+ }
134
+ results.push(fullPath);
135
+ }
136
+ } catch {
137
+ // root might be a file, not a directory
138
+ }
139
+ return results;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Request handlers
144
+ // ---------------------------------------------------------------------------
145
+
146
+ const cwd = process.cwd();
147
+
148
+ async function handleGetFiles(
149
+ req: IncomingMessage,
150
+ res: ServerResponse,
151
+ state: VolumesState,
152
+ ): Promise<void> {
153
+ const url = new URL(req.url ?? "/", "http://localhost");
154
+ const [, ...segments] = url.pathname.split("/files");
155
+ const filePath = segments.join("/files") || "/";
156
+ const withContent = url.searchParams.get("content") === "true";
157
+
158
+ const root = safePath(cwd, filePath);
159
+ if (!root) {
160
+ res.writeHead(403);
161
+ res.end("Path traversal denied");
162
+ return;
163
+ }
164
+ const fs: Record<string, { content: string | null }> = {};
165
+
166
+ const files = await walkFiles(root);
167
+ if (files.length > 0) {
168
+ for (const fullPath of files) {
169
+ const key = toPosix(fullPath.replace(root, "/"));
170
+ fs[key] = {
171
+ content: withContent ? await readTextFileSafe(fullPath) : null,
172
+ };
173
+ }
174
+ } else {
175
+ // Might be a single file
176
+ const content = withContent ? await readTextFileSafe(root) : null;
177
+ fs[toPosix(filePath)] = { content };
178
+ }
179
+
180
+ const body = JSON.stringify({ timestamp: state.timestamp, fs });
181
+ res.writeHead(200, { "Content-Type": "application/json" });
182
+ res.end(body);
183
+ }
184
+
185
+ async function handlePatchFiles(
186
+ req: IncomingMessage,
187
+ res: ServerResponse,
188
+ state: VolumesState,
189
+ ): Promise<void> {
190
+ const raw = await readBody(req);
191
+ let request: VolumePatchRequest;
192
+ try {
193
+ request = JSON.parse(raw);
194
+ } catch {
195
+ res.writeHead(400);
196
+ res.end("Invalid JSON");
197
+ return;
198
+ }
199
+
200
+ const results: FilePatchResult[] = [];
201
+
202
+ for (const patch of request.patches) {
203
+ // Validate path traversal for every patch
204
+ const resolvedPath = safePath(cwd, patch.path);
205
+ if (!resolvedPath) {
206
+ results.push({ accepted: false, path: patch.path });
207
+ continue;
208
+ }
209
+
210
+ if (isJSONFilePatch(patch)) {
211
+ const { path: filePath, patches: operations } = patch;
212
+ const content =
213
+ (await readTextFileSafe(resolvedPath)) ?? "{}";
214
+ try {
215
+ const newContent = JSON.stringify(
216
+ operations.reduce(fjp.applyReducer, JSON.parse(content)),
217
+ );
218
+ results.push({
219
+ accepted: true,
220
+ path: filePath,
221
+ content: newContent,
222
+ deleted: newContent === "null",
223
+ });
224
+ } catch (error) {
225
+ console.error(error);
226
+ results.push({ accepted: false, path: filePath, content });
227
+ }
228
+ } else if (isTextFileSet(patch)) {
229
+ const { path: filePath, content } = patch;
230
+ results.push({
231
+ accepted: true,
232
+ path: filePath,
233
+ content: content ?? "",
234
+ deleted: content === null,
235
+ });
236
+ }
237
+ }
238
+
239
+ state.timestamp = Date.now();
240
+
241
+ // Atomic: only commit writes if all patches accepted
242
+ const shouldWrite = results.every((r) => r.accepted);
243
+ if (shouldWrite) {
244
+ await Promise.all(
245
+ results.map(async (r) => {
246
+ try {
247
+ const system = join(cwd, r.path);
248
+ if (r.deleted) {
249
+ await rm(system, { force: true });
250
+ } else if (r.content != null) {
251
+ await ensureFile(system);
252
+ await writeFile(system, r.content, "utf-8");
253
+ }
254
+ } catch (error) {
255
+ console.error(error);
256
+ r.accepted = false;
257
+ }
258
+ }),
259
+ );
260
+ }
261
+
262
+ const body: VolumePatchResponse = { timestamp: state.timestamp, results };
263
+ res.writeHead(200, { "Content-Type": "application/json" });
264
+ res.end(JSON.stringify(body));
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Body reader helper
269
+ // ---------------------------------------------------------------------------
270
+
271
+ function readBody(req: IncomingMessage): Promise<string> {
272
+ return new Promise((resolve, reject) => {
273
+ const chunks: Buffer[] = [];
274
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
275
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
276
+ req.on("error", reject);
277
+ });
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Setup: attach WebSocket server and file watcher
282
+ // ---------------------------------------------------------------------------
283
+
284
+ export interface VolumesOptions {
285
+ /** Vite's HTTP server to attach WebSocket upgrades to. */
286
+ httpServer: HttpServer;
287
+ /** Vite's file watcher (chokidar instance) for broadcasting changes. */
288
+ watcher: { on(event: string, cb: (...args: unknown[]) => void): void };
289
+ }
290
+
291
+ export function createVolumesHandler(opts: VolumesOptions) {
292
+ const state: VolumesState = {
293
+ sessions: [],
294
+ wss: new WebSocketServer({ noServer: true }),
295
+ timestamp: Date.now(),
296
+ };
297
+
298
+ // Handle WebSocket upgrades for /volumes/*/files paths with x-daemon-api
299
+ opts.httpServer.on("upgrade", (req, socket, head) => {
300
+ const isDaemon =
301
+ req.headers["x-daemon-api"] ?? req.headers["x-hypervisor-api"];
302
+ if (!isDaemon) return;
303
+
304
+ const url = req.url ?? "";
305
+ if (!url.includes("/volumes/") || !url.includes("/files")) return;
306
+
307
+ state.wss.handleUpgrade(req, socket, head, (ws) => {
308
+ state.sessions.push(ws);
309
+ console.log("[deco] admin websocket connected");
310
+
311
+ ws.on("close", () => {
312
+ console.log("[deco] admin websocket disconnected");
313
+ const idx = state.sessions.indexOf(ws);
314
+ if (idx > -1) state.sessions.splice(idx, 1);
315
+ });
316
+ });
317
+ });
318
+
319
+ // Broadcast file changes from Vite's watcher
320
+ const broadcastChange = (filePath: string, deleted = false) => {
321
+ const rel = toPosix(filePath).replace(toPosix(cwd), "");
322
+ if (
323
+ rel.includes("/.git/") ||
324
+ rel.includes("/node_modules/") ||
325
+ rel.includes("/.agent-home/") ||
326
+ rel.includes("/.claude/")
327
+ ) {
328
+ return;
329
+ }
330
+ broadcast(state, { path: rel, timestamp: Date.now(), deleted });
331
+ };
332
+
333
+ opts.watcher.on("change", (path: unknown) => {
334
+ if (typeof path === "string") broadcastChange(path);
335
+ });
336
+ opts.watcher.on("add", (path: unknown) => {
337
+ if (typeof path === "string") broadcastChange(path);
338
+ });
339
+ opts.watcher.on("unlink", (path: unknown) => {
340
+ if (typeof path === "string") broadcastChange(path, true);
341
+ });
342
+
343
+ // Connect-style middleware for HTTP requests
344
+ return async (
345
+ req: IncomingMessage,
346
+ res: ServerResponse,
347
+ next: () => void,
348
+ ): Promise<void> => {
349
+ const url = req.url ?? "";
350
+
351
+ // Match /volumes/:id/files patterns
352
+ if (!url.includes("/volumes/") || !url.includes("/files")) {
353
+ next();
354
+ return;
355
+ }
356
+
357
+ if (req.method === "GET") {
358
+ await handleGetFiles(req, res, state);
359
+ } else if (req.method === "PATCH") {
360
+ await handlePatchFiles(req, res, state);
361
+ } else {
362
+ res.writeHead(405);
363
+ res.end();
364
+ }
365
+ };
366
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * SSE endpoint for file change events — initial sync + live updates.
3
+ *
4
+ * Ported from: deco-cx/deco daemon/sse/api.ts + daemon/sse/channel.ts
5
+ */
6
+ import { readdir, readFile, stat } from "node:fs/promises";
7
+ import { join, sep } from "node:path";
8
+ import type { IncomingMessage, ServerResponse } from "node:http";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Event types — simplified from daemon/fs/common.ts
12
+ // ---------------------------------------------------------------------------
13
+
14
+ interface FSEvent {
15
+ type: "fs-sync" | "fs-snapshot" | "worker-status" | "meta-info";
16
+ detail: Record<string, unknown>;
17
+ }
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Broadcast channel (EventTarget-based, same as daemon/sse/channel.ts)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const channel = new EventTarget();
24
+
25
+ export function broadcastFSEvent(event: FSEvent): void {
26
+ channel.dispatchEvent(new CustomEvent("broadcast", { detail: event }));
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const toPosix = (p: string) => p.replaceAll(sep, "/");
34
+
35
+ function shouldIgnore(path: string): boolean {
36
+ return (
37
+ path.includes(`${sep}.git${sep}`) ||
38
+ path.includes(`${sep}node_modules${sep}`) ||
39
+ path.includes(`${sep}.agent-home${sep}`) ||
40
+ path.includes(`${sep}.claude${sep}`)
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Infer block type from __resolveType string.
46
+ * Maps to manifest block categories (pages, sections, loaders, etc.).
47
+ */
48
+ function inferBlockType(resolveType: string): string | null {
49
+ if (!resolveType) return null;
50
+ if (resolveType.includes("/pages/")) return "pages";
51
+ if (resolveType.includes("/sections/")) return "sections";
52
+ if (resolveType.includes("/loaders/")) return "loaders";
53
+ if (resolveType.includes("/actions/")) return "actions";
54
+ if (resolveType.includes("/matchers/")) return "matchers";
55
+ if (resolveType.includes("/flags/")) return "sections";
56
+ return null;
57
+ }
58
+
59
+ export interface Metadata {
60
+ kind: "block" | "file";
61
+ blockType?: string;
62
+ __resolveType?: string;
63
+ name?: string;
64
+ path?: string;
65
+ }
66
+
67
+ /**
68
+ * Read a JSON file and infer its metadata (block type, resolveType, etc.).
69
+ * Matches the Deno daemon's inferMetadata from daemon/fs/api.ts.
70
+ */
71
+ export async function inferMetadata(filepath: string): Promise<Metadata | null> {
72
+ try {
73
+ const raw = await readFile(filepath, "utf-8");
74
+ const parsed = JSON.parse(raw);
75
+ const { __resolveType, name, path: pagePath } = parsed;
76
+
77
+ if (!__resolveType) return { kind: "file" };
78
+
79
+ const blockType = inferBlockType(__resolveType);
80
+ if (!blockType) return { kind: "file" };
81
+
82
+ if (blockType === "pages") {
83
+ return {
84
+ kind: "block",
85
+ blockType,
86
+ __resolveType,
87
+ name: name ?? undefined,
88
+ path: pagePath ?? undefined,
89
+ };
90
+ }
91
+
92
+ return { kind: "block", blockType, __resolveType };
93
+ } catch {
94
+ return { kind: "file" };
95
+ }
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Initial file scan — yields fs-sync events for each .deco/ file
100
+ // ---------------------------------------------------------------------------
101
+
102
+ async function* scanFiles(
103
+ cwd: string,
104
+ since: number,
105
+ ): AsyncGenerator<FSEvent> {
106
+ const decoDir = join(cwd, ".deco");
107
+ try {
108
+ const entries = await readdir(decoDir, {
109
+ recursive: true,
110
+ withFileTypes: true,
111
+ });
112
+ for (const entry of entries) {
113
+ if (!entry.isFile()) continue;
114
+ const fullPath = join(entry.parentPath, entry.name);
115
+ if (shouldIgnore(fullPath)) continue;
116
+
117
+ let mtime: number;
118
+ try {
119
+ const stats = await stat(fullPath);
120
+ mtime = stats.mtimeMs;
121
+ } catch {
122
+ mtime = Date.now();
123
+ }
124
+
125
+ if (mtime < since) continue;
126
+
127
+ const metadata = await inferMetadata(fullPath);
128
+ const filepath = toPosix(fullPath.replace(cwd, ""));
129
+ yield {
130
+ type: "fs-sync",
131
+ detail: { metadata, filepath, timestamp: mtime },
132
+ };
133
+ }
134
+ } catch {
135
+ // .deco dir might not exist yet
136
+ }
137
+
138
+ yield {
139
+ type: "fs-snapshot",
140
+ detail: { timestamp: Date.now() },
141
+ };
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // SSE handler — Connect-style middleware
146
+ // ---------------------------------------------------------------------------
147
+
148
+ export function createWatchHandler(opts?: { getPort?: () => number }) {
149
+ const cwd = process.cwd();
150
+ const getPort = opts?.getPort ?? (() => 5173);
151
+
152
+ return async (
153
+ req: IncomingMessage,
154
+ res: ServerResponse,
155
+ next: () => void,
156
+ ): Promise<void> => {
157
+ const url = new URL(req.url ?? "/", "http://localhost");
158
+
159
+ // Only handle /watch or the root SSE endpoint
160
+ if (url.pathname !== "/watch" && url.pathname !== "/") {
161
+ next();
162
+ return;
163
+ }
164
+
165
+ if (req.method !== "GET") {
166
+ next();
167
+ return;
168
+ }
169
+
170
+ // SSE headers
171
+ res.writeHead(200, {
172
+ "Content-Type": "text/event-stream",
173
+ "Cache-Control": "no-cache",
174
+ Connection: "keep-alive",
175
+ });
176
+
177
+ const since = Number(url.searchParams.get("since")) || 0;
178
+ let closed = false;
179
+
180
+ req.on("close", () => {
181
+ closed = true;
182
+ console.log("[deco] SSE stream closed");
183
+ });
184
+
185
+ function sendEvent(event: FSEvent): void {
186
+ if (closed) return;
187
+ const data = encodeURIComponent(JSON.stringify(event));
188
+ res.write(`event: message\ndata: ${data}\n\n`);
189
+ }
190
+
191
+ // Live broadcast listener
192
+ const handler = (e: Event) => {
193
+ const ce = e as CustomEvent<FSEvent>;
194
+ sendEvent(ce.detail);
195
+ };
196
+ channel.addEventListener("broadcast", handler);
197
+ req.on("close", () => {
198
+ channel.removeEventListener("broadcast", handler);
199
+ });
200
+
201
+ console.log("[deco] SSE stream opened");
202
+
203
+ // Initial scan
204
+ for await (const event of scanFiles(cwd, since)) {
205
+ if (closed) break;
206
+ sendEvent(event);
207
+ }
208
+
209
+ if (closed) return;
210
+
211
+ // Worker status — Vite dev server is always ready
212
+ sendEvent({
213
+ type: "worker-status",
214
+ detail: { state: "ready" },
215
+ });
216
+
217
+ // Meta info — schema + manifest so admin knows about sections/loaders/actions.
218
+ // Fetch via HTTP so the request goes through Vite SSR where the data lives
219
+ // (daemon's native imports create separate module instances).
220
+ try {
221
+ const metaResponse = await fetch(`http://localhost:${getPort()}/live/_meta`);
222
+ if (metaResponse.ok) {
223
+ const metaData = await metaResponse.json();
224
+ sendEvent({
225
+ type: "meta-info",
226
+ detail: { ...metaData, timestamp: Date.now() },
227
+ });
228
+ }
229
+ } catch {
230
+ // Schema may not be initialized yet — admin will retry via /live/_meta
231
+ }
232
+ };
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Wire Vite watcher to broadcast channel
237
+ // ---------------------------------------------------------------------------
238
+
239
+ export function watchFS(watcher: {
240
+ on(event: string, cb: (...args: unknown[]) => void): void;
241
+ }): void {
242
+ const cwd = process.cwd();
243
+
244
+ const onChange = async (filePath: unknown, deleted = false) => {
245
+ if (typeof filePath !== "string") return;
246
+ if (shouldIgnore(filePath)) return;
247
+
248
+ const metadata = deleted ? null : await inferMetadata(filePath);
249
+ let mtime = Date.now();
250
+ if (!deleted) {
251
+ try {
252
+ const stats = await stat(filePath);
253
+ mtime = stats.mtimeMs;
254
+ } catch {
255
+ // use Date.now()
256
+ }
257
+ }
258
+
259
+ broadcastFSEvent({
260
+ type: "fs-sync",
261
+ detail: {
262
+ metadata,
263
+ filepath: toPosix(filePath.replace(cwd, "")),
264
+ timestamp: mtime,
265
+ },
266
+ });
267
+ };
268
+
269
+ watcher.on("change", (path: unknown) => onChange(path));
270
+ watcher.on("add", (path: unknown) => onChange(path));
271
+ watcher.on("unlink", (path: unknown) => onChange(path, true));
272
+ }
@@ -11,7 +11,7 @@
11
11
  * (e.g. "product") which derives timing from the unified profile system.
12
12
  */
13
13
 
14
- import { loaderCacheOptions, type CacheProfileName } from "./cacheHeaders";
14
+ import { loaderCacheOptions, type CacheProfileName } from "./cacheHeaders.ts";
15
15
 
16
16
  export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate";
17
17
 
@@ -115,12 +115,53 @@ export function decoVitePlugin() {
115
115
  }
116
116
  }
117
117
  });
118
+
119
+ // Tunnel + daemon: connect local dev to admin.deco.cx
120
+ // Activated when DECO_SITE_NAME is set (e.g. DECO_SITE_NAME=mysite vite dev)
121
+ const siteName = process.env.DECO_SITE_NAME;
122
+ if (siteName) {
123
+ const envName = process.env.DECO_ENV_NAME || "dev";
124
+
125
+ // Add daemon middleware (x-daemon-api interception + auth + volumes + SSE + admin routes)
126
+ import("../daemon/middleware.ts").then(({ createDaemonMiddleware }) => {
127
+ server.middlewares.use(createDaemonMiddleware({ site: siteName, server }));
128
+ }).catch((err) => {
129
+ console.warn("[deco] Failed to load daemon middleware:", err.message);
130
+ });
131
+
132
+ // Start tunnel after HTTP server is listening (so we know the real port)
133
+ server.httpServer?.once("listening", async () => {
134
+ const addr = server.httpServer?.address();
135
+ const port = typeof addr === "object" && addr ? addr.port : 5173;
136
+ try {
137
+ const { startTunnel } = await import("../daemon/tunnel.ts");
138
+ const tunnel = await startTunnel({
139
+ site: siteName,
140
+ env: envName,
141
+ port,
142
+ decoHost: process.env.DECO_HOST === "true",
143
+ });
144
+ server.httpServer?.on("close", () => tunnel.close());
145
+ } catch (err) {
146
+ console.warn("[deco] Failed to start tunnel:", err.message);
147
+ }
148
+ });
149
+ }
118
150
  },
119
151
 
120
152
  config(_cfg, { command }) {
153
+ /** @type {import("vite").UserConfig} */
154
+ const cfg = {};
155
+
156
+ // Allow tunnel domains through Vite's host check
157
+ if (process.env.DECO_SITE_NAME) {
158
+ cfg.server = { allowedHosts: [".deco.host", ".decocdn.com"] };
159
+ }
160
+
121
161
  // Only split chunks for production builds — dev uses unbundled ESM.
122
- if (command !== "build") return;
162
+ if (command !== "build") return cfg;
123
163
  return {
164
+ ...cfg,
124
165
  build: {
125
166
  rollupOptions: {
126
167
  output: {