@ait-co/devtools 0.1.18 → 0.1.20
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/README.en.md +863 -0
- package/README.md +117 -5
- package/dist/panel/index.d.ts.map +1 -1
- package/dist/panel/index.js +650 -118
- package/dist/panel/index.js.map +1 -1
- package/dist/tunnel-BbcgVy4L.js +114 -0
- package/dist/tunnel-BbcgVy4L.js.map +1 -0
- package/dist/tunnel-DeXfLGRl.cjs +115 -0
- package/dist/tunnel-DeXfLGRl.cjs.map +1 -0
- package/dist/unplugin/index.cjs +34 -0
- package/dist/unplugin/index.cjs.map +1 -1
- package/dist/unplugin/index.d.cts +10 -0
- package/dist/unplugin/index.d.cts.map +1 -1
- package/dist/unplugin/index.d.ts +11 -1
- package/dist/unplugin/index.d.ts.map +1 -1
- package/dist/unplugin/index.js +34 -0
- package/dist/unplugin/index.js.map +1 -1
- package/dist/unplugin/tunnel.cjs +117 -0
- package/dist/unplugin/tunnel.cjs.map +1 -0
- package/dist/unplugin/tunnel.d.cts +47 -0
- package/dist/unplugin/tunnel.d.cts.map +1 -0
- package/dist/unplugin/tunnel.d.ts +47 -0
- package/dist/unplugin/tunnel.d.ts.map +1 -0
- package/dist/unplugin/tunnel.js +114 -0
- package/dist/unplugin/tunnel.js.map +1 -0
- package/package.json +11 -3
package/dist/unplugin/index.js
CHANGED
|
@@ -49,6 +49,9 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
|
|
|
49
49
|
const shouldEnable = isDev || (options?.forceEnable ?? false);
|
|
50
50
|
const shouldMock = shouldEnable && (options?.mock ?? isDev);
|
|
51
51
|
const shouldPanel = shouldEnable && (options?.panel ?? true);
|
|
52
|
+
const tunnelOpt = options?.tunnel;
|
|
53
|
+
const shouldTunnel = isDev && !!tunnelOpt;
|
|
54
|
+
const tunnelConfig = typeof tunnelOpt === "object" ? tunnelOpt : {};
|
|
52
55
|
return {
|
|
53
56
|
name: "ait-co-devtools",
|
|
54
57
|
enforce: "pre",
|
|
@@ -65,6 +68,37 @@ const aitDevtoolsPlugin = createUnplugin((options) => {
|
|
|
65
68
|
if (!shouldPanel) return null;
|
|
66
69
|
if (code.includes("@ait-co/devtools/panel")) return null;
|
|
67
70
|
return `import '@ait-co/devtools/panel';\n${code}`;
|
|
71
|
+
},
|
|
72
|
+
vite: {
|
|
73
|
+
config() {
|
|
74
|
+
if (!shouldTunnel) return;
|
|
75
|
+
return { server: { allowedHosts: [".trycloudflare.com"] } };
|
|
76
|
+
},
|
|
77
|
+
configureServer(server) {
|
|
78
|
+
if (!shouldTunnel) return;
|
|
79
|
+
let tunnel = null;
|
|
80
|
+
const httpServer = server.httpServer;
|
|
81
|
+
httpServer?.once("listening", () => {
|
|
82
|
+
const address = httpServer?.address();
|
|
83
|
+
const port = tunnelConfig.port ?? (address && typeof address === "object" ? address.port : void 0);
|
|
84
|
+
if (!port) {
|
|
85
|
+
console.warn("[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
import("../tunnel-BbcgVy4L.js").then(async ({ startQuickTunnel, printTunnelBanner }) => {
|
|
89
|
+
const t = await startQuickTunnel(port);
|
|
90
|
+
tunnel = t;
|
|
91
|
+
await printTunnelBanner(t.url, { qr: tunnelConfig.qr });
|
|
92
|
+
}).catch((err) => {
|
|
93
|
+
console.warn(`[@ait-co/devtools] tunnel failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
const cleanup = () => tunnel?.stop();
|
|
97
|
+
httpServer?.once("close", cleanup);
|
|
98
|
+
process.once("SIGINT", cleanup);
|
|
99
|
+
process.once("SIGTERM", cleanup);
|
|
100
|
+
process.once("exit", cleanup);
|
|
101
|
+
}
|
|
68
102
|
}
|
|
69
103
|
};
|
|
70
104
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/unplugin/index.ts"],"sourcesContent":["/**\n * @ait-co/devtools unplugin\n *\n * 모든 주요 번들러를 지원하는 단일 플러그인.\n * @apps-in-toss/web-framework → @ait-co/devtools/mock 으로 alias 설정.\n *\n * Usage:\n * import aitDevtools from '@ait-co/devtools/unplugin';\n *\n * // Vite\n * export default { plugins: [aitDevtools.vite()] };\n *\n * // Webpack / Next.js\n * config.plugins.push(aitDevtools.webpack());\n *\n * // Rspack\n * config.plugins.push(aitDevtools.rspack());\n *\n * // esbuild\n * { plugins: [aitDevtools.esbuild()] }\n *\n * // Rollup\n * { plugins: [aitDevtools.rollup()] }\n */\n\nimport { fileURLToPath } from 'node:url';\nimport { createUnplugin } from 'unplugin';\n\n/**\n * Resolve `@ait-co/devtools/mock` to its real file path at plugin-load time.\n *\n * Returning the bare specifier from `resolveId` would stop the bundler from\n * walking node_modules for it — Vite 8+ treats such a non-null string as the\n * final resolved id and serves it via the virtual `/@id/` prefix, which 404s\n * because we don't provide a `load` hook. Resolving to an absolute path here\n * lets every supported bundler load the file the normal way.\n */\nconst MOCK_PATH = (() => {\n try {\n return fileURLToPath(import.meta.resolve('@ait-co/devtools/mock'));\n } catch {\n // Fallback for runtimes where `import.meta.resolve` is unavailable.\n return '@ait-co/devtools/mock';\n }\n})();\n\nexport interface AitDevtoolsOptions {\n /**\n * 패널 자동 주입 여부 (default: true)\n * true이면 진입점에 floating panel import를 자동 추가한다.\n */\n panel?: boolean;\n /**\n * production 환경에서도 devtools를 강제로 활성화 (default: false)\n */\n forceEnable?: boolean;\n /**\n * mock alias 활성화 여부. default: true (development), false (production + forceEnable)\n */\n mock?: boolean;\n}\n\nconst FRAMEWORK_ID = '@apps-in-toss/web-framework';\nconst BRIDGE_ID = '@apps-in-toss/web-bridge';\nconst ANALYTICS_ID = '@apps-in-toss/web-analytics';\n\nconst aitDevtoolsPlugin = createUnplugin((options?: AitDevtoolsOptions) => {\n const isDev = process.env.NODE_ENV !== 'production';\n const shouldEnable = isDev || (options?.forceEnable ?? false);\n const shouldMock = shouldEnable && (options?.mock ?? isDev);\n const shouldPanel = shouldEnable && (options?.panel ?? true);\n\n return {\n name: 'ait-co-devtools',\n enforce: 'pre' as const,\n\n resolveId(id: string) {\n if (!shouldMock) return null;\n // @apps-in-toss/web-framework → @ait-co/devtools/mock (absolute path)\n if (id === FRAMEWORK_ID || id === BRIDGE_ID || id === ANALYTICS_ID) {\n return MOCK_PATH;\n }\n return null;\n },\n\n transformInclude(id: string) {\n if (!shouldPanel) return false;\n // 진입점 파일에만 패널 import를 주입\n return (\n /\\.(tsx?|jsx?)$/.test(id) &&\n /\\/(main|index|entry|app)\\.[tj]sx?$/i.test(id) &&\n !id.includes('node_modules')\n );\n },\n\n transform(code: string) {\n // transformInclude가 이미 shouldPanel을 확인하지만, 안전망으로 유지\n if (!shouldPanel) return null;\n // 이미 패널이 import 되어있으면 스킵\n if (code.includes('@ait-co/devtools/panel')) return null;\n // transformInclude가 진입점 파일만 통과시키므로 바로 prepend\n return `import '@ait-co/devtools/panel';\\n${code}`;\n },\n };\n});\n\nexport const vite = aitDevtoolsPlugin.vite;\nexport const webpack = aitDevtoolsPlugin.webpack;\nexport const rollup = aitDevtoolsPlugin.rollup;\nexport const esbuild = aitDevtoolsPlugin.esbuild;\nexport const rspack = aitDevtoolsPlugin.rspack;\n\nexport default aitDevtoolsPlugin;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAM,mBAAmB;AACvB,KAAI;AACF,SAAO,cAAc,OAAO,KAAK,QAAQ,wBAAwB,CAAC;SAC5D;AAEN,SAAO;;IAEP;
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/unplugin/index.ts"],"sourcesContent":["/**\n * @ait-co/devtools unplugin\n *\n * 모든 주요 번들러를 지원하는 단일 플러그인.\n * @apps-in-toss/web-framework → @ait-co/devtools/mock 으로 alias 설정.\n *\n * Usage:\n * import aitDevtools from '@ait-co/devtools/unplugin';\n *\n * // Vite\n * export default { plugins: [aitDevtools.vite()] };\n *\n * // Webpack / Next.js\n * config.plugins.push(aitDevtools.webpack());\n *\n * // Rspack\n * config.plugins.push(aitDevtools.rspack());\n *\n * // esbuild\n * { plugins: [aitDevtools.esbuild()] }\n *\n * // Rollup\n * { plugins: [aitDevtools.rollup()] }\n */\n\nimport { fileURLToPath } from 'node:url';\nimport { createUnplugin } from 'unplugin';\n\n/**\n * Resolve `@ait-co/devtools/mock` to its real file path at plugin-load time.\n *\n * Returning the bare specifier from `resolveId` would stop the bundler from\n * walking node_modules for it — Vite 8+ treats such a non-null string as the\n * final resolved id and serves it via the virtual `/@id/` prefix, which 404s\n * because we don't provide a `load` hook. Resolving to an absolute path here\n * lets every supported bundler load the file the normal way.\n */\nconst MOCK_PATH = (() => {\n try {\n return fileURLToPath(import.meta.resolve('@ait-co/devtools/mock'));\n } catch {\n // Fallback for runtimes where `import.meta.resolve` is unavailable.\n return '@ait-co/devtools/mock';\n }\n})();\n\nexport interface AitDevtoolsOptions {\n /**\n * 패널 자동 주입 여부 (default: true)\n * true이면 진입점에 floating panel import를 자동 추가한다.\n */\n panel?: boolean;\n /**\n * production 환경에서도 devtools를 강제로 활성화 (default: false)\n */\n forceEnable?: boolean;\n /**\n * mock alias 활성화 여부. default: true (development), false (production + forceEnable)\n */\n mock?: boolean;\n /**\n * Vite dev 서버를 Cloudflare quick tunnel(`*.trycloudflare.com`, 계정 불필요)로\n * 외부 노출해 실제 폰에서 미리보기. **Vite dev 모드 전용** — production은\n * `forceEnable`이어도 터널을 띄우지 않는다 (의도치 않은 노출 방지). 다른 번들러는\n * 무시. `true`면 기본 동작, 객체로 세부 설정 가능.\n */\n tunnel?:\n | boolean\n | {\n /** 노출할 포트 (미지정 시 dev 서버가 실제 listen한 포트 자동 감지). */\n port?: number;\n /** 터미널 ASCII QR 출력 (default: true). */\n qr?: boolean;\n };\n}\n\nconst FRAMEWORK_ID = '@apps-in-toss/web-framework';\nconst BRIDGE_ID = '@apps-in-toss/web-bridge';\nconst ANALYTICS_ID = '@apps-in-toss/web-analytics';\n\nconst aitDevtoolsPlugin = createUnplugin((options?: AitDevtoolsOptions) => {\n const isDev = process.env.NODE_ENV !== 'production';\n const shouldEnable = isDev || (options?.forceEnable ?? false);\n const shouldMock = shouldEnable && (options?.mock ?? isDev);\n const shouldPanel = shouldEnable && (options?.panel ?? true);\n // Tunnel is dev-only and Vite-only. Never under production — even with\n // forceEnable — so a production build can't accidentally expose itself.\n const tunnelOpt = options?.tunnel;\n const shouldTunnel = isDev && !!tunnelOpt;\n const tunnelConfig = typeof tunnelOpt === 'object' ? tunnelOpt : {};\n\n return {\n name: 'ait-co-devtools',\n enforce: 'pre' as const,\n\n resolveId(id: string) {\n if (!shouldMock) return null;\n // @apps-in-toss/web-framework → @ait-co/devtools/mock (absolute path)\n if (id === FRAMEWORK_ID || id === BRIDGE_ID || id === ANALYTICS_ID) {\n return MOCK_PATH;\n }\n return null;\n },\n\n transformInclude(id: string) {\n if (!shouldPanel) return false;\n // 진입점 파일에만 패널 import를 주입\n return (\n /\\.(tsx?|jsx?)$/.test(id) &&\n /\\/(main|index|entry|app)\\.[tj]sx?$/i.test(id) &&\n !id.includes('node_modules')\n );\n },\n\n transform(code: string) {\n // transformInclude가 이미 shouldPanel을 확인하지만, 안전망으로 유지\n if (!shouldPanel) return null;\n // 이미 패널이 import 되어있으면 스킵\n if (code.includes('@ait-co/devtools/panel')) return null;\n // transformInclude가 진입점 파일만 통과시키므로 바로 prepend\n return `import '@ait-co/devtools/panel';\\n${code}`;\n },\n\n // Vite-only: start a Cloudflare quick tunnel once the dev server is\n // listening. unplugin passes this through to Vite's plugin object; other\n // bundlers ignore it.\n vite: {\n config() {\n if (!shouldTunnel) return;\n // Vite blocks requests whose Host header isn't in `server.allowedHosts`\n // (defaults to localhost only). The quick-tunnel hostname is random per\n // run, so allow the whole `.trycloudflare.com` suffix while the tunnel\n // is on. (A leading `.` makes Vite match the domain and its subdomains.)\n return { server: { allowedHosts: ['.trycloudflare.com'] } };\n },\n\n configureServer(server: import('vite').ViteDevServer) {\n if (!shouldTunnel) return;\n let tunnel: { stop: () => void } | null = null;\n const httpServer = server.httpServer;\n\n httpServer?.once('listening', () => {\n const address = httpServer?.address();\n const port =\n tunnelConfig.port ??\n (address && typeof address === 'object' ? address.port : undefined);\n if (!port) {\n console.warn(\n '[@ait-co/devtools] tunnel: could not determine the dev server port; skipping.',\n );\n return;\n }\n // Dynamic import keeps `cloudflared` / `qrcode-terminal` off the\n // module graph unless the tunnel is actually used.\n import('./tunnel.js')\n .then(async ({ startQuickTunnel, printTunnelBanner }) => {\n const t = await startQuickTunnel(port);\n tunnel = t;\n await printTunnelBanner(t.url, { qr: tunnelConfig.qr });\n })\n .catch((err: unknown) => {\n console.warn(\n `[@ait-co/devtools] tunnel failed to start: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n });\n });\n\n const cleanup = () => tunnel?.stop();\n httpServer?.once('close', cleanup);\n process.once('SIGINT', cleanup);\n process.once('SIGTERM', cleanup);\n process.once('exit', cleanup);\n },\n },\n };\n});\n\nexport const vite = aitDevtoolsPlugin.vite;\nexport const webpack = aitDevtoolsPlugin.webpack;\nexport const rollup = aitDevtoolsPlugin.rollup;\nexport const esbuild = aitDevtoolsPlugin.esbuild;\nexport const rspack = aitDevtoolsPlugin.rspack;\n\nexport default aitDevtoolsPlugin;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAM,mBAAmB;AACvB,KAAI;AACF,SAAO,cAAc,OAAO,KAAK,QAAQ,wBAAwB,CAAC;SAC5D;AAEN,SAAO;;IAEP;AAgCJ,MAAM,eAAe;AACrB,MAAM,YAAY;AAClB,MAAM,eAAe;AAErB,MAAM,oBAAoB,gBAAgB,YAAiC;CACzE,MAAM,QAAQ,QAAQ,IAAI,aAAa;CACvC,MAAM,eAAe,UAAU,SAAS,eAAe;CACvD,MAAM,aAAa,iBAAiB,SAAS,QAAQ;CACrD,MAAM,cAAc,iBAAiB,SAAS,SAAS;CAGvD,MAAM,YAAY,SAAS;CAC3B,MAAM,eAAe,SAAS,CAAC,CAAC;CAChC,MAAM,eAAe,OAAO,cAAc,WAAW,YAAY,EAAE;AAEnE,QAAO;EACL,MAAM;EACN,SAAS;EAET,UAAU,IAAY;AACpB,OAAI,CAAC,WAAY,QAAO;AAExB,OAAI,OAAO,gBAAgB,OAAO,aAAa,OAAO,aACpD,QAAO;AAET,UAAO;;EAGT,iBAAiB,IAAY;AAC3B,OAAI,CAAC,YAAa,QAAO;AAEzB,UACE,iBAAiB,KAAK,GAAG,IACzB,sCAAsC,KAAK,GAAG,IAC9C,CAAC,GAAG,SAAS,eAAe;;EAIhC,UAAU,MAAc;AAEtB,OAAI,CAAC,YAAa,QAAO;AAEzB,OAAI,KAAK,SAAS,yBAAyB,CAAE,QAAO;AAEpD,UAAO,qCAAqC;;EAM9C,MAAM;GACJ,SAAS;AACP,QAAI,CAAC,aAAc;AAKnB,WAAO,EAAE,QAAQ,EAAE,cAAc,CAAC,qBAAqB,EAAE,EAAE;;GAG7D,gBAAgB,QAAsC;AACpD,QAAI,CAAC,aAAc;IACnB,IAAI,SAAsC;IAC1C,MAAM,aAAa,OAAO;AAE1B,gBAAY,KAAK,mBAAmB;KAClC,MAAM,UAAU,YAAY,SAAS;KACrC,MAAM,OACJ,aAAa,SACZ,WAAW,OAAO,YAAY,WAAW,QAAQ,OAAO,KAAA;AAC3D,SAAI,CAAC,MAAM;AACT,cAAQ,KACN,gFACD;AACD;;AAIF,YAAO,yBACJ,KAAK,OAAO,EAAE,kBAAkB,wBAAwB;MACvD,MAAM,IAAI,MAAM,iBAAiB,KAAK;AACtC,eAAS;AACT,YAAM,kBAAkB,EAAE,KAAK,EAAE,IAAI,aAAa,IAAI,CAAC;OACvD,CACD,OAAO,QAAiB;AACvB,cAAQ,KACN,8CACE,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI,GAEnD;OACD;MACJ;IAEF,MAAM,gBAAgB,QAAQ,MAAM;AACpC,gBAAY,KAAK,SAAS,QAAQ;AAClC,YAAQ,KAAK,UAAU,QAAQ;AAC/B,YAAQ,KAAK,WAAW,QAAQ;AAChC,YAAQ,KAAK,QAAQ,QAAQ;;GAEhC;EACF;EACD;AAEF,MAAa,OAAO,kBAAkB;AACtC,MAAa,UAAU,kBAAkB;AACzC,MAAa,SAAS,kBAAkB;AACxC,MAAa,UAAU,kBAAkB;AACzC,MAAa,SAAS,kBAAkB"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_fs = require("node:fs");
|
|
3
|
+
let node_fs_promises = require("node:fs/promises");
|
|
4
|
+
let node_path = require("node:path");
|
|
5
|
+
//#region src/unplugin/tunnel.ts
|
|
6
|
+
/**
|
|
7
|
+
* Cloudflare quick-tunnel helper for the devtools unplugin.
|
|
8
|
+
*
|
|
9
|
+
* Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is
|
|
10
|
+
* on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common
|
|
11
|
+
* case. This is the one place in `@ait-co/devtools` that depends on Node-only
|
|
12
|
+
* APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of
|
|
13
|
+
* jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the
|
|
14
|
+
* "web 모드는 e2e" rule in CLAUDE.md). The pure helpers below
|
|
15
|
+
* (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.
|
|
16
|
+
*/
|
|
17
|
+
/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */
|
|
18
|
+
const TRYCLOUDFLARE_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
19
|
+
/**
|
|
20
|
+
* Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared
|
|
21
|
+
* output, or `null` if the line doesn't contain one. Pulled out as a pure
|
|
22
|
+
* function so it can be unit-tested without spawning anything.
|
|
23
|
+
*/
|
|
24
|
+
function parseTrycloudflareUrl(line) {
|
|
25
|
+
const m = line.match(TRYCLOUDFLARE_RE);
|
|
26
|
+
return m ? m[0] : null;
|
|
27
|
+
}
|
|
28
|
+
const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
|
|
29
|
+
/**
|
|
30
|
+
* Print the terminal banner announcing the live tunnel: the public URL, an ASCII
|
|
31
|
+
* QR encoding it, and a one-line note that quick tunnels are ephemeral,
|
|
32
|
+
* unauthenticated and not for production. Pure w.r.t. side effects other than
|
|
33
|
+
* the injected `log` sink and `qrcode-terminal` — unit-tested.
|
|
34
|
+
*/
|
|
35
|
+
async function printTunnelBanner(url, opts = {}) {
|
|
36
|
+
const log = opts.log ?? ((m) => console.log(m));
|
|
37
|
+
log([
|
|
38
|
+
"",
|
|
39
|
+
" ┌─ @ait-co/devtools · live tunnel ────────────────────────────",
|
|
40
|
+
` │ ${url}`,
|
|
41
|
+
" │",
|
|
42
|
+
` │ Open the launcher on your phone: ${LAUNCHER_URL}`,
|
|
43
|
+
" │ Scan the QR below from there (or paste the URL).",
|
|
44
|
+
" │ Quick tunnels are unauthenticated, change every run, and are",
|
|
45
|
+
" │ not for production use.",
|
|
46
|
+
" └──────────────────────────────────────────────────────────────",
|
|
47
|
+
""
|
|
48
|
+
].join("\n"));
|
|
49
|
+
if (opts.qr !== false) {
|
|
50
|
+
const qrcode = (await import("qrcode-terminal")).default;
|
|
51
|
+
await new Promise((resolve) => {
|
|
52
|
+
qrcode.generate(url, { small: true }, (out) => {
|
|
53
|
+
log(out);
|
|
54
|
+
resolve();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const URL_TIMEOUT_MS = 2e4;
|
|
60
|
+
/**
|
|
61
|
+
* Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`
|
|
62
|
+
* and resolve once the public URL is known. Downloads the `cloudflared` binary
|
|
63
|
+
* on first use if it is not already installed. Rejects with a friendly error if
|
|
64
|
+
* no URL appears within {@link URL_TIMEOUT_MS}.
|
|
65
|
+
*/
|
|
66
|
+
async function startQuickTunnel(port) {
|
|
67
|
+
const { bin, install, Tunnel } = await import("cloudflared");
|
|
68
|
+
if (!(0, node_fs.existsSync)(bin)) {
|
|
69
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(bin), { recursive: true });
|
|
70
|
+
await install(bin);
|
|
71
|
+
}
|
|
72
|
+
const tunnel = Tunnel.quick(`http://localhost:${port}`);
|
|
73
|
+
let stopped = false;
|
|
74
|
+
const stop = () => {
|
|
75
|
+
if (stopped) return;
|
|
76
|
+
stopped = true;
|
|
77
|
+
try {
|
|
78
|
+
tunnel.stop();
|
|
79
|
+
} catch {}
|
|
80
|
+
};
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
stop();
|
|
84
|
+
reject(/* @__PURE__ */ new Error(`[@ait-co/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.`));
|
|
85
|
+
}, URL_TIMEOUT_MS);
|
|
86
|
+
const onUrl = (line) => {
|
|
87
|
+
const found = parseTrycloudflareUrl(line);
|
|
88
|
+
if (!found) return;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
tunnel.off("stdout", onUrl);
|
|
91
|
+
tunnel.off("stderr", onUrl);
|
|
92
|
+
resolve({
|
|
93
|
+
url: found,
|
|
94
|
+
stop
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
tunnel.once("url", onUrl);
|
|
98
|
+
tunnel.on("stdout", onUrl);
|
|
99
|
+
tunnel.on("stderr", onUrl);
|
|
100
|
+
tunnel.once("error", (err) => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
stop();
|
|
103
|
+
reject(err);
|
|
104
|
+
});
|
|
105
|
+
tunnel.once("exit", (code) => {
|
|
106
|
+
if (stopped) return;
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared exited (code ${code ?? "null"}) before reporting a tunnel URL.`));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
exports.parseTrycloudflareUrl = parseTrycloudflareUrl;
|
|
114
|
+
exports.printTunnelBanner = printTunnelBanner;
|
|
115
|
+
exports.startQuickTunnel = startQuickTunnel;
|
|
116
|
+
|
|
117
|
+
//# sourceMappingURL=tunnel.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tunnel.cjs","names":[],"sources":["../../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding it, and a one-line note that quick tunnels are ephemeral,\n * unauthenticated and not for production. Pure w.r.t. side effects other than\n * the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Open the launcher on your phone: ${LAUNCHER_URL}`,\n ' │ Scan the QR below from there (or paste the URL).',\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(url, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n const timer = setTimeout(() => {\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n resolve({ url: found, stop });\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.`,\n ),\n );\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAiBA,MAAM,mBAAmB;;;;;;AAOzB,SAAgB,sBAAsB,MAA6B;CACjE,MAAM,IAAI,KAAK,MAAM,iBAAiB;AACtC,QAAO,IAAI,EAAE,KAAK;;AAUpB,MAAM,eAAe;;;;;;;AAQrB,eAAsB,kBACpB,KACA,OAAiC,EAAE,EACpB;CACf,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;AAatD,KAZwB;EACtB;EACA;EACA,QAAQ;EACR;EACA,0CAA0C;EAC1C;EACA;EACA;EACA;EACA;EACD,CACS,KAAK,KAAK,CAAC;AAErB,KAAI,KAAK,OAAO,OAAO;EAGrB,MAAM,UAAU,MAAM,OAAO,oBAAoB;AACjD,QAAM,IAAI,SAAe,YAAY;AACnC,UAAO,SAAS,KAAK,EAAE,OAAO,MAAM,GAAG,QAAQ;AAC7C,QAAI,IAAI;AACR,aAAS;KACT;IACF;;;AAWN,MAAM,iBAAiB;;;;;;;AAQvB,eAAsB,iBAAiB,MAAoC;CAEzE,MAAM,EAAE,KAAK,SAAS,WADF,MAAM,OAAO;AAGjC,KAAI,EAAA,GAAA,QAAA,YAAY,IAAI,EAAE;AACpB,SAAA,GAAA,iBAAA,QAAA,GAAA,UAAA,SAAoB,IAAI,EAAE,EAAE,WAAW,MAAM,CAAC;AAC9C,QAAM,QAAQ,IAAI;;CAGpB,MAAM,SAAS,OAAO,MAAM,oBAAoB,OAAO;CACvD,IAAI,UAAU;CACd,MAAM,aAAa;AACjB,MAAI,QAAS;AACb,YAAU;AACV,MAAI;AACF,UAAO,MAAM;UACP;;AAKV,QAAO,IAAI,SAAsB,SAAS,WAAW;EACnD,MAAM,QAAQ,iBAAiB;AAC7B,SAAM;AACN,0BACE,IAAI,MACF,qEACE,iBAAiB,IAClB,uFAAuF,KAAK,cAC9F,CACF;KACA,eAAe;EAElB,MAAM,SAAS,SAAiB;GAC9B,MAAM,QAAQ,sBAAsB,KAAK;AACzC,OAAI,CAAC,MAAO;AACZ,gBAAa,MAAM;AAEnB,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,MAAM;AAC3B,WAAQ;IAAE,KAAK;IAAO;IAAM,CAAC;;AAK/B,SAAO,KAAK,OAAO,MAAM;AACzB,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,KAAK,UAAU,QAAe;AACnC,gBAAa,MAAM;AACnB,SAAM;AACN,UAAO,IAAI;IACX;AACF,SAAO,KAAK,SAAS,SAAwB;AAC3C,OAAI,QAAS;AACb,gBAAa,MAAM;AACnB,0BACE,IAAI,MACF,+CAA+C,QAAQ,OAAO,kCAC/D,CACF;IACD;GACF"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/unplugin/tunnel.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Cloudflare quick-tunnel helper for the devtools unplugin.
|
|
4
|
+
*
|
|
5
|
+
* Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is
|
|
6
|
+
* on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common
|
|
7
|
+
* case. This is the one place in `@ait-co/devtools` that depends on Node-only
|
|
8
|
+
* APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of
|
|
9
|
+
* jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the
|
|
10
|
+
* "web 모드는 e2e" rule in CLAUDE.md). The pure helpers below
|
|
11
|
+
* (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared
|
|
15
|
+
* output, or `null` if the line doesn't contain one. Pulled out as a pure
|
|
16
|
+
* function so it can be unit-tested without spawning anything.
|
|
17
|
+
*/
|
|
18
|
+
declare function parseTrycloudflareUrl(line: string): string | null;
|
|
19
|
+
interface PrintTunnelBannerOptions {
|
|
20
|
+
/** Print an ASCII QR encoding the tunnel URL (default: true). */
|
|
21
|
+
qr?: boolean;
|
|
22
|
+
/** Sink for the banner text (default: `console.log`). Injected for testing. */
|
|
23
|
+
log?: (msg: string) => void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Print the terminal banner announcing the live tunnel: the public URL, an ASCII
|
|
27
|
+
* QR encoding it, and a one-line note that quick tunnels are ephemeral,
|
|
28
|
+
* unauthenticated and not for production. Pure w.r.t. side effects other than
|
|
29
|
+
* the injected `log` sink and `qrcode-terminal` — unit-tested.
|
|
30
|
+
*/
|
|
31
|
+
declare function printTunnelBanner(url: string, opts?: PrintTunnelBannerOptions): Promise<void>;
|
|
32
|
+
interface QuickTunnel {
|
|
33
|
+
/** The public `https://*.trycloudflare.com` URL. */
|
|
34
|
+
url: string;
|
|
35
|
+
/** Stop the underlying `cloudflared` process. Idempotent. */
|
|
36
|
+
stop: () => void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`
|
|
40
|
+
* and resolve once the public URL is known. Downloads the `cloudflared` binary
|
|
41
|
+
* on first use if it is not already installed. Rejects with a friendly error if
|
|
42
|
+
* no URL appears within {@link URL_TIMEOUT_MS}.
|
|
43
|
+
*/
|
|
44
|
+
declare function startQuickTunnel(port: number): Promise<QuickTunnel>;
|
|
45
|
+
//#endregion
|
|
46
|
+
export { PrintTunnelBannerOptions, QuickTunnel, parseTrycloudflareUrl, printTunnelBanner, startQuickTunnel };
|
|
47
|
+
//# sourceMappingURL=tunnel.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tunnel.d.cts","names":[],"sources":["../../src/unplugin/tunnel.ts"],"mappings":";;AAwBA;;;;;AAKA;;;;;;;;;AAeA;iBApBgB,qBAAA,CAAsB,IAAA;AAAA,UAKrB,wBAAA;EAkBP;EAhBR,EAAA;EAeM;EAbN,GAAA,IAAO,GAAA;AAAA;;;AA2CT;;;;iBAhCsB,iBAAA,CACpB,GAAA,UACA,IAAA,GAAM,wBAAA,GACL,OAAA;AAAA,UA6Bc,WAAA;EAeqB;EAbpC,GAAA;EAa2D;EAX3D,IAAA;AAAA;;;;;;;iBAWoB,gBAAA,CAAiB,IAAA,WAAe,OAAA,CAAQ,WAAA"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/unplugin/tunnel.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Cloudflare quick-tunnel helper for the devtools unplugin.
|
|
4
|
+
*
|
|
5
|
+
* Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is
|
|
6
|
+
* on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common
|
|
7
|
+
* case. This is the one place in `@ait-co/devtools` that depends on Node-only
|
|
8
|
+
* APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of
|
|
9
|
+
* jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the
|
|
10
|
+
* "web 모드는 e2e" rule in CLAUDE.md). The pure helpers below
|
|
11
|
+
* (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared
|
|
15
|
+
* output, or `null` if the line doesn't contain one. Pulled out as a pure
|
|
16
|
+
* function so it can be unit-tested without spawning anything.
|
|
17
|
+
*/
|
|
18
|
+
declare function parseTrycloudflareUrl(line: string): string | null;
|
|
19
|
+
interface PrintTunnelBannerOptions {
|
|
20
|
+
/** Print an ASCII QR encoding the tunnel URL (default: true). */
|
|
21
|
+
qr?: boolean;
|
|
22
|
+
/** Sink for the banner text (default: `console.log`). Injected for testing. */
|
|
23
|
+
log?: (msg: string) => void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Print the terminal banner announcing the live tunnel: the public URL, an ASCII
|
|
27
|
+
* QR encoding it, and a one-line note that quick tunnels are ephemeral,
|
|
28
|
+
* unauthenticated and not for production. Pure w.r.t. side effects other than
|
|
29
|
+
* the injected `log` sink and `qrcode-terminal` — unit-tested.
|
|
30
|
+
*/
|
|
31
|
+
declare function printTunnelBanner(url: string, opts?: PrintTunnelBannerOptions): Promise<void>;
|
|
32
|
+
interface QuickTunnel {
|
|
33
|
+
/** The public `https://*.trycloudflare.com` URL. */
|
|
34
|
+
url: string;
|
|
35
|
+
/** Stop the underlying `cloudflared` process. Idempotent. */
|
|
36
|
+
stop: () => void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`
|
|
40
|
+
* and resolve once the public URL is known. Downloads the `cloudflared` binary
|
|
41
|
+
* on first use if it is not already installed. Rejects with a friendly error if
|
|
42
|
+
* no URL appears within {@link URL_TIMEOUT_MS}.
|
|
43
|
+
*/
|
|
44
|
+
declare function startQuickTunnel(port: number): Promise<QuickTunnel>;
|
|
45
|
+
//#endregion
|
|
46
|
+
export { PrintTunnelBannerOptions, QuickTunnel, parseTrycloudflareUrl, printTunnelBanner, startQuickTunnel };
|
|
47
|
+
//# sourceMappingURL=tunnel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tunnel.d.ts","names":[],"sources":["../../src/unplugin/tunnel.ts"],"mappings":";;AAwBA;;;;;AAKA;;;;;;;;;AAeA;iBApBgB,qBAAA,CAAsB,IAAA;AAAA,UAKrB,wBAAA;EAkBP;EAhBR,EAAA;EAeM;EAbN,GAAA,IAAO,GAAA;AAAA;;;AA2CT;;;;iBAhCsB,iBAAA,CACpB,GAAA,UACA,IAAA,GAAM,wBAAA,GACL,OAAA;AAAA,UA6Bc,WAAA;EAeqB;EAbpC,GAAA;EAa2D;EAX3D,IAAA;AAAA;;;;;;;iBAWoB,gBAAA,CAAiB,IAAA,WAAe,OAAA,CAAQ,WAAA"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
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 `@ait-co/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
|
+
* Print the terminal banner announcing the live tunnel: the public URL, an ASCII
|
|
30
|
+
* QR encoding it, and a one-line note that quick tunnels are ephemeral,
|
|
31
|
+
* unauthenticated and not for production. Pure w.r.t. side effects other than
|
|
32
|
+
* the injected `log` sink and `qrcode-terminal` — unit-tested.
|
|
33
|
+
*/
|
|
34
|
+
async function printTunnelBanner(url, opts = {}) {
|
|
35
|
+
const log = opts.log ?? ((m) => console.log(m));
|
|
36
|
+
log([
|
|
37
|
+
"",
|
|
38
|
+
" ┌─ @ait-co/devtools · live tunnel ────────────────────────────",
|
|
39
|
+
` │ ${url}`,
|
|
40
|
+
" │",
|
|
41
|
+
` │ Open the launcher on your phone: ${LAUNCHER_URL}`,
|
|
42
|
+
" │ Scan the QR below from there (or paste the URL).",
|
|
43
|
+
" │ Quick tunnels are unauthenticated, change every run, and are",
|
|
44
|
+
" │ not for production use.",
|
|
45
|
+
" └──────────────────────────────────────────────────────────────",
|
|
46
|
+
""
|
|
47
|
+
].join("\n"));
|
|
48
|
+
if (opts.qr !== false) {
|
|
49
|
+
const qrcode = (await import("qrcode-terminal")).default;
|
|
50
|
+
await new Promise((resolve) => {
|
|
51
|
+
qrcode.generate(url, { small: true }, (out) => {
|
|
52
|
+
log(out);
|
|
53
|
+
resolve();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const URL_TIMEOUT_MS = 2e4;
|
|
59
|
+
/**
|
|
60
|
+
* Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`
|
|
61
|
+
* and resolve once the public URL is known. Downloads the `cloudflared` binary
|
|
62
|
+
* on first use if it is not already installed. Rejects with a friendly error if
|
|
63
|
+
* no URL appears within {@link URL_TIMEOUT_MS}.
|
|
64
|
+
*/
|
|
65
|
+
async function startQuickTunnel(port) {
|
|
66
|
+
const { bin, install, Tunnel } = await import("cloudflared");
|
|
67
|
+
if (!existsSync(bin)) {
|
|
68
|
+
await mkdir(dirname(bin), { recursive: true });
|
|
69
|
+
await install(bin);
|
|
70
|
+
}
|
|
71
|
+
const tunnel = Tunnel.quick(`http://localhost:${port}`);
|
|
72
|
+
let stopped = false;
|
|
73
|
+
const stop = () => {
|
|
74
|
+
if (stopped) return;
|
|
75
|
+
stopped = true;
|
|
76
|
+
try {
|
|
77
|
+
tunnel.stop();
|
|
78
|
+
} catch {}
|
|
79
|
+
};
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
stop();
|
|
83
|
+
reject(/* @__PURE__ */ new Error(`[@ait-co/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.`));
|
|
84
|
+
}, URL_TIMEOUT_MS);
|
|
85
|
+
const onUrl = (line) => {
|
|
86
|
+
const found = parseTrycloudflareUrl(line);
|
|
87
|
+
if (!found) return;
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
tunnel.off("stdout", onUrl);
|
|
90
|
+
tunnel.off("stderr", onUrl);
|
|
91
|
+
resolve({
|
|
92
|
+
url: found,
|
|
93
|
+
stop
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
tunnel.once("url", onUrl);
|
|
97
|
+
tunnel.on("stdout", onUrl);
|
|
98
|
+
tunnel.on("stderr", onUrl);
|
|
99
|
+
tunnel.once("error", (err) => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
stop();
|
|
102
|
+
reject(err);
|
|
103
|
+
});
|
|
104
|
+
tunnel.once("exit", (code) => {
|
|
105
|
+
if (stopped) return;
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared exited (code ${code ?? "null"}) before reporting a tunnel URL.`));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
export { parseTrycloudflareUrl, printTunnelBanner, startQuickTunnel };
|
|
113
|
+
|
|
114
|
+
//# sourceMappingURL=tunnel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tunnel.js","names":[],"sources":["../../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding it, and a one-line note that quick tunnels are ephemeral,\n * unauthenticated and not for production. Pure w.r.t. side effects other than\n * the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Open the launcher on your phone: ${LAUNCHER_URL}`,\n ' │ Scan the QR below from there (or paste the URL).',\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(url, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n const timer = setTimeout(() => {\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n resolve({ url: found, stop });\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.`,\n ),\n );\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,MAAM,mBAAmB;;;;;;AAOzB,SAAgB,sBAAsB,MAA6B;CACjE,MAAM,IAAI,KAAK,MAAM,iBAAiB;AACtC,QAAO,IAAI,EAAE,KAAK;;AAUpB,MAAM,eAAe;;;;;;;AAQrB,eAAsB,kBACpB,KACA,OAAiC,EAAE,EACpB;CACf,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;AAatD,KAZwB;EACtB;EACA;EACA,QAAQ;EACR;EACA,0CAA0C;EAC1C;EACA;EACA;EACA;EACA;EACD,CACS,KAAK,KAAK,CAAC;AAErB,KAAI,KAAK,OAAO,OAAO;EAGrB,MAAM,UAAU,MAAM,OAAO,oBAAoB;AACjD,QAAM,IAAI,SAAe,YAAY;AACnC,UAAO,SAAS,KAAK,EAAE,OAAO,MAAM,GAAG,QAAQ;AAC7C,QAAI,IAAI;AACR,aAAS;KACT;IACF;;;AAWN,MAAM,iBAAiB;;;;;;;AAQvB,eAAsB,iBAAiB,MAAoC;CAEzE,MAAM,EAAE,KAAK,SAAS,WADF,MAAM,OAAO;AAGjC,KAAI,CAAC,WAAW,IAAI,EAAE;AACpB,QAAM,MAAM,QAAQ,IAAI,EAAE,EAAE,WAAW,MAAM,CAAC;AAC9C,QAAM,QAAQ,IAAI;;CAGpB,MAAM,SAAS,OAAO,MAAM,oBAAoB,OAAO;CACvD,IAAI,UAAU;CACd,MAAM,aAAa;AACjB,MAAI,QAAS;AACb,YAAU;AACV,MAAI;AACF,UAAO,MAAM;UACP;;AAKV,QAAO,IAAI,SAAsB,SAAS,WAAW;EACnD,MAAM,QAAQ,iBAAiB;AAC7B,SAAM;AACN,0BACE,IAAI,MACF,qEACE,iBAAiB,IAClB,uFAAuF,KAAK,cAC9F,CACF;KACA,eAAe;EAElB,MAAM,SAAS,SAAiB;GAC9B,MAAM,QAAQ,sBAAsB,KAAK;AACzC,OAAI,CAAC,MAAO;AACZ,gBAAa,MAAM;AAEnB,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,MAAM;AAC3B,WAAQ;IAAE,KAAK;IAAO;IAAM,CAAC;;AAK/B,SAAO,KAAK,OAAO,MAAM;AACzB,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,KAAK,UAAU,QAAe;AACnC,gBAAa,MAAM;AACnB,SAAM;AACN,UAAO,IAAI;IACX;AACF,SAAO,KAAK,SAAS,SAAwB;AAC3C,OAAI,QAAS;AACb,gBAAa,MAAM;AACnB,0BACE,IAAI,MACF,+CAA+C,QAAQ,OAAO,kCAC/D,CACF;IACD;GACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ait-co/devtools",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "Development tools for Apps in Toss mini-apps — mock SDK, floating devtools panel, and universal bundler plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
+
"cloudflared": "^0.7.1",
|
|
43
|
+
"qrcode-terminal": "^0.12.0",
|
|
42
44
|
"unplugin": "^3.0.0"
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
@@ -48,6 +50,7 @@
|
|
|
48
50
|
"@playwright/test": "^1.59.1",
|
|
49
51
|
"@types/react": "^19.2.14",
|
|
50
52
|
"jsdom": "^29.1.1",
|
|
53
|
+
"qr-scanner": "^1.4.2",
|
|
51
54
|
"react": "^19.2.5",
|
|
52
55
|
"satori": "^0.26.0",
|
|
53
56
|
"sharp": "^0.34.5",
|
|
@@ -63,7 +66,12 @@
|
|
|
63
66
|
"mini-app",
|
|
64
67
|
"devtools",
|
|
65
68
|
"mock",
|
|
66
|
-
"sdk"
|
|
69
|
+
"sdk",
|
|
70
|
+
"miniapp",
|
|
71
|
+
"simulator",
|
|
72
|
+
"testing",
|
|
73
|
+
"vite-plugin",
|
|
74
|
+
"webpack-plugin"
|
|
67
75
|
],
|
|
68
76
|
"license": "BSD-3-Clause",
|
|
69
77
|
"publishConfig": {
|
|
@@ -73,7 +81,7 @@
|
|
|
73
81
|
"type": "git",
|
|
74
82
|
"url": "https://github.com/apps-in-toss-community/devtools"
|
|
75
83
|
},
|
|
76
|
-
"homepage": "https://
|
|
84
|
+
"homepage": "https://devtools.aitc.dev/",
|
|
77
85
|
"bugs": {
|
|
78
86
|
"url": "https://github.com/apps-in-toss-community/devtools/issues"
|
|
79
87
|
},
|