@apps-in-toss/devtools 3.0.2

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,186 @@
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 };
@@ -0,0 +1,187 @@
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;
@@ -0,0 +1,312 @@
1
+ Object.defineProperties(exports, {
2
+ __esModule: { value: true },
3
+ [Symbol.toStringTag]: { value: "Module" }
4
+ });
5
+ let node_fs = require("node:fs");
6
+ let node_module = require("node:module");
7
+ let node_path = require("node:path");
8
+ let unplugin = require("unplugin");
9
+ //#region src/shared/parent-watcher.ts
10
+ /**
11
+ * Shared parent-PID watcher — used by both the MCP debug daemon and the
12
+ * unplugin tunnel path to self-terminate when the parent process (e.g. Claude
13
+ * Code, vite) has died or been reparented without sending SIGTERM/SIGHUP.
14
+ *
15
+ * Intentionally react-free and Node-stdlib-only so this module is safe to
16
+ * import from the MCP daemon bundle (`dist/mcp/cli.js`) without violating the
17
+ * install-graph invariant.
18
+ */
19
+ /**
20
+ * Returns `true` when the given PID refers to a running process.
21
+ *
22
+ * Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process
23
+ * exists and we have permission to signal it; throws ESRCH when it doesn't exist.
24
+ */
25
+ function isPidAlive(pid) {
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ } catch (err) {
30
+ if (err.code === "EPERM") return true;
31
+ return false;
32
+ }
33
+ }
34
+ /**
35
+ * Starts a periodic watcher that detects when the parent process (e.g. Claude
36
+ * Code) has died without sending SIGTERM/SIGHUP, and calls `onOrphaned` so the
37
+ * daemon can self-terminate rather than running as a zombie.
38
+ *
39
+ * Mirrors the `startAttachWatcher` pattern: `setInterval`-based, returns
40
+ * `{ stop(): void }`, injectable deps for testability.
41
+ *
42
+ * @param onOrphaned - Called once when the parent is gone.
43
+ * @param opts.intervalMs - Poll interval in milliseconds (default 5 000).
44
+ * @param opts.initialPpid - Parent PID to watch (default `process.ppid`).
45
+ * @param opts.isAlive - Predicate to test if a PID is running (default `isPidAlive`).
46
+ * @param opts.getPpid - Supplier of current ppid (default `() => process.ppid`).
47
+ * Detects ppid changes as well as death.
48
+ * @param opts.log - Logger (default `process.stderr.write`).
49
+ *
50
+ * @returns `stop` — call during shutdown to clear the interval.
51
+ */
52
+ function startParentWatcher(onOrphaned, opts) {
53
+ const { intervalMs = 5e3, initialPpid = process.ppid, isAlive = isPidAlive, getPpid = () => process.ppid, log = (msg) => process.stderr.write(msg) } = opts ?? {};
54
+ if (initialPpid <= 1) {
55
+ log("[ait-debug] parent-pid watcher: no parent to watch (ppid<=1), skipping\n");
56
+ return { stop() {} };
57
+ }
58
+ let fired = false;
59
+ const handle = setInterval(() => {
60
+ if (fired) return;
61
+ const currentPpid = getPpid();
62
+ if (currentPpid !== initialPpid || !isAlive(initialPpid)) {
63
+ fired = true;
64
+ clearInterval(handle);
65
+ log(`[ait-debug] parent-pid watcher: parent PID ${initialPpid} is gone (currentPpid=${currentPpid}) — shutting down\n`);
66
+ onOrphaned();
67
+ }
68
+ }, intervalMs);
69
+ return { stop() {
70
+ clearInterval(handle);
71
+ } };
72
+ }
73
+ //#endregion
74
+ //#region src/unplugin/index.ts
75
+ /**
76
+ * @apps-in-toss/devtools unplugin
77
+ *
78
+ * 모든 주요 번들러를 지원하는 단일 플러그인.
79
+ * @apps-in-toss/web-framework → @apps-in-toss/devtools/mock 으로 alias 설정.
80
+ *
81
+ * Usage:
82
+ * import aitDevtools from '@apps-in-toss/devtools/unplugin';
83
+ *
84
+ * // Vite
85
+ * export default { plugins: [aitDevtools.vite()] };
86
+ *
87
+ * // Webpack / Next.js
88
+ * config.plugins.push(aitDevtools.webpack());
89
+ *
90
+ * // Rspack
91
+ * config.plugins.push(aitDevtools.rspack());
92
+ *
93
+ * // esbuild
94
+ * { plugins: [aitDevtools.esbuild()] }
95
+ *
96
+ * // Rollup
97
+ * { plugins: [aitDevtools.rollup()] }
98
+ */
99
+ /**
100
+ * Resolve `@apps-in-toss/devtools/mock` to its real file path at plugin-load time.
101
+ *
102
+ * Returning the bare specifier from `resolveId` would stop the bundler from
103
+ * walking node_modules for it — Vite 8+ treats such a non-null string as the
104
+ * final resolved id and serves it via the virtual `/@id/` prefix, which 404s
105
+ * because we don't provide a `load` hook. Resolving to an absolute path here
106
+ * lets every supported bundler load the file the normal way.
107
+ */
108
+ function resolveMockPath(specifier) {
109
+ try {
110
+ return (0, node_module.createRequire)((0, node_path.resolve)(process.cwd(), "package.json")).resolve(specifier);
111
+ } catch {
112
+ return specifier;
113
+ }
114
+ }
115
+ const MOCK_PATH_2X = resolveMockPath("@apps-in-toss/devtools/mock/2x");
116
+ const MOCK_PATH_3X = resolveMockPath("@apps-in-toss/devtools/mock/3x");
117
+ /** Resolve the consumer project's installed SDK major without importing it. */
118
+ function detectInstalledSdkMajor(cwd = process.cwd()) {
119
+ try {
120
+ const packageJsonPath = (0, node_module.createRequire)((0, node_path.resolve)(cwd, "package.json")).resolve(`${FRAMEWORK_ID}/package.json`);
121
+ const version = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf8")).version;
122
+ if (version?.startsWith("2.")) return "2";
123
+ if (version?.startsWith("3.")) return "3";
124
+ } catch {}
125
+ return null;
126
+ }
127
+ const FRAMEWORK_ID = "@apps-in-toss/web-framework";
128
+ const BRIDGE_ID = "@apps-in-toss/web-bridge";
129
+ const ANALYTICS_ID = "@apps-in-toss/web-analytics";
130
+ const WEBVIEW_BRIDGE_ID = "@apps-in-toss/webview-bridge";
131
+ /** MCP state endpoint path — browser panel POSTs here, MCP server GETs here */
132
+ const MCP_STATE_PATH = "/api/ait-devtools/state";
133
+ /** Browser runtime opt-in consumed by the panel's state-sync helper. */
134
+ const MCP_ENABLE_SNIPPET = "globalThis.__AIT_DEVTOOLS_MCP_ENABLED__ = true;";
135
+ /**
136
+ * Resolves the effective tunnel option (#425).
137
+ *
138
+ * An explicit `tunnel` value (including `false`) always takes priority over
139
+ * env vars — the `??` operator means `undefined` (= omitted) falls through,
140
+ * but `false` / `true` / an object are preserved as-is (non-breaking).
141
+ *
142
+ * When the option is omitted:
143
+ * - `AIT_TUNNEL=1` enables the screen-preview tunnel.
144
+ * - Not set → `false` (disabled).
145
+ *
146
+ * Extracted as a pure function so it can be unit-tested without standing up
147
+ * a full Vite dev server.
148
+ *
149
+ * @param explicit - The `tunnel` option as passed by the consumer (or `undefined` when omitted).
150
+ * @param env - The process environment (injectable for testing).
151
+ */
152
+ function resolveTunnelOption(explicit, env) {
153
+ return explicit ?? !!env.AIT_TUNNEL;
154
+ }
155
+ const aitDevtoolsPlugin = (0, unplugin.createUnplugin)((options) => {
156
+ const isDev = process.env.NODE_ENV !== "production";
157
+ const shouldEnable = isDev;
158
+ const shouldMock = shouldEnable && (options?.mock ?? isDev);
159
+ const sdkMajor = options?.sdkVersion === "2" || options?.sdkVersion === "3" ? options.sdkVersion : detectInstalledSdkMajor() ?? "3";
160
+ const shouldPanel = shouldEnable && (options?.panel ?? true);
161
+ const shouldMcp = shouldEnable && (options?.mcp ?? false);
162
+ let lastState = null;
163
+ const tunnelOpt = resolveTunnelOption(options?.tunnel, process.env);
164
+ const shouldTunnel = isDev && !!tunnelOpt;
165
+ const webViewType = options?.webViewType ?? "partner";
166
+ const navBarTransparent = options?.navBarTransparent;
167
+ const navBarTheme = options?.navBarTheme;
168
+ const tunnelConfig = typeof tunnelOpt === "object" ? tunnelOpt : {};
169
+ return {
170
+ name: "ait-co-devtools",
171
+ enforce: "pre",
172
+ resolveId(id) {
173
+ if (!shouldMock) return null;
174
+ if (id === FRAMEWORK_ID || id === WEBVIEW_BRIDGE_ID || id === BRIDGE_ID || id === ANALYTICS_ID) {
175
+ if (id === BRIDGE_ID || id === ANALYTICS_ID) return MOCK_PATH_2X;
176
+ if (id === WEBVIEW_BRIDGE_ID) return MOCK_PATH_3X;
177
+ return sdkMajor === "2" ? MOCK_PATH_2X : MOCK_PATH_3X;
178
+ }
179
+ return null;
180
+ },
181
+ transformInclude(id) {
182
+ if (!shouldPanel && !shouldMcp) return false;
183
+ return /\.(tsx?|jsx?)$/.test(id) && /\/(main|index|entry|app)\.[tj]sx?$/i.test(id) && !id.includes("node_modules");
184
+ },
185
+ transform(code) {
186
+ let result = code;
187
+ let changed = false;
188
+ if (shouldMcp && !code.includes("__AIT_DEVTOOLS_MCP_ENABLED__")) {
189
+ result = `${MCP_ENABLE_SNIPPET}\n${result}`;
190
+ changed = true;
191
+ }
192
+ if (shouldPanel && !code.includes("@apps-in-toss/devtools/panel")) {
193
+ result = `import '@apps-in-toss/devtools/panel';\n${result}`;
194
+ changed = true;
195
+ }
196
+ return changed ? result : null;
197
+ },
198
+ vite: {
199
+ config() {
200
+ const define = { __WEB_VIEW_TYPE__: JSON.stringify(webViewType) };
201
+ if (!shouldTunnel) return { define };
202
+ return {
203
+ define,
204
+ server: { allowedHosts: [".trycloudflare.com"] }
205
+ };
206
+ },
207
+ configureServer(server) {
208
+ if (shouldMcp) server.middlewares.use(MCP_STATE_PATH, (req, res) => {
209
+ res.setHeader("Access-Control-Allow-Origin", "*");
210
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
211
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
212
+ if (req.method === "OPTIONS") {
213
+ res.writeHead(204);
214
+ res.end();
215
+ return;
216
+ }
217
+ if (req.method === "GET") {
218
+ if (lastState === null) {
219
+ res.writeHead(503, { "Content-Type": "application/json" });
220
+ res.end(JSON.stringify({ error: "No state received yet. Open the app in a browser first." }));
221
+ return;
222
+ }
223
+ res.writeHead(200, { "Content-Type": "application/json" });
224
+ res.end(lastState);
225
+ return;
226
+ }
227
+ if (req.method === "POST") {
228
+ const chunks = [];
229
+ req.on("data", (chunk) => chunks.push(chunk));
230
+ req.on("end", () => {
231
+ try {
232
+ const body = Buffer.concat(chunks).toString("utf-8");
233
+ JSON.parse(body);
234
+ lastState = body;
235
+ res.writeHead(204);
236
+ res.end();
237
+ } catch {
238
+ res.writeHead(400, { "Content-Type": "application/json" });
239
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
240
+ }
241
+ });
242
+ return;
243
+ }
244
+ res.writeHead(405, { "Content-Type": "application/json" });
245
+ res.end(JSON.stringify({ error: "Method not allowed" }));
246
+ });
247
+ if (shouldTunnel) {
248
+ let tunnel = null;
249
+ let parentWatcher = null;
250
+ const httpServer = server.httpServer;
251
+ httpServer?.once("listening", () => {
252
+ const address = httpServer?.address();
253
+ const port = tunnelConfig.port ?? (address && typeof address === "object" ? address.port : void 0);
254
+ if (!port) {
255
+ console.warn("[@apps-in-toss/devtools] tunnel: could not determine the dev server port; skipping.");
256
+ return;
257
+ }
258
+ Promise.resolve().then(() => require("../tunnel-DtCTOUlp.cjs")).then(async ({ startQuickTunnel, printTunnelBanner }) => {
259
+ const t = await startQuickTunnel(port);
260
+ tunnel = t;
261
+ let tunnelAppName;
262
+ try {
263
+ const { readFileSync } = await import("node:fs");
264
+ const pkgRaw = readFileSync(`${server.config.root}/package.json`, "utf8");
265
+ const pkg = JSON.parse(pkgRaw);
266
+ const rawName = typeof pkg.name === "string" ? pkg.name : "";
267
+ tunnelAppName = (rawName.includes("/") ? rawName.slice(rawName.indexOf("/") + 1) : rawName).trim() || void 0;
268
+ } catch {}
269
+ await printTunnelBanner(t.url, {
270
+ qr: tunnelConfig.qr,
271
+ name: tunnelAppName,
272
+ webViewType,
273
+ navBarTransparent,
274
+ navBarTheme
275
+ });
276
+ parentWatcher = startParentWatcher(() => {
277
+ cleanup();
278
+ process.exit(0);
279
+ });
280
+ }).catch((err) => {
281
+ console.warn(`[@apps-in-toss/devtools] tunnel failed to start: ${err instanceof Error ? err.message : String(err)}`);
282
+ });
283
+ });
284
+ const cleanup = () => {
285
+ parentWatcher?.stop();
286
+ tunnel?.stop();
287
+ };
288
+ httpServer?.once("close", cleanup);
289
+ process.once("SIGINT", cleanup);
290
+ process.once("SIGTERM", cleanup);
291
+ process.once("SIGHUP", cleanup);
292
+ process.once("exit", cleanup);
293
+ }
294
+ }
295
+ }
296
+ };
297
+ });
298
+ const vite = aitDevtoolsPlugin.vite;
299
+ const webpack = aitDevtoolsPlugin.webpack;
300
+ const rollup = aitDevtoolsPlugin.rollup;
301
+ const esbuild = aitDevtoolsPlugin.esbuild;
302
+ const rspack = aitDevtoolsPlugin.rspack;
303
+ const aitDevtools = aitDevtoolsPlugin;
304
+ //#endregion
305
+ exports.default = aitDevtools;
306
+ exports.detectInstalledSdkMajor = detectInstalledSdkMajor;
307
+ exports.esbuild = esbuild;
308
+ exports.resolveTunnelOption = resolveTunnelOption;
309
+ exports.rollup = rollup;
310
+ exports.rspack = rspack;
311
+ exports.vite = vite;
312
+ exports.webpack = webpack;