@apps-in-toss/devtools 3.0.3 → 3.0.5
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/CHANGELOG.md +32 -0
- package/LICENSE +201 -28
- package/README.md +237 -871
- package/dist/mock/2x.d.ts +3 -122
- package/dist/mock/2x.js +1312 -1533
- package/dist/mock/3x.d.ts +3 -122
- package/dist/mock/3x.js +1246 -1467
- package/dist/mock/index.d.ts +3 -122
- package/dist/mock/index.js +1246 -1467
- package/dist/panel/index.js +154 -73
- package/dist/unplugin/index.cjs +12 -147
- package/dist/unplugin/index.d.cts +408 -78
- package/dist/unplugin/index.d.ts +408 -78
- package/dist/unplugin/index.js +13 -147
- package/package.json +4 -7
- package/dist/tunnel-BvEf1qGV.js +0 -186
- package/dist/tunnel-DtCTOUlp.cjs +0 -187
- package/dist/unplugin/tunnel.cjs +0 -191
- package/dist/unplugin/tunnel.d.cts +0 -140
- package/dist/unplugin/tunnel.d.ts +0 -140
- package/dist/unplugin/tunnel.js +0 -186
package/dist/unplugin/index.js
CHANGED
|
@@ -2,71 +2,6 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { createUnplugin } from "unplugin";
|
|
5
|
-
//#region src/shared/parent-watcher.ts
|
|
6
|
-
/**
|
|
7
|
-
* Shared parent-PID watcher — used by both the MCP debug daemon and the
|
|
8
|
-
* unplugin tunnel path to self-terminate when the parent process (e.g. Claude
|
|
9
|
-
* Code, vite) has died or been reparented without sending SIGTERM/SIGHUP.
|
|
10
|
-
*
|
|
11
|
-
* Intentionally react-free and Node-stdlib-only so this module is safe to
|
|
12
|
-
* import from the MCP daemon bundle (`dist/mcp/cli.js`) without violating the
|
|
13
|
-
* install-graph invariant.
|
|
14
|
-
*/
|
|
15
|
-
/**
|
|
16
|
-
* Returns `true` when the given PID refers to a running process.
|
|
17
|
-
*
|
|
18
|
-
* Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process
|
|
19
|
-
* exists and we have permission to signal it; throws ESRCH when it doesn't exist.
|
|
20
|
-
*/
|
|
21
|
-
function isPidAlive(pid) {
|
|
22
|
-
try {
|
|
23
|
-
process.kill(pid, 0);
|
|
24
|
-
return true;
|
|
25
|
-
} catch (err) {
|
|
26
|
-
if (err.code === "EPERM") return true;
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Starts a periodic watcher that detects when the parent process (e.g. Claude
|
|
32
|
-
* Code) has died without sending SIGTERM/SIGHUP, and calls `onOrphaned` so the
|
|
33
|
-
* daemon can self-terminate rather than running as a zombie.
|
|
34
|
-
*
|
|
35
|
-
* Mirrors the `startAttachWatcher` pattern: `setInterval`-based, returns
|
|
36
|
-
* `{ stop(): void }`, injectable deps for testability.
|
|
37
|
-
*
|
|
38
|
-
* @param onOrphaned - Called once when the parent is gone.
|
|
39
|
-
* @param opts.intervalMs - Poll interval in milliseconds (default 5 000).
|
|
40
|
-
* @param opts.initialPpid - Parent PID to watch (default `process.ppid`).
|
|
41
|
-
* @param opts.isAlive - Predicate to test if a PID is running (default `isPidAlive`).
|
|
42
|
-
* @param opts.getPpid - Supplier of current ppid (default `() => process.ppid`).
|
|
43
|
-
* Detects ppid changes as well as death.
|
|
44
|
-
* @param opts.log - Logger (default `process.stderr.write`).
|
|
45
|
-
*
|
|
46
|
-
* @returns `stop` — call during shutdown to clear the interval.
|
|
47
|
-
*/
|
|
48
|
-
function startParentWatcher(onOrphaned, opts) {
|
|
49
|
-
const { intervalMs = 5e3, initialPpid = process.ppid, isAlive = isPidAlive, getPpid = () => process.ppid, log = (msg) => process.stderr.write(msg) } = opts ?? {};
|
|
50
|
-
if (initialPpid <= 1) {
|
|
51
|
-
log("[ait-debug] parent-pid watcher: no parent to watch (ppid<=1), skipping\n");
|
|
52
|
-
return { stop() {} };
|
|
53
|
-
}
|
|
54
|
-
let fired = false;
|
|
55
|
-
const handle = setInterval(() => {
|
|
56
|
-
if (fired) return;
|
|
57
|
-
const currentPpid = getPpid();
|
|
58
|
-
if (currentPpid !== initialPpid || !isAlive(initialPpid)) {
|
|
59
|
-
fired = true;
|
|
60
|
-
clearInterval(handle);
|
|
61
|
-
log(`[ait-debug] parent-pid watcher: parent PID ${initialPpid} is gone (currentPpid=${currentPpid}) — shutting down\n`);
|
|
62
|
-
onOrphaned();
|
|
63
|
-
}
|
|
64
|
-
}, intervalMs);
|
|
65
|
-
return { stop() {
|
|
66
|
-
clearInterval(handle);
|
|
67
|
-
} };
|
|
68
|
-
}
|
|
69
|
-
//#endregion
|
|
70
5
|
//#region src/unplugin/index.ts
|
|
71
6
|
/**
|
|
72
7
|
* @apps-in-toss/devtools unplugin
|
|
@@ -120,6 +55,14 @@ function detectInstalledSdkMajor(cwd = process.cwd()) {
|
|
|
120
55
|
} catch {}
|
|
121
56
|
return null;
|
|
122
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* 패널 자동 주입 대상 진입점 파일명 기본 패턴. `entryPattern` 옵션 미지정 시
|
|
60
|
+
* 사용된다. `id.test()`는 상태를 갖지 않아야 하므로(매 파일마다 호출) 이
|
|
61
|
+
* 패턴과 사용자 지정 `entryPattern` 모두 `g`/`y` 플래그를 붙이지 않는다는
|
|
62
|
+
* 전제다 — `RegExp.prototype.test`는 global/sticky 플래그가 있으면 호출마다
|
|
63
|
+
* `lastIndex`가 전진해 다음 파일 매칭이 간헐적으로 실패한다.
|
|
64
|
+
*/
|
|
65
|
+
const DEFAULT_ENTRY_PATTERN = /\/(main|index|entry|app)\.[tj]sx?$/i;
|
|
123
66
|
const FRAMEWORK_ID = "@apps-in-toss/web-framework";
|
|
124
67
|
const BRIDGE_ID = "@apps-in-toss/web-bridge";
|
|
125
68
|
const ANALYTICS_ID = "@apps-in-toss/web-analytics";
|
|
@@ -128,40 +71,14 @@ const WEBVIEW_BRIDGE_ID = "@apps-in-toss/webview-bridge";
|
|
|
128
71
|
const MCP_STATE_PATH = "/api/ait-devtools/state";
|
|
129
72
|
/** Browser runtime opt-in consumed by the panel's state-sync helper. */
|
|
130
73
|
const MCP_ENABLE_SNIPPET = "globalThis.__AIT_DEVTOOLS_MCP_ENABLED__ = true;";
|
|
131
|
-
/**
|
|
132
|
-
* Resolves the effective tunnel option (#425).
|
|
133
|
-
*
|
|
134
|
-
* An explicit `tunnel` value (including `false`) always takes priority over
|
|
135
|
-
* env vars — the `??` operator means `undefined` (= omitted) falls through,
|
|
136
|
-
* but `false` / `true` / an object are preserved as-is (non-breaking).
|
|
137
|
-
*
|
|
138
|
-
* When the option is omitted:
|
|
139
|
-
* - `AIT_TUNNEL=1` enables the screen-preview tunnel.
|
|
140
|
-
* - Not set → `false` (disabled).
|
|
141
|
-
*
|
|
142
|
-
* Extracted as a pure function so it can be unit-tested without standing up
|
|
143
|
-
* a full Vite dev server.
|
|
144
|
-
*
|
|
145
|
-
* @param explicit - The `tunnel` option as passed by the consumer (or `undefined` when omitted).
|
|
146
|
-
* @param env - The process environment (injectable for testing).
|
|
147
|
-
*/
|
|
148
|
-
function resolveTunnelOption(explicit, env) {
|
|
149
|
-
return explicit ?? !!env.AIT_TUNNEL;
|
|
150
|
-
}
|
|
151
74
|
const aitDevtoolsPlugin = createUnplugin((options) => {
|
|
152
75
|
const isDev = process.env.NODE_ENV !== "production";
|
|
153
|
-
const shouldEnable = isDev;
|
|
76
|
+
const shouldEnable = isDev || !!options?.forceEnable;
|
|
154
77
|
const shouldMock = shouldEnable && (options?.mock ?? isDev);
|
|
155
78
|
const sdkMajor = options?.sdkVersion === "2" || options?.sdkVersion === "3" ? options.sdkVersion : detectInstalledSdkMajor() ?? "3";
|
|
156
79
|
const shouldPanel = shouldEnable && (options?.panel ?? true);
|
|
157
80
|
const shouldMcp = shouldEnable && (options?.mcp ?? false);
|
|
158
81
|
let lastState = null;
|
|
159
|
-
const tunnelOpt = resolveTunnelOption(options?.tunnel, process.env);
|
|
160
|
-
const shouldTunnel = isDev && !!tunnelOpt;
|
|
161
|
-
const webViewType = options?.webViewType ?? "partner";
|
|
162
|
-
const navBarTransparent = options?.navBarTransparent;
|
|
163
|
-
const navBarTheme = options?.navBarTheme;
|
|
164
|
-
const tunnelConfig = typeof tunnelOpt === "object" ? tunnelOpt : {};
|
|
165
82
|
return {
|
|
166
83
|
name: "ait-co-devtools",
|
|
167
84
|
enforce: "pre",
|
|
@@ -176,7 +93,7 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
|
|
|
176
93
|
},
|
|
177
94
|
transformInclude(id) {
|
|
178
95
|
if (!shouldPanel && !shouldMcp) return false;
|
|
179
|
-
return /\.(tsx?|jsx?)$/.test(id) &&
|
|
96
|
+
return /\.(tsx?|jsx?)$/.test(id) && (options?.entryPattern ?? DEFAULT_ENTRY_PATTERN).test(id) && !id.includes("node_modules");
|
|
180
97
|
},
|
|
181
98
|
transform(code) {
|
|
182
99
|
let result = code;
|
|
@@ -193,12 +110,8 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
|
|
|
193
110
|
},
|
|
194
111
|
vite: {
|
|
195
112
|
config() {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return {
|
|
199
|
-
define,
|
|
200
|
-
server: { allowedHosts: [".trycloudflare.com"] }
|
|
201
|
-
};
|
|
113
|
+
if (options?.initialState === void 0) return {};
|
|
114
|
+
return { define: { __AIT_INITIAL_STATE__: JSON.stringify(options.initialState) } };
|
|
202
115
|
},
|
|
203
116
|
configureServer(server) {
|
|
204
117
|
if (shouldMcp) server.middlewares.use(MCP_STATE_PATH, (req, res) => {
|
|
@@ -240,53 +153,6 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
|
|
|
240
153
|
res.writeHead(405, { "Content-Type": "application/json" });
|
|
241
154
|
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
242
155
|
});
|
|
243
|
-
if (shouldTunnel) {
|
|
244
|
-
let tunnel = null;
|
|
245
|
-
let parentWatcher = null;
|
|
246
|
-
const httpServer = server.httpServer;
|
|
247
|
-
httpServer?.once("listening", () => {
|
|
248
|
-
const address = httpServer?.address();
|
|
249
|
-
const port = tunnelConfig.port ?? (address && typeof address === "object" ? address.port : void 0);
|
|
250
|
-
if (!port) {
|
|
251
|
-
console.warn("[@apps-in-toss/devtools] tunnel: could not determine the dev server port; skipping.");
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
import("../tunnel-BvEf1qGV.js").then(async ({ startQuickTunnel, printTunnelBanner }) => {
|
|
255
|
-
const t = await startQuickTunnel(port);
|
|
256
|
-
tunnel = t;
|
|
257
|
-
let tunnelAppName;
|
|
258
|
-
try {
|
|
259
|
-
const { readFileSync } = await import("node:fs");
|
|
260
|
-
const pkgRaw = readFileSync(`${server.config.root}/package.json`, "utf8");
|
|
261
|
-
const pkg = JSON.parse(pkgRaw);
|
|
262
|
-
const rawName = typeof pkg.name === "string" ? pkg.name : "";
|
|
263
|
-
tunnelAppName = (rawName.includes("/") ? rawName.slice(rawName.indexOf("/") + 1) : rawName).trim() || void 0;
|
|
264
|
-
} catch {}
|
|
265
|
-
await printTunnelBanner(t.url, {
|
|
266
|
-
qr: tunnelConfig.qr,
|
|
267
|
-
name: tunnelAppName,
|
|
268
|
-
webViewType,
|
|
269
|
-
navBarTransparent,
|
|
270
|
-
navBarTheme
|
|
271
|
-
});
|
|
272
|
-
parentWatcher = startParentWatcher(() => {
|
|
273
|
-
cleanup();
|
|
274
|
-
process.exit(0);
|
|
275
|
-
});
|
|
276
|
-
}).catch((err) => {
|
|
277
|
-
console.warn(`[@apps-in-toss/devtools] tunnel failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
const cleanup = () => {
|
|
281
|
-
parentWatcher?.stop();
|
|
282
|
-
tunnel?.stop();
|
|
283
|
-
};
|
|
284
|
-
httpServer?.once("close", cleanup);
|
|
285
|
-
process.once("SIGINT", cleanup);
|
|
286
|
-
process.once("SIGTERM", cleanup);
|
|
287
|
-
process.once("SIGHUP", cleanup);
|
|
288
|
-
process.once("exit", cleanup);
|
|
289
|
-
}
|
|
290
156
|
}
|
|
291
157
|
}
|
|
292
158
|
};
|
|
@@ -298,4 +164,4 @@ const esbuild = aitDevtoolsPlugin.esbuild;
|
|
|
298
164
|
const rspack = aitDevtoolsPlugin.rspack;
|
|
299
165
|
const aitDevtools = aitDevtoolsPlugin;
|
|
300
166
|
//#endregion
|
|
301
|
-
export { aitDevtools as default, detectInstalledSdkMajor, esbuild,
|
|
167
|
+
export { aitDevtools as default, detectInstalledSdkMajor, esbuild, rollup, rspack, vite, webpack };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apps-in-toss/devtools",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.5",
|
|
4
4
|
"description": "Browser development tools for Apps in Toss mini-apps — mock SDK, floating devtools panel, and universal bundler plugin. devDependency only; contributes nothing to a production bundle",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"apps-in-toss",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"vite-plugin",
|
|
16
16
|
"webpack-plugin"
|
|
17
17
|
],
|
|
18
|
-
"license": "
|
|
18
|
+
"license": "Apache-2.0",
|
|
19
19
|
"files": [
|
|
20
20
|
"dist"
|
|
21
21
|
],
|
|
@@ -64,17 +64,14 @@
|
|
|
64
64
|
"check:footprint-absent": "bash scripts/check-devtools-footprint-absent.sh"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"cloudflared": "^0.7.1",
|
|
68
|
-
"qrcode-terminal": "^0.12.0",
|
|
69
67
|
"unplugin": "^3.2.0"
|
|
70
68
|
},
|
|
71
69
|
"devDependencies": {
|
|
72
|
-
"@apps-in-toss/web-framework": "^3.0.
|
|
70
|
+
"@apps-in-toss/web-framework": "^3.0.5",
|
|
73
71
|
"@apps-in-toss/web-framework-2x": "npm:@apps-in-toss/web-framework@2.10.8",
|
|
74
72
|
"@testing-library/dom": "10.4.1",
|
|
75
73
|
"@testing-library/react": "16.3.2",
|
|
76
74
|
"@types/node": "~24.13.3",
|
|
77
|
-
"@types/qrcode-terminal": "0.12.2",
|
|
78
75
|
"@types/react": "19.2.17",
|
|
79
76
|
"@types/react-dom": "19.2.3",
|
|
80
77
|
"jsdom": "29.1.1",
|
|
@@ -97,4 +94,4 @@
|
|
|
97
94
|
"engines": {
|
|
98
95
|
"node": ">=24"
|
|
99
96
|
}
|
|
100
|
-
}
|
|
97
|
+
}
|
package/dist/tunnel-BvEf1qGV.js
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
|
-
import { mkdir } from "node:fs/promises";
|
|
4
|
-
//#region src/unplugin/tunnel.ts
|
|
5
|
-
/**
|
|
6
|
-
* Cloudflare quick-tunnel helper for the devtools unplugin.
|
|
7
|
-
*
|
|
8
|
-
* Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is
|
|
9
|
-
* on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common
|
|
10
|
-
* case. This is the one place in `@apps-in-toss/devtools` that depends on Node-only
|
|
11
|
-
* APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of
|
|
12
|
-
* jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the
|
|
13
|
-
* "web 모드는 e2e" rule in CLAUDE.md). The pure helpers below
|
|
14
|
-
* (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.
|
|
15
|
-
*/
|
|
16
|
-
/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */
|
|
17
|
-
const TRYCLOUDFLARE_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
18
|
-
/**
|
|
19
|
-
* Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared
|
|
20
|
-
* output, or `null` if the line doesn't contain one. Pulled out as a pure
|
|
21
|
-
* function so it can be unit-tested without spawning anything.
|
|
22
|
-
*/
|
|
23
|
-
function parseTrycloudflareUrl(line) {
|
|
24
|
-
const m = line.match(TRYCLOUDFLARE_RE);
|
|
25
|
-
return m ? m[0] : null;
|
|
26
|
-
}
|
|
27
|
-
const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
|
|
28
|
-
/**
|
|
29
|
-
* Build the deep-link URL that QR codes encode: when the launcher PWA is
|
|
30
|
-
* already on the phone's home screen, scanning this opens it directly into the
|
|
31
|
-
* live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).
|
|
32
|
-
* Plain-text raw URL is no longer enough — the launcher gates its setup UI to
|
|
33
|
-
* the installed PWA, so a raw tunnel URL opened in a normal browser tab would
|
|
34
|
-
* land on a "please install" screen.
|
|
35
|
-
*
|
|
36
|
-
* When `opts.name` is given (non-blank), it is added as `&name=` so the launcher
|
|
37
|
-
* partner bar shows the app name instead of the generic default (#498).
|
|
38
|
-
*
|
|
39
|
-
* When `opts.webViewType` is `'game'`, `&navBarType=game` is appended so the
|
|
40
|
-
* launcher enters game nav chrome (floating capsule, no full bar) automatically
|
|
41
|
-
* on scan. `'partner'` is the launcher's implicit default and is not added to
|
|
42
|
-
* keep the URL clean (#584).
|
|
43
|
-
*
|
|
44
|
-
* When `opts.navBarTransparent` is `true`, `&navBarTransparent=1` is appended
|
|
45
|
-
* so the launcher partner bar renders with a transparent background (#587).
|
|
46
|
-
*
|
|
47
|
-
* When `opts.navBarTheme` is `'light'` or `'dark'`, `&navBarTheme=<v>` is
|
|
48
|
-
* appended so the launcher partner bar uses the matching foreground colour (#587).
|
|
49
|
-
*/
|
|
50
|
-
function buildLauncherDeepLink(tunnelUrl, optsParam) {
|
|
51
|
-
const opts = optsParam ?? {};
|
|
52
|
-
let url = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;
|
|
53
|
-
if (opts.name !== void 0 && opts.name.trim() !== "") url += `&name=${encodeURIComponent(opts.name.trim())}`;
|
|
54
|
-
if (opts.webViewType === "game") url += "&navBarType=game";
|
|
55
|
-
if (opts.navBarTransparent === true) url += "&navBarTransparent=1";
|
|
56
|
-
if (opts.navBarTheme === "light" || opts.navBarTheme === "dark") url += `&navBarTheme=${opts.navBarTheme}`;
|
|
57
|
-
return url;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Print the terminal banner announcing the live tunnel: the public URL, an ASCII
|
|
61
|
-
* QR encoding a launcher deep-link, and a one-line note that quick tunnels are
|
|
62
|
-
* ephemeral, unauthenticated and not for production. Pure w.r.t. side effects
|
|
63
|
-
* other than the injected `log` sink and `qrcode-terminal` — unit-tested.
|
|
64
|
-
*/
|
|
65
|
-
async function printTunnelBanner(url, opts = {}) {
|
|
66
|
-
const log = opts.log ?? ((m) => console.log(m));
|
|
67
|
-
const deepLink = buildLauncherDeepLink(url, {
|
|
68
|
-
name: opts.name,
|
|
69
|
-
webViewType: opts.webViewType,
|
|
70
|
-
navBarTransparent: opts.navBarTransparent,
|
|
71
|
-
navBarTheme: opts.navBarTheme
|
|
72
|
-
});
|
|
73
|
-
log([
|
|
74
|
-
"",
|
|
75
|
-
" ┌─ @apps-in-toss/devtools · live tunnel ────────────────────────────",
|
|
76
|
-
` │ ${url}`,
|
|
77
|
-
" │",
|
|
78
|
-
` │ Install the launcher PWA once: ${LAUNCHER_URL}`,
|
|
79
|
-
" │ Then scan the QR below — it opens the launcher directly",
|
|
80
|
-
" │ into this tunnel URL (no manual paste needed).",
|
|
81
|
-
" │ Quick tunnels are unauthenticated, change every run, and are",
|
|
82
|
-
" │ not for production use.",
|
|
83
|
-
" └──────────────────────────────────────────────────────────────",
|
|
84
|
-
""
|
|
85
|
-
].join("\n"));
|
|
86
|
-
if (opts.qr !== false) {
|
|
87
|
-
const qrcode = (await import("qrcode-terminal")).default;
|
|
88
|
-
await new Promise((resolve) => {
|
|
89
|
-
qrcode.generate(deepLink, { small: true }, (out) => {
|
|
90
|
-
log(out);
|
|
91
|
-
resolve();
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Sanitize cloudflared stderr output for error diagnostics (#421).
|
|
98
|
-
*
|
|
99
|
-
* Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs
|
|
100
|
-
* that carry those hostnames so tunnel host values never appear in error
|
|
101
|
-
* messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.
|
|
102
|
-
*
|
|
103
|
-
* SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only
|
|
104
|
-
* placeholder text is emitted.
|
|
105
|
-
*/
|
|
106
|
-
function sanitizeCloudflaredOutput(line) {
|
|
107
|
-
let s = line.replace(/(?:https?|wss?):\/\/[a-z0-9-]+\.trycloudflare\.com(?:\/[^\s]*)*/gi, (m) => m.replace(/[a-z0-9-]+\.trycloudflare\.com/i, "<HOST>.trycloudflare.com"));
|
|
108
|
-
s = s.replace(/[a-z0-9-]+\.trycloudflare\.com/gi, "<HOST>.trycloudflare.com");
|
|
109
|
-
return s;
|
|
110
|
-
}
|
|
111
|
-
const URL_TIMEOUT_MS = 2e4;
|
|
112
|
-
/**
|
|
113
|
-
* Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`
|
|
114
|
-
* and resolve once the public URL is known. Downloads the `cloudflared` binary
|
|
115
|
-
* on first use if it is not already installed. Rejects with a friendly error if
|
|
116
|
-
* no URL appears within {@link URL_TIMEOUT_MS}.
|
|
117
|
-
*/
|
|
118
|
-
async function startQuickTunnel(port) {
|
|
119
|
-
const { bin, install, Tunnel } = await import("cloudflared");
|
|
120
|
-
if (!existsSync(bin)) {
|
|
121
|
-
await mkdir(dirname(bin), { recursive: true });
|
|
122
|
-
await install(bin);
|
|
123
|
-
}
|
|
124
|
-
const tunnel = Tunnel.quick(`http://localhost:${port}`);
|
|
125
|
-
let stopped = false;
|
|
126
|
-
const stop = () => {
|
|
127
|
-
if (stopped) return;
|
|
128
|
-
stopped = true;
|
|
129
|
-
try {
|
|
130
|
-
tunnel.stop();
|
|
131
|
-
} catch {}
|
|
132
|
-
};
|
|
133
|
-
return new Promise((resolve, reject) => {
|
|
134
|
-
const stderrLines = [];
|
|
135
|
-
/**
|
|
136
|
-
* Format the last `n` sanitized stderr lines as a diagnostic appendix.
|
|
137
|
-
* Returns an empty string when no lines have been collected.
|
|
138
|
-
*/
|
|
139
|
-
const stderrTail = (n = 15) => {
|
|
140
|
-
if (stderrLines.length === 0) return "";
|
|
141
|
-
const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join("");
|
|
142
|
-
return `\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\n${tail}`;
|
|
143
|
-
};
|
|
144
|
-
const timer = setTimeout(() => {
|
|
145
|
-
cleanup();
|
|
146
|
-
stop();
|
|
147
|
-
reject(/* @__PURE__ */ new Error(`[@apps-in-toss/devtools] cloudflared did not report a tunnel URL within ${URL_TIMEOUT_MS / 1e3}s. Check your network connection, or run \`cloudflared tunnel --url http://localhost:${port}\` manually.${stderrTail()}`));
|
|
148
|
-
}, URL_TIMEOUT_MS);
|
|
149
|
-
const onUrl = (line) => {
|
|
150
|
-
const found = parseTrycloudflareUrl(line);
|
|
151
|
-
if (!found) return;
|
|
152
|
-
clearTimeout(timer);
|
|
153
|
-
cleanup();
|
|
154
|
-
resolve({
|
|
155
|
-
url: found,
|
|
156
|
-
stop
|
|
157
|
-
});
|
|
158
|
-
};
|
|
159
|
-
const pushStderr = (line) => {
|
|
160
|
-
stderrLines.push(line);
|
|
161
|
-
};
|
|
162
|
-
const cleanup = () => {
|
|
163
|
-
tunnel.off("stdout", onUrl);
|
|
164
|
-
tunnel.off("stderr", onUrl);
|
|
165
|
-
tunnel.off("stderr", pushStderr);
|
|
166
|
-
};
|
|
167
|
-
tunnel.once("url", onUrl);
|
|
168
|
-
tunnel.on("stdout", onUrl);
|
|
169
|
-
tunnel.on("stderr", onUrl);
|
|
170
|
-
tunnel.on("stderr", pushStderr);
|
|
171
|
-
tunnel.once("error", (err) => {
|
|
172
|
-
clearTimeout(timer);
|
|
173
|
-
cleanup();
|
|
174
|
-
stop();
|
|
175
|
-
reject(err);
|
|
176
|
-
});
|
|
177
|
-
tunnel.once("exit", (code) => {
|
|
178
|
-
if (stopped) return;
|
|
179
|
-
clearTimeout(timer);
|
|
180
|
-
cleanup();
|
|
181
|
-
reject(/* @__PURE__ */ new Error(`[@apps-in-toss/devtools] cloudflared exited (code ${code ?? "null"}) before reporting a tunnel URL.${stderrTail()}`));
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
//#endregion
|
|
186
|
-
export { printTunnelBanner, startQuickTunnel };
|
package/dist/tunnel-DtCTOUlp.cjs
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
let node_fs = require("node:fs");
|
|
2
|
-
let node_path = require("node:path");
|
|
3
|
-
let node_fs_promises = require("node:fs/promises");
|
|
4
|
-
//#region src/unplugin/tunnel.ts
|
|
5
|
-
/**
|
|
6
|
-
* Cloudflare quick-tunnel helper for the devtools unplugin.
|
|
7
|
-
*
|
|
8
|
-
* Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is
|
|
9
|
-
* on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common
|
|
10
|
-
* case. This is the one place in `@apps-in-toss/devtools` that depends on Node-only
|
|
11
|
-
* APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of
|
|
12
|
-
* jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the
|
|
13
|
-
* "web 모드는 e2e" rule in CLAUDE.md). The pure helpers below
|
|
14
|
-
* (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.
|
|
15
|
-
*/
|
|
16
|
-
/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */
|
|
17
|
-
const TRYCLOUDFLARE_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
18
|
-
/**
|
|
19
|
-
* Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared
|
|
20
|
-
* output, or `null` if the line doesn't contain one. Pulled out as a pure
|
|
21
|
-
* function so it can be unit-tested without spawning anything.
|
|
22
|
-
*/
|
|
23
|
-
function parseTrycloudflareUrl(line) {
|
|
24
|
-
const m = line.match(TRYCLOUDFLARE_RE);
|
|
25
|
-
return m ? m[0] : null;
|
|
26
|
-
}
|
|
27
|
-
const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
|
|
28
|
-
/**
|
|
29
|
-
* Build the deep-link URL that QR codes encode: when the launcher PWA is
|
|
30
|
-
* already on the phone's home screen, scanning this opens it directly into the
|
|
31
|
-
* live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).
|
|
32
|
-
* Plain-text raw URL is no longer enough — the launcher gates its setup UI to
|
|
33
|
-
* the installed PWA, so a raw tunnel URL opened in a normal browser tab would
|
|
34
|
-
* land on a "please install" screen.
|
|
35
|
-
*
|
|
36
|
-
* When `opts.name` is given (non-blank), it is added as `&name=` so the launcher
|
|
37
|
-
* partner bar shows the app name instead of the generic default (#498).
|
|
38
|
-
*
|
|
39
|
-
* When `opts.webViewType` is `'game'`, `&navBarType=game` is appended so the
|
|
40
|
-
* launcher enters game nav chrome (floating capsule, no full bar) automatically
|
|
41
|
-
* on scan. `'partner'` is the launcher's implicit default and is not added to
|
|
42
|
-
* keep the URL clean (#584).
|
|
43
|
-
*
|
|
44
|
-
* When `opts.navBarTransparent` is `true`, `&navBarTransparent=1` is appended
|
|
45
|
-
* so the launcher partner bar renders with a transparent background (#587).
|
|
46
|
-
*
|
|
47
|
-
* When `opts.navBarTheme` is `'light'` or `'dark'`, `&navBarTheme=<v>` is
|
|
48
|
-
* appended so the launcher partner bar uses the matching foreground colour (#587).
|
|
49
|
-
*/
|
|
50
|
-
function buildLauncherDeepLink(tunnelUrl, optsParam) {
|
|
51
|
-
const opts = optsParam ?? {};
|
|
52
|
-
let url = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;
|
|
53
|
-
if (opts.name !== void 0 && opts.name.trim() !== "") url += `&name=${encodeURIComponent(opts.name.trim())}`;
|
|
54
|
-
if (opts.webViewType === "game") url += "&navBarType=game";
|
|
55
|
-
if (opts.navBarTransparent === true) url += "&navBarTransparent=1";
|
|
56
|
-
if (opts.navBarTheme === "light" || opts.navBarTheme === "dark") url += `&navBarTheme=${opts.navBarTheme}`;
|
|
57
|
-
return url;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Print the terminal banner announcing the live tunnel: the public URL, an ASCII
|
|
61
|
-
* QR encoding a launcher deep-link, and a one-line note that quick tunnels are
|
|
62
|
-
* ephemeral, unauthenticated and not for production. Pure w.r.t. side effects
|
|
63
|
-
* other than the injected `log` sink and `qrcode-terminal` — unit-tested.
|
|
64
|
-
*/
|
|
65
|
-
async function printTunnelBanner(url, opts = {}) {
|
|
66
|
-
const log = opts.log ?? ((m) => console.log(m));
|
|
67
|
-
const deepLink = buildLauncherDeepLink(url, {
|
|
68
|
-
name: opts.name,
|
|
69
|
-
webViewType: opts.webViewType,
|
|
70
|
-
navBarTransparent: opts.navBarTransparent,
|
|
71
|
-
navBarTheme: opts.navBarTheme
|
|
72
|
-
});
|
|
73
|
-
log([
|
|
74
|
-
"",
|
|
75
|
-
" ┌─ @apps-in-toss/devtools · live tunnel ────────────────────────────",
|
|
76
|
-
` │ ${url}`,
|
|
77
|
-
" │",
|
|
78
|
-
` │ Install the launcher PWA once: ${LAUNCHER_URL}`,
|
|
79
|
-
" │ Then scan the QR below — it opens the launcher directly",
|
|
80
|
-
" │ into this tunnel URL (no manual paste needed).",
|
|
81
|
-
" │ Quick tunnels are unauthenticated, change every run, and are",
|
|
82
|
-
" │ not for production use.",
|
|
83
|
-
" └──────────────────────────────────────────────────────────────",
|
|
84
|
-
""
|
|
85
|
-
].join("\n"));
|
|
86
|
-
if (opts.qr !== false) {
|
|
87
|
-
const qrcode = (await import("qrcode-terminal")).default;
|
|
88
|
-
await new Promise((resolve) => {
|
|
89
|
-
qrcode.generate(deepLink, { small: true }, (out) => {
|
|
90
|
-
log(out);
|
|
91
|
-
resolve();
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Sanitize cloudflared stderr output for error diagnostics (#421).
|
|
98
|
-
*
|
|
99
|
-
* Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs
|
|
100
|
-
* that carry those hostnames so tunnel host values never appear in error
|
|
101
|
-
* messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.
|
|
102
|
-
*
|
|
103
|
-
* SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only
|
|
104
|
-
* placeholder text is emitted.
|
|
105
|
-
*/
|
|
106
|
-
function sanitizeCloudflaredOutput(line) {
|
|
107
|
-
let s = line.replace(/(?:https?|wss?):\/\/[a-z0-9-]+\.trycloudflare\.com(?:\/[^\s]*)*/gi, (m) => m.replace(/[a-z0-9-]+\.trycloudflare\.com/i, "<HOST>.trycloudflare.com"));
|
|
108
|
-
s = s.replace(/[a-z0-9-]+\.trycloudflare\.com/gi, "<HOST>.trycloudflare.com");
|
|
109
|
-
return s;
|
|
110
|
-
}
|
|
111
|
-
const URL_TIMEOUT_MS = 2e4;
|
|
112
|
-
/**
|
|
113
|
-
* Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`
|
|
114
|
-
* and resolve once the public URL is known. Downloads the `cloudflared` binary
|
|
115
|
-
* on first use if it is not already installed. Rejects with a friendly error if
|
|
116
|
-
* no URL appears within {@link URL_TIMEOUT_MS}.
|
|
117
|
-
*/
|
|
118
|
-
async function startQuickTunnel(port) {
|
|
119
|
-
const { bin, install, Tunnel } = await import("cloudflared");
|
|
120
|
-
if (!(0, node_fs.existsSync)(bin)) {
|
|
121
|
-
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(bin), { recursive: true });
|
|
122
|
-
await install(bin);
|
|
123
|
-
}
|
|
124
|
-
const tunnel = Tunnel.quick(`http://localhost:${port}`);
|
|
125
|
-
let stopped = false;
|
|
126
|
-
const stop = () => {
|
|
127
|
-
if (stopped) return;
|
|
128
|
-
stopped = true;
|
|
129
|
-
try {
|
|
130
|
-
tunnel.stop();
|
|
131
|
-
} catch {}
|
|
132
|
-
};
|
|
133
|
-
return new Promise((resolve, reject) => {
|
|
134
|
-
const stderrLines = [];
|
|
135
|
-
/**
|
|
136
|
-
* Format the last `n` sanitized stderr lines as a diagnostic appendix.
|
|
137
|
-
* Returns an empty string when no lines have been collected.
|
|
138
|
-
*/
|
|
139
|
-
const stderrTail = (n = 15) => {
|
|
140
|
-
if (stderrLines.length === 0) return "";
|
|
141
|
-
const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join("");
|
|
142
|
-
return `\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\n${tail}`;
|
|
143
|
-
};
|
|
144
|
-
const timer = setTimeout(() => {
|
|
145
|
-
cleanup();
|
|
146
|
-
stop();
|
|
147
|
-
reject(/* @__PURE__ */ new Error(`[@apps-in-toss/devtools] cloudflared did not report a tunnel URL within ${URL_TIMEOUT_MS / 1e3}s. Check your network connection, or run \`cloudflared tunnel --url http://localhost:${port}\` manually.${stderrTail()}`));
|
|
148
|
-
}, URL_TIMEOUT_MS);
|
|
149
|
-
const onUrl = (line) => {
|
|
150
|
-
const found = parseTrycloudflareUrl(line);
|
|
151
|
-
if (!found) return;
|
|
152
|
-
clearTimeout(timer);
|
|
153
|
-
cleanup();
|
|
154
|
-
resolve({
|
|
155
|
-
url: found,
|
|
156
|
-
stop
|
|
157
|
-
});
|
|
158
|
-
};
|
|
159
|
-
const pushStderr = (line) => {
|
|
160
|
-
stderrLines.push(line);
|
|
161
|
-
};
|
|
162
|
-
const cleanup = () => {
|
|
163
|
-
tunnel.off("stdout", onUrl);
|
|
164
|
-
tunnel.off("stderr", onUrl);
|
|
165
|
-
tunnel.off("stderr", pushStderr);
|
|
166
|
-
};
|
|
167
|
-
tunnel.once("url", onUrl);
|
|
168
|
-
tunnel.on("stdout", onUrl);
|
|
169
|
-
tunnel.on("stderr", onUrl);
|
|
170
|
-
tunnel.on("stderr", pushStderr);
|
|
171
|
-
tunnel.once("error", (err) => {
|
|
172
|
-
clearTimeout(timer);
|
|
173
|
-
cleanup();
|
|
174
|
-
stop();
|
|
175
|
-
reject(err);
|
|
176
|
-
});
|
|
177
|
-
tunnel.once("exit", (code) => {
|
|
178
|
-
if (stopped) return;
|
|
179
|
-
clearTimeout(timer);
|
|
180
|
-
cleanup();
|
|
181
|
-
reject(/* @__PURE__ */ new Error(`[@apps-in-toss/devtools] cloudflared exited (code ${code ?? "null"}) before reporting a tunnel URL.${stderrTail()}`));
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
//#endregion
|
|
186
|
-
exports.printTunnelBanner = printTunnelBanner;
|
|
187
|
-
exports.startQuickTunnel = startQuickTunnel;
|