@decocms/start 2.0.1 → 2.1.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.
- package/package.json +8 -3
- package/src/admin/decofile.ts +7 -7
- package/src/admin/index.ts +3 -0
- package/src/admin/meta.ts +36 -11
- package/src/admin/schema.ts +64 -0
- package/src/admin/setup.ts +3 -0
- package/src/cms/loader.ts +1 -1
- package/src/cms/resolve.ts +32 -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/sdk/cachedLoader.ts +1 -1
- package/src/vite/plugin.js +52 -1
package/src/daemon/fs.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem REST API — read, patch, delete .deco/ files.
|
|
3
|
+
*
|
|
4
|
+
* The admin UI reads individual files via GET /fs/file/<path>.
|
|
5
|
+
* This is separate from the volumes/realtime WebSocket API.
|
|
6
|
+
*
|
|
7
|
+
* Ported from: deco-cx/deco daemon/fs/api.ts
|
|
8
|
+
*/
|
|
9
|
+
import { readFile, writeFile, rm, stat, mkdir } from "node:fs/promises";
|
|
10
|
+
import { join, resolve, sep } from "node:path";
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
|
+
import fjp from "fast-json-patch";
|
|
13
|
+
import type { Operation } from "fast-json-patch";
|
|
14
|
+
import { inferMetadata, broadcastFSEvent, type Metadata } from "./watch.ts";
|
|
15
|
+
|
|
16
|
+
const cwd = process.cwd();
|
|
17
|
+
const toPosix = (p: string) => p.replaceAll(sep, "/");
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
function safePath(untrusted: string): string | null {
|
|
24
|
+
const resolved = resolve(cwd, untrusted.startsWith("/") ? `.${untrusted}` : untrusted);
|
|
25
|
+
if (!resolved.startsWith(cwd + sep) && resolved !== cwd) return null;
|
|
26
|
+
return resolved;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function extractFilePath(url: string): string {
|
|
30
|
+
// URL: /fs/file/.deco/blocks/site.json
|
|
31
|
+
const [, ...segments] = url.split("/file");
|
|
32
|
+
return segments.join("/file") || "/";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const chunks: Buffer[] = [];
|
|
38
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
39
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
40
|
+
req.on("error", reject);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function mtimeFor(filepath: string): Promise<number> {
|
|
45
|
+
try {
|
|
46
|
+
const stats = await stat(filepath);
|
|
47
|
+
return stats.mtimeMs;
|
|
48
|
+
} catch {
|
|
49
|
+
return Date.now();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Patch application — matches daemon/fs/common.ts
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
interface Patch {
|
|
58
|
+
type: "json" | "text";
|
|
59
|
+
payload: Operation[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function applyPatch(
|
|
63
|
+
content: string | null,
|
|
64
|
+
patch: Patch,
|
|
65
|
+
): { conflict: boolean; content?: string } {
|
|
66
|
+
try {
|
|
67
|
+
if (patch.type === "json") {
|
|
68
|
+
const result = patch.payload.reduce(
|
|
69
|
+
fjp.applyReducer,
|
|
70
|
+
JSON.parse(content ?? "{}"),
|
|
71
|
+
);
|
|
72
|
+
return { conflict: false, content: JSON.stringify(result, null, 2) };
|
|
73
|
+
}
|
|
74
|
+
if (patch.type === "text") {
|
|
75
|
+
const result = patch.payload.reduce(
|
|
76
|
+
fjp.applyReducer,
|
|
77
|
+
content?.split("\n") ?? [],
|
|
78
|
+
);
|
|
79
|
+
return { conflict: false, content: (result as string[]).join("\n") };
|
|
80
|
+
}
|
|
81
|
+
} catch (err: unknown) {
|
|
82
|
+
if (
|
|
83
|
+
err instanceof fjp.JsonPatchError &&
|
|
84
|
+
err.name === "TEST_OPERATION_FAILED"
|
|
85
|
+
) {
|
|
86
|
+
return { conflict: true };
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
return { conflict: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Handler
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export function createFSHandler() {
|
|
98
|
+
return async (
|
|
99
|
+
req: IncomingMessage,
|
|
100
|
+
res: ServerResponse,
|
|
101
|
+
next: () => void,
|
|
102
|
+
): Promise<void> => {
|
|
103
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
104
|
+
const { pathname } = url;
|
|
105
|
+
|
|
106
|
+
// Only handle /fs/file/* paths
|
|
107
|
+
if (!pathname.startsWith("/fs/file")) {
|
|
108
|
+
// Also handle /fs/grep (admin search)
|
|
109
|
+
if (pathname === "/fs/grep") {
|
|
110
|
+
// Minimal grep stub — return empty results
|
|
111
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
112
|
+
res.end(JSON.stringify({ matches: [], totalMatches: 0 }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
next();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const filePath = extractFilePath(pathname);
|
|
120
|
+
const systemPath = safePath(filePath);
|
|
121
|
+
|
|
122
|
+
if (!systemPath) {
|
|
123
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
124
|
+
res.end(JSON.stringify({ error: "Path traversal denied" }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// GET /fs/file/* — read file
|
|
129
|
+
if (req.method === "GET") {
|
|
130
|
+
try {
|
|
131
|
+
const [content, metadata, mtime] = await Promise.all([
|
|
132
|
+
readFile(systemPath, "utf-8"),
|
|
133
|
+
inferMetadata(systemPath),
|
|
134
|
+
mtimeFor(systemPath),
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
138
|
+
res.end(JSON.stringify({ content, metadata, timestamp: mtime }));
|
|
139
|
+
} catch (err: unknown) {
|
|
140
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
141
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
142
|
+
res.end(JSON.stringify({ timestamp: Date.now() }));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// PATCH /fs/file/* — apply JSON patch
|
|
151
|
+
if (req.method === "PATCH") {
|
|
152
|
+
const raw = await readBody(req);
|
|
153
|
+
let body: { patch: Patch; timestamp: number };
|
|
154
|
+
try {
|
|
155
|
+
body = JSON.parse(raw);
|
|
156
|
+
} catch {
|
|
157
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
158
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const mtimeBefore = await mtimeFor(systemPath);
|
|
163
|
+
let content: string | null;
|
|
164
|
+
try {
|
|
165
|
+
content = await readFile(systemPath, "utf-8");
|
|
166
|
+
} catch {
|
|
167
|
+
content = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const result = applyPatch(content, body.patch);
|
|
171
|
+
|
|
172
|
+
if (!result.conflict && result.content != null) {
|
|
173
|
+
const dir = join(systemPath, "..");
|
|
174
|
+
await mkdir(dir, { recursive: true });
|
|
175
|
+
await writeFile(systemPath, result.content, "utf-8");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const [metadata, mtimeAfter] = await Promise.all([
|
|
179
|
+
inferMetadata(systemPath),
|
|
180
|
+
mtimeFor(systemPath),
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
// Broadcast change for SSE listeners
|
|
184
|
+
broadcastFSEvent({
|
|
185
|
+
type: "fs-sync",
|
|
186
|
+
detail: {
|
|
187
|
+
metadata,
|
|
188
|
+
timestamp: mtimeAfter,
|
|
189
|
+
filepath: toPosix(systemPath.replace(cwd, "")),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const update = result.conflict
|
|
194
|
+
? { conflict: true, metadata, timestamp: mtimeAfter, content }
|
|
195
|
+
: {
|
|
196
|
+
conflict: false,
|
|
197
|
+
metadata,
|
|
198
|
+
timestamp: mtimeAfter,
|
|
199
|
+
content: mtimeBefore !== body.timestamp ? result.content : undefined,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
203
|
+
res.end(JSON.stringify(update));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// DELETE /fs/file/*
|
|
208
|
+
if (req.method === "DELETE") {
|
|
209
|
+
try {
|
|
210
|
+
await rm(systemPath, { force: true });
|
|
211
|
+
} catch {
|
|
212
|
+
// ignore
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
broadcastFSEvent({
|
|
216
|
+
type: "fs-sync",
|
|
217
|
+
detail: {
|
|
218
|
+
metadata: null,
|
|
219
|
+
timestamp: Date.now(),
|
|
220
|
+
filepath: toPosix(systemPath.replace(cwd, "")),
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
225
|
+
res.end(
|
|
226
|
+
JSON.stringify({
|
|
227
|
+
conflict: false,
|
|
228
|
+
metadata: null,
|
|
229
|
+
timestamp: Date.now(),
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
res.writeHead(405);
|
|
236
|
+
res.end();
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { startTunnel } from "./tunnel.ts";
|
|
2
|
+
export type { TunnelOptions, TunnelConnection } from "./tunnel.ts";
|
|
3
|
+
export { createAuthMiddleware, verifyAdminJwt, tokenIsValid } from "./auth.ts";
|
|
4
|
+
export type { JwtPayload } from "./auth.ts";
|
|
5
|
+
export { createDaemonMiddleware } from "./middleware.ts";
|
|
6
|
+
export type { DaemonOptions } from "./middleware.ts";
|
|
7
|
+
export { createVolumesHandler } from "./volumes.ts";
|
|
8
|
+
export { createWatchHandler, watchFS, broadcastFSEvent } from "./watch.ts";
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon middleware — intercepts x-daemon-api requests, applies auth,
|
|
3
|
+
* and routes to volumes API or watch SSE.
|
|
4
|
+
*
|
|
5
|
+
* Admin runtime routes (/live/_meta, /.decofile) are NOT handled here —
|
|
6
|
+
* they fall through to Vite SSR (worker-entry.ts) where setMetaData()
|
|
7
|
+
* and setBlocks() have populated shared state. The daemon middleware loads
|
|
8
|
+
* modules via native import() which creates separate module instances.
|
|
9
|
+
*
|
|
10
|
+
* Ported from: deco-cx/deco daemon/daemon.ts
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage, ServerResponse, Server as HttpServer } from "node:http";
|
|
13
|
+
import { createAuthMiddleware } from "./auth.ts";
|
|
14
|
+
import { createFSHandler } from "./fs.ts";
|
|
15
|
+
import { createVolumesHandler } from "./volumes.ts";
|
|
16
|
+
import { createWatchHandler, watchFS } from "./watch.ts";
|
|
17
|
+
|
|
18
|
+
const DAEMON_API_SPECIFIER = "x-daemon-api";
|
|
19
|
+
const HYPERVISOR_API_SPECIFIER = "x-hypervisor-api";
|
|
20
|
+
|
|
21
|
+
export interface DaemonOptions {
|
|
22
|
+
/** Site name for JWT validation. */
|
|
23
|
+
site: string;
|
|
24
|
+
/** Vite dev server instance. */
|
|
25
|
+
server: {
|
|
26
|
+
httpServer: HttpServer | null;
|
|
27
|
+
watcher: { on(event: string, cb: (...args: unknown[]) => void): void };
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Creates a Connect-style middleware that:
|
|
32
|
+
// 1. Checks for x-daemon-api or x-hypervisor-api header
|
|
33
|
+
// 2. Applies JWT auth
|
|
34
|
+
// 3. Routes to volumes API or SSE watch
|
|
35
|
+
// 4. Falls through to Vite for other daemon requests (admin routes)
|
|
36
|
+
export function createDaemonMiddleware(opts: DaemonOptions) {
|
|
37
|
+
const auth = createAuthMiddleware(opts.site);
|
|
38
|
+
const httpServer = opts.server.httpServer;
|
|
39
|
+
|
|
40
|
+
// Volumes handler (includes WebSocket upgrade registration)
|
|
41
|
+
const volumes = httpServer
|
|
42
|
+
? createVolumesHandler({
|
|
43
|
+
httpServer,
|
|
44
|
+
watcher: opts.server.watcher,
|
|
45
|
+
})
|
|
46
|
+
: null;
|
|
47
|
+
|
|
48
|
+
// FS REST API handler (/fs/file/* — read, patch, delete)
|
|
49
|
+
const fs = createFSHandler();
|
|
50
|
+
|
|
51
|
+
// SSE watch handler — lazy port resolver for /live/_meta fetch
|
|
52
|
+
const watch = createWatchHandler({
|
|
53
|
+
getPort: () => {
|
|
54
|
+
const addr = httpServer?.address();
|
|
55
|
+
return typeof addr === "object" && addr ? addr.port : 5173;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Wire Vite's file watcher to the broadcast channel
|
|
60
|
+
watchFS(opts.server.watcher);
|
|
61
|
+
|
|
62
|
+
// Version reported to admin.deco.cx — must satisfy admin's minimum version check.
|
|
63
|
+
// Admin compares against deco-cx/deco versions (e.g. 1.177.x), not @decocms/start versions.
|
|
64
|
+
const VERSION = "1.177.5";
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
req: IncomingMessage,
|
|
68
|
+
res: ServerResponse,
|
|
69
|
+
next: () => void,
|
|
70
|
+
): void => {
|
|
71
|
+
let pathname: string;
|
|
72
|
+
try {
|
|
73
|
+
pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
74
|
+
} catch {
|
|
75
|
+
pathname = req.url ?? "/";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Healthcheck — no auth required, admin uses this to verify env is reachable
|
|
79
|
+
if (pathname === "/_healthcheck") {
|
|
80
|
+
res.writeHead(200, {
|
|
81
|
+
"Content-Type": "text/plain",
|
|
82
|
+
"Access-Control-Allow-Origin": "*",
|
|
83
|
+
"Access-Control-Allow-Methods": "GET",
|
|
84
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
85
|
+
});
|
|
86
|
+
res.end(VERSION);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Admin runtime routes (/live/_meta, /.decofile) are NOT handled here.
|
|
91
|
+
// They fall through to Vite SSR (worker-entry.ts / TanStack routes) where
|
|
92
|
+
// setMetaData() and setBlocks() have already populated the shared state.
|
|
93
|
+
// The daemon middleware loads modules via native import() which creates
|
|
94
|
+
// separate module instances from Vite SSR — they don't share state.
|
|
95
|
+
|
|
96
|
+
const isDaemonAPI =
|
|
97
|
+
req.headers[DAEMON_API_SPECIFIER] ??
|
|
98
|
+
req.headers[HYPERVISOR_API_SPECIFIER] ??
|
|
99
|
+
false;
|
|
100
|
+
|
|
101
|
+
// Also check query param: ?x-daemon-api=true
|
|
102
|
+
if (!isDaemonAPI) {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
105
|
+
if (url.searchParams.get(DAEMON_API_SPECIFIER) !== "true") {
|
|
106
|
+
next();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
next();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Add CORS headers for admin.deco.cx
|
|
116
|
+
const origin = req.headers.origin;
|
|
117
|
+
if (origin) {
|
|
118
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
119
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
|
|
120
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-daemon-api, x-hypervisor-api");
|
|
121
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Handle CORS preflight
|
|
125
|
+
if (req.method === "OPTIONS") {
|
|
126
|
+
res.writeHead(204);
|
|
127
|
+
res.end();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Auth → then route
|
|
132
|
+
auth(req, res, () => {
|
|
133
|
+
// FS REST API: /fs/file/* (read, patch, delete .deco/ files)
|
|
134
|
+
if (pathname.startsWith("/fs/")) {
|
|
135
|
+
fs(req, res, next);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Volumes API: /volumes/:id/files/*
|
|
140
|
+
if (pathname.includes("/volumes/") && pathname.includes("/files") && volumes) {
|
|
141
|
+
volumes(req, res, next);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// SSE watch: /watch or root /
|
|
146
|
+
if (pathname === "/watch" || pathname === "/") {
|
|
147
|
+
watch(req, res, next);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Everything else falls through to Vite/TanStack admin routes
|
|
152
|
+
// (e.g., /live/_meta, /.decofile, /live/previews, /deco/invoke)
|
|
153
|
+
next();
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel registration — connects local dev server to deco.cx admin
|
|
3
|
+
* via a WebSocket reverse proxy (@deco-cx/warp-node).
|
|
4
|
+
*
|
|
5
|
+
* Ported from: deco-cx/deco daemon/tunnel.ts
|
|
6
|
+
*/
|
|
7
|
+
import { connect } from "@deco-cx/warp-node";
|
|
8
|
+
|
|
9
|
+
export interface TunnelOptions {
|
|
10
|
+
/** Environment name (DECO_ENV_NAME). */
|
|
11
|
+
env: string;
|
|
12
|
+
/** Site name (DECO_SITE_NAME). */
|
|
13
|
+
site: string;
|
|
14
|
+
/** Local dev server port. */
|
|
15
|
+
port: number;
|
|
16
|
+
/** Use deco.host relay (true) or simpletunnel.deco.site (false). Default true. */
|
|
17
|
+
decoHost?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TunnelConnection {
|
|
21
|
+
close: () => void;
|
|
22
|
+
domain: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const VERBOSE = process.env.VERBOSE;
|
|
26
|
+
|
|
27
|
+
export async function startTunnel(
|
|
28
|
+
opts: TunnelOptions,
|
|
29
|
+
): Promise<TunnelConnection> {
|
|
30
|
+
const { env, site, port, decoHost = true } = opts;
|
|
31
|
+
|
|
32
|
+
const decoHostDomain = `${env}--${site}.deco.host`;
|
|
33
|
+
const { server, domain } = decoHost
|
|
34
|
+
? { server: `wss://${decoHostDomain}`, domain: decoHostDomain }
|
|
35
|
+
: {
|
|
36
|
+
server: "wss://simpletunnel.deco.site",
|
|
37
|
+
domain: `${env}--${site}.deco.site`,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const localAddr = `http://localhost:${port}`;
|
|
41
|
+
const apiKey =
|
|
42
|
+
process.env.DECO_TUNNEL_SERVER_TOKEN ??
|
|
43
|
+
"c309424a-2dc4-46fe-bfc7-a7c10df59477";
|
|
44
|
+
|
|
45
|
+
let closed = false;
|
|
46
|
+
let activeConn: Awaited<ReturnType<typeof connect>> | null = null;
|
|
47
|
+
|
|
48
|
+
async function doConnect(): Promise<void> {
|
|
49
|
+
if (closed) return;
|
|
50
|
+
|
|
51
|
+
let r: Awaited<ReturnType<typeof connect>>;
|
|
52
|
+
try {
|
|
53
|
+
r = await connect({ domain, localAddr, server, apiKey });
|
|
54
|
+
activeConn = r;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (closed) return;
|
|
57
|
+
console.log(
|
|
58
|
+
"[deco] tunnel connect failed, retrying in 500ms…",
|
|
59
|
+
VERBOSE ? err : "",
|
|
60
|
+
);
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
62
|
+
return doConnect();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
r.registered
|
|
66
|
+
.then(() => {
|
|
67
|
+
const adminUrl = new URL(
|
|
68
|
+
`/sites/${site}/spaces/dashboard?env=${env}`,
|
|
69
|
+
"https://admin.deco.cx",
|
|
70
|
+
);
|
|
71
|
+
console.log(
|
|
72
|
+
`\n[deco] tunnel connected — env \x1b[32m${env}\x1b[0m for site \x1b[34m${site}\x1b[0m` +
|
|
73
|
+
`\n -> Preview: \x1b[36mhttps://${domain}\x1b[0m` +
|
|
74
|
+
`\n -> Admin: \x1b[36m${adminUrl.href}\x1b[0m\n`,
|
|
75
|
+
);
|
|
76
|
+
})
|
|
77
|
+
.catch((err) => {
|
|
78
|
+
console.error("[deco] tunnel registration failed:", err);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
r.closed
|
|
82
|
+
.then(async (reason) => {
|
|
83
|
+
if (closed) return;
|
|
84
|
+
if (
|
|
85
|
+
reason &&
|
|
86
|
+
typeof reason === "object" &&
|
|
87
|
+
"intentional" in reason &&
|
|
88
|
+
(reason as Record<string, unknown>).intentional
|
|
89
|
+
)
|
|
90
|
+
return;
|
|
91
|
+
console.log(
|
|
92
|
+
"[deco] tunnel disconnected, retrying in 500ms…",
|
|
93
|
+
VERBOSE ? reason : "",
|
|
94
|
+
);
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
96
|
+
return doConnect();
|
|
97
|
+
})
|
|
98
|
+
.catch(async (err: unknown) => {
|
|
99
|
+
if (closed) return;
|
|
100
|
+
if (
|
|
101
|
+
err &&
|
|
102
|
+
typeof err === "object" &&
|
|
103
|
+
"intentional" in err &&
|
|
104
|
+
(err as Record<string, unknown>).intentional
|
|
105
|
+
)
|
|
106
|
+
return;
|
|
107
|
+
console.log(
|
|
108
|
+
"[deco] tunnel error, retrying in 500ms…",
|
|
109
|
+
VERBOSE ? err : "",
|
|
110
|
+
);
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
112
|
+
return doConnect();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await doConnect();
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
close() {
|
|
120
|
+
closed = true;
|
|
121
|
+
activeConn?.closed?.catch(() => {});
|
|
122
|
+
// warp-node's connect() returns a Connected object; closing the
|
|
123
|
+
// underlying WebSocket is handled internally when the process exits.
|
|
124
|
+
// Setting closed=true prevents reconnection attempts.
|
|
125
|
+
activeConn = null;
|
|
126
|
+
},
|
|
127
|
+
domain,
|
|
128
|
+
};
|
|
129
|
+
}
|