diff --git a/src/serve/commands/index.test.ts b/src/serve/commands/index.test.ts index 5553f83..b2c955a 100644 --- a/src/serve/commands/index.test.ts +++ b/src/serve/commands/index.test.ts @@ -21,17 +21,27 @@ describe("serveCommand", () => { port: "8000", proxyType: "local-proxy", localProxyPort: "9001", + localProxyInspectorPort: "9223", ngrokCommand: "ngrok", inspect: true, }; const cwd = process.cwd(); - const proxy = new LocalProxy({ - keyPath: path.resolve(cwd, "localhost-key.pem"), - certPath: path.resolve(cwd, "localhost.pem"), + const keyPath = path.resolve(cwd, "localhost-key.pem"); + const certPath = path.resolve(cwd, "localhost.pem"); + + const liffAppProxy = new LocalProxy({ + keyPath, + certPath, port: "9001", }); + const liffInspectorProxy = new LocalProxy({ + keyPath, + certPath, + port: "9223", + }); + const command = installServeCommands(program); await command.parseAsync([ "_", @@ -46,6 +56,8 @@ describe("serveCommand", () => { options.port, "--local-proxy-port", options.localProxyPort, + "--local-proxy-inspector-port", + options.localProxyInspectorPort, "--inspect", "--proxy-type", options.proxyType, @@ -53,6 +65,10 @@ describe("serveCommand", () => { options.ngrokCommand, ]); - expect(serveAction).toHaveBeenCalledWith(options, proxy); + expect(serveAction).toHaveBeenCalledWith( + options, + liffAppProxy, + liffInspectorProxy, + ); }); }); diff --git a/src/serve/commands/index.ts b/src/serve/commands/index.ts index 0d4a99f..264f825 100644 --- a/src/serve/commands/index.ts +++ b/src/serve/commands/index.ts @@ -27,14 +27,19 @@ export const installServeCommands = (program: Command) => { "The port number of the application proxy server to listen on when running the CLI.", "9000", ) + .option( + "--local-proxy-inspector-port ", + "The port number of the inspector proxy server to listen on when running the CLI.", + "9223", + ) .option( "--ngrok-command ", "The command to run ngrok.", "ngrok", ) .action(async (options) => { - const proxy = resolveProxy(options); - await serveAction(options, proxy); + const { liffAppProxy, liffInspectorProxy } = resolveProxy(options); + await serveAction(options, liffAppProxy, liffInspectorProxy); }); return serve; }; diff --git a/src/serve/proxy/local-proxy.ts b/src/serve/proxy/local-proxy.ts index e23739e..1fa1b5e 100644 --- a/src/serve/proxy/local-proxy.ts +++ b/src/serve/proxy/local-proxy.ts @@ -28,6 +28,18 @@ export class LocalProxy implements ProxyInterface { } async connect(targetUrl: URL): Promise { + if (targetUrl.protocol === "http:") { + return this.connectHttp(targetUrl); + } + + if (targetUrl.protocol === "ws:") { + return this.connectWs(targetUrl); + } + + throw new Error(`Unsupported protocol: ${targetUrl.protocol}`); + } + + private async connectHttp(targetUrl: URL): Promise { const proxy = httpProxy.createProxyServer({}); const proxyUrl = new URL("https://localhost"); @@ -55,8 +67,33 @@ export class LocalProxy implements ProxyInterface { }); } - async cleanup(): Promise { + private async connectWs(targetUrl: URL): Promise { + const proxy = httpProxy.createProxyServer({ target: targetUrl.toString() }); + + const proxyUrl = new URL("wss://localhost"); + proxyUrl.port = this.port; + return new Promise((resolve, reject) => { + this.server = https + .createServer({ key: this.key, cert: this.cert }, (req, res) => { + proxy.web(req, res); + }) + .listen(this.port) + .on("listening", () => { + resolve(proxyUrl); + }) + .on("upgrade", function (req, socket, head) { + req.headers["x-forwarded-proto"] = "https"; + proxy.ws(req, socket, head); + }) + .on("error", (e) => { + reject(e); + }); + }); + } + + async cleanup(): Promise { + return new Promise((resolve, reject) => { if (!this.server) { resolve(); return; @@ -65,6 +102,8 @@ export class LocalProxy implements ProxyInterface { if (e) reject(e); else resolve(); }); + }).finally(() => { + this.server = undefined; }); } } diff --git a/src/serve/resolveProxy.test.ts b/src/serve/resolveProxy.test.ts index d1e565c..38472bf 100644 --- a/src/serve/resolveProxy.test.ts +++ b/src/serve/resolveProxy.test.ts @@ -8,26 +8,35 @@ describe("resolveProxy", () => { const options = { proxyType: "local-proxy", localProxyPort: "9000", + localProxyInspectorPort: "9223", ngrokCommand: "ngrok", }; - expect(resolveProxy(options)).toBeInstanceOf(LocalProxy); + expect(resolveProxy(options)).toEqual({ + liffAppProxy: expect.any(LocalProxy), + liffInspectorProxy: expect.any(LocalProxy), + }); }); it("should return NgrokV1Proxy when proxyType is ngrok-v1", () => { const options = { proxyType: "ngrok-v1", localProxyPort: "9000", + localProxyInspectorPort: "9223", ngrokCommand: "ngrok", }; - expect(resolveProxy(options)).toBeInstanceOf(NgrokV1Proxy); + expect(resolveProxy(options)).toEqual({ + liffAppProxy: expect.any(NgrokV1Proxy), + liffInspectorProxy: expect.any(NgrokV1Proxy), + }); }); it("should throw an error when an unknown proxyType is specified", () => { const options = { proxyType: "unknown", localProxyPort: "9000", + localProxyInspectorPort: "9223", ngrokCommand: "ngrok", }; diff --git a/src/serve/resolveProxy.ts b/src/serve/resolveProxy.ts index 62f6cb8..56252f7 100644 --- a/src/serve/resolveProxy.ts +++ b/src/serve/resolveProxy.ts @@ -7,23 +7,40 @@ import { ProxyInterface } from "./proxy/proxy-interface.js"; type Options = { proxyType: string; localProxyPort: string; + localProxyInspectorPort: string; ngrokCommand: string; }; -export const resolveProxy = (options: Options): ProxyInterface => { +export const resolveProxy = ( + options: Options, +): { liffAppProxy: ProxyInterface; liffInspectorProxy: ProxyInterface } => { if (options.proxyType === "local-proxy") { const cwd = process.cwd(); - return new LocalProxy({ - keyPath: path.resolve(cwd, "localhost-key.pem"), - certPath: path.resolve(cwd, "localhost.pem"), - port: options.localProxyPort, - }); + const keyPath = path.resolve(cwd, "localhost-key.pem"); + const certPath = path.resolve(cwd, "localhost.pem"); + return { + liffAppProxy: new LocalProxy({ + keyPath, + certPath, + port: options.localProxyPort, + }), + liffInspectorProxy: new LocalProxy({ + keyPath, + certPath, + port: options.localProxyInspectorPort, + }), + }; } if (options.proxyType === "ngrok-v1") { - return new NgrokV1Proxy({ - ngrokCommand: options.ngrokCommand, - }); + return { + liffAppProxy: new NgrokV1Proxy({ + ngrokCommand: options.ngrokCommand, + }), + liffInspectorProxy: new NgrokV1Proxy({ + ngrokCommand: options.ngrokCommand, + }), + }; } throw new Error(`Unknown proxy type: ${options.proxyType}`); diff --git a/src/serve/serveAction.test.ts b/src/serve/serveAction.test.ts index fd3d9d5..c05d3dc 100644 --- a/src/serve/serveAction.test.ts +++ b/src/serve/serveAction.test.ts @@ -18,6 +18,22 @@ vi.mock("../api/liff.js", () => { vi.mock("../channel/resolveChannel.js"); vi.mock("node:child_process"); +const cwd = process.cwd(); +const keyPath = path.resolve(cwd, "localhost-key.pem"); +const certPath = path.resolve(cwd, "localhost.pem"); + +const liffAppProxy = new LocalProxy({ + keyPath, + certPath, + port: "9000", +}); + +const liffInspectorProxy = new LocalProxy({ + keyPath, + certPath, + port: "9223", +}); + describe("serveAction", () => { let mockUpdateApp: LiffApiClient["updateApp"]; @@ -33,14 +49,16 @@ describe("serveAction", () => { mockUpdateApp = liffApiClientInstance.updateApp; }); - afterEach(() => { + afterEach(async () => { + await liffAppProxy.cleanup(); + await liffInspectorProxy.cleanup(); vi.restoreAllMocks(); }); it("should update a LIFF app successfully when the --url option is specified", async () => { const options = { liffId: "123-xxx", - url: "https://example.com", + url: "http://example.com", localProxyPort: "9000", }; @@ -52,16 +70,7 @@ describe("serveAction", () => { }); vi.mocked(mockUpdateApp).mockResolvedValueOnce(); - const cwd = process.cwd(); - const proxy = new LocalProxy({ - keyPath: path.resolve(cwd, "localhost-key.pem"), - certPath: path.resolve(cwd, "localhost.pem"), - port: "9000", - }); - - await serveAction(options, proxy); - - await proxy.cleanup(); + await serveAction(options, liffAppProxy, liffInspectorProxy); expect(mockUpdateApp).toHaveBeenCalledWith(options.liffId, { view: { url: "https://localhost:9000/" }, @@ -74,7 +83,7 @@ describe("serveAction", () => { it("should update a LIFF app successfully with liff-inspector when the --inspect option is specified", async () => { const options = { liffId: "123-xxx", - url: "https://example.com?hoge=fuga", + url: "http://example.com?hoge=fuga", inspect: true, localProxyPort: "9000", }; @@ -87,33 +96,16 @@ describe("serveAction", () => { }); vi.mocked(mockUpdateApp).mockResolvedValueOnce(); - const cwd = process.cwd(); - const proxy = new LocalProxy({ - keyPath: path.resolve(cwd, "localhost-key.pem"), - certPath: path.resolve(cwd, "localhost.pem"), - port: "9000", - }); - - await serveAction(options, proxy); - - await proxy.cleanup(); + await serveAction(options, liffAppProxy, liffInspectorProxy); - expect(spawn).toHaveBeenCalledWith( - "npx", - [ - "@line/liff-inspector", - "--key=./localhost-key.pem", - "--cert=./localhost.pem", - ], - { - shell: true, - stdio: "inherit", - }, - ); + expect(spawn).toHaveBeenCalledWith("npx", ["@line/liff-inspector"], { + shell: true, + stdio: "inherit", + }); expect(mockUpdateApp).toHaveBeenCalledWith(options.liffId, { view: { - url: "https://localhost:9000/?hoge=fuga&li.origin=wss%3A%2F%2Flocalhost%3A9222", + url: "https://localhost:9000/?hoge=fuga&li.origin=wss%3A%2F%2Flocalhost%3A9223%2F", }, }); }); @@ -121,33 +113,24 @@ describe("serveAction", () => { it("should handle channel not found", async () => { const options = { liffId: "123-xxx", - url: "https://example.com", + url: "http://example.com", localProxyPort: "9000", }; vi.mocked(resolveChannel).mockResolvedValueOnce(undefined); - const cwd = process.cwd(); - const proxy = new LocalProxy({ - keyPath: path.resolve(cwd, "localhost-key.pem"), - certPath: path.resolve(cwd, "localhost.pem"), - port: "9000", - }); - - await expect(serveAction(options, proxy)).rejects + await expect(serveAction(options, liffAppProxy, liffInspectorProxy)).rejects .toThrow(`Access token not found. Please set the current channel first. `); - await proxy.cleanup(); - expect(mockUpdateApp).not.toHaveBeenCalled(); }); it("Should not update a LIFF app when --url, --host, and --port are specified", async () => { const options = { liffId: "123-xxx", - url: "https://example.com", + url: "http://example.com", host: "localhost", port: "8080", localProxyPort: "9000", @@ -161,19 +144,12 @@ describe("serveAction", () => { }); vi.mocked(mockUpdateApp).mockResolvedValueOnce(); - const cwd = process.cwd(); - const proxy = new LocalProxy({ - keyPath: path.resolve(cwd, "localhost-key.pem"), - certPath: path.resolve(cwd, "localhost.pem"), - port: "9000", - }); - - await expect(serveAction(options, proxy)).rejects.toThrow( + await expect( + serveAction(options, liffAppProxy, liffInspectorProxy), + ).rejects.toThrow( "When --url is specified, --host, and --port cannot be specified.", ); - await proxy.cleanup(); - expect(mockUpdateApp).not.toHaveBeenCalled(); }); }); diff --git a/src/serve/serveAction.ts b/src/serve/serveAction.ts index a88732a..bbc16e4 100644 --- a/src/serve/serveAction.ts +++ b/src/serve/serveAction.ts @@ -6,6 +6,21 @@ import { ProxyInterface } from "./proxy/proxy-interface.js"; import resolveEndpointUrl from "./resolveEndpointUrl.js"; import pc from "picocolors"; +const setupLiffInspector = async (liffInspectorProxy: ProxyInterface) => { + const LIFF_INSPECTOR_DEFAULT_PORT = "9222"; + const liffInspectorUrl = new URL("ws://localhost"); + liffInspectorUrl.port = LIFF_INSPECTOR_DEFAULT_PORT; + + spawn("npx", ["@line/liff-inspector"], { + shell: true, + stdio: "inherit", + }); + + const wssUrl = await liffInspectorProxy.connect(liffInspectorUrl); + + return wssUrl; +}; + export const serveAction = async ( options: { liffId: string; @@ -15,7 +30,8 @@ export const serveAction = async ( inspect?: boolean; localProxyPort: string; }, - proxy: ProxyInterface, + liffAppProxy: ProxyInterface, + liffInspectorProxy: ProxyInterface, ) => { const accessToken = (await resolveChannel(getCurrentChannelId())) ?.accessToken; @@ -31,23 +47,11 @@ export const serveAction = async ( port: options.port, }); - if (options.inspect) { - spawn( - "npx", - [ - "@line/liff-inspector", - "--key=./localhost-key.pem", - "--cert=./localhost.pem", - ], - { - shell: true, - stdio: "inherit", - }, - ); - endpointUrl.searchParams.set("li.origin", "wss://localhost:9222"); - } + const wssUrl = options.inspect + ? await setupLiffInspector(liffInspectorProxy) + : undefined; + const httpsUrl = await liffAppProxy.connect(endpointUrl); - const httpsUrl = await proxy.connect(endpointUrl); const liffUrl = new URL("https://liff.line.me/"); liffUrl.pathname = options.liffId; @@ -55,6 +59,10 @@ export const serveAction = async ( token: accessToken, baseUrl: "https://api.line.me", }); + if (wssUrl) { + httpsUrl.searchParams.set("li.origin", wssUrl.toString()); + } + await client.updateApp(options.liffId, { view: { url: httpsUrl.toString() }, }); diff --git a/src/setup.test.ts b/src/setup.test.ts index bb55c8d..875d59d 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -100,22 +100,16 @@ Commands: Manage HTTPS dev server Options: - -l, --liff-id The LIFF id that the user wants to - update. - -u, --url The local URL of the LIFF app. - --host The host of the application server. - --port The port number of the application - server. - -i, --inspect The flag indicates LIFF app starts on - debug mode. (default: false) - --proxy-type The type of proxy to use. local-proxy or - ngrok-v1 (default: "local-proxy") - --local-proxy-port The port number of the application proxy - server to listen on when running the - CLI. (default: "9000") - --ngrok-command The command to run ngrok. (default: - "ngrok") - -h, --help display help for command + -l, --liff-id The LIFF id that the user wants to update. + -u, --url The local URL of the LIFF app. + --host The host of the application server. + --port The port number of the application server. + -i, --inspect The flag indicates LIFF app starts on debug mode. (default: false) + --proxy-type The type of proxy to use. local-proxy or ngrok-v1 (default: "local-proxy") + --local-proxy-port The port number of the application proxy server to listen on when running the CLI. (default: "9000") + --local-proxy-inspector-port The port number of the inspector proxy server to listen on when running the CLI. (default: "9223") + --ngrok-command The command to run ngrok. (default: "ngrok") + -h, --help display help for command `); }); });