diff --git a/packages/app/package.json b/packages/app/package.json index db14a4ebb6a..3e703337d2f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -62,7 +62,6 @@ "esbuild": "0.27.4", "graphql-request": "6.1.0", "h3": "1.15.9", - "http-proxy-node16": "1.0.6", "ignore": "6.0.2", "json-schema-to-typescript": "15.0.4", "prettier": "3.8.1", diff --git a/packages/app/src/cli/utilities/app/http-proxy.test.ts b/packages/app/src/cli/utilities/app/http-proxy.test.ts new file mode 100644 index 00000000000..e0ca7eab9bf --- /dev/null +++ b/packages/app/src/cli/utilities/app/http-proxy.test.ts @@ -0,0 +1,164 @@ +import {createProxyServer} from './http-proxy.js' +import {getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' +import {describe, test, expect, afterEach} from 'vitest' +import fetch from 'node-fetch' +import http from 'http' + +const servers: http.Server[] = [] + +function listen(server: http.Server, port: number): Promise { + servers.push(server) + return new Promise((resolve) => server.listen(port, 'localhost', resolve)) +} + +afterEach(async () => { + const toClose = [...servers] + servers.splice(0, servers.length) + await Promise.all( + toClose.map((server) => { + server.closeAllConnections() + return new Promise((resolve) => server.close(() => resolve())) + }), + ) +}) + +describe('createProxyServer', () => { + describe('web', () => { + test('forwards GET request and returns response', async () => { + const targetPort = await getAvailableTCPPort() + const proxyPort = await getAvailableTCPPort() + + const target = http.createServer((_req, res) => { + res.writeHead(200, {'content-type': 'text/plain'}) + res.end('hello from target') + }) + await listen(target, targetPort) + + const proxy = createProxyServer() + const server = http.createServer((req, res) => { + proxy.web(req, res, {target: `http://localhost:${targetPort}`}) + }) + await listen(server, proxyPort) + + const res = await fetch(`http://localhost:${proxyPort}/test`) + expect(res.status).toBe(200) + await expect(res.text()).resolves.toBe('hello from target') + }) + + test('forwards POST request with body', async () => { + const targetPort = await getAvailableTCPPort() + const proxyPort = await getAvailableTCPPort() + + const target = http.createServer((req, res) => { + let body = '' + req.on('data', (chunk) => { + body += chunk + }) + req.on('end', () => { + res.writeHead(200, {'content-type': 'text/plain'}) + res.end(`received: ${body}`) + }) + }) + await listen(target, targetPort) + + const proxy = createProxyServer() + const server = http.createServer((req, res) => { + proxy.web(req, res, {target: `http://localhost:${targetPort}`}) + }) + await listen(server, proxyPort) + + const res = await fetch(`http://localhost:${proxyPort}/test`, { + method: 'POST', + body: 'test payload', + }) + await expect(res.text()).resolves.toBe('received: test payload') + }) + + test('preserves response headers', async () => { + const targetPort = await getAvailableTCPPort() + const proxyPort = await getAvailableTCPPort() + + const target = http.createServer((_req, res) => { + res.writeHead(200, {'x-custom-header': 'custom-value', 'content-type': 'text/plain'}) + res.end('ok') + }) + await listen(target, targetPort) + + const proxy = createProxyServer() + const server = http.createServer((req, res) => { + proxy.web(req, res, {target: `http://localhost:${targetPort}`}) + }) + await listen(server, proxyPort) + + const res = await fetch(`http://localhost:${proxyPort}/`) + expect(res.headers.get('x-custom-header')).toBe('custom-value') + }) + + test('calls error callback when target is unreachable', async () => { + const proxyPort = await getAvailableTCPPort() + const deadPort = await getAvailableTCPPort() + + const proxy = createProxyServer() + const errors: Error[] = [] + const server = http.createServer((req, res) => { + proxy.web(req, res, {target: `http://localhost:${deadPort}`}, (err) => { + errors.push(err) + }) + }) + await listen(server, proxyPort) + + const res = await fetch(`http://localhost:${proxyPort}/`) + expect(res.status).toBe(502) + expect(errors.length).toBe(1) + expect((errors[0] as NodeJS.ErrnoException).code).toBe('ECONNREFUSED') + }) + + test('forwards request path to target', async () => { + const targetPort = await getAvailableTCPPort() + const proxyPort = await getAvailableTCPPort() + + const target = http.createServer((req, res) => { + res.writeHead(200) + res.end(req.url) + }) + await listen(target, targetPort) + + const proxy = createProxyServer() + const server = http.createServer((req, res) => { + proxy.web(req, res, {target: `http://localhost:${targetPort}`}) + }) + await listen(server, proxyPort) + + const res = await fetch(`http://localhost:${proxyPort}/some/path?q=1`) + await expect(res.text()).resolves.toBe('/some/path?q=1') + }) + }) + + describe('ws', () => { + test('calls error callback when target is unreachable', async () => { + const proxyPort = await getAvailableTCPPort() + const deadPort = await getAvailableTCPPort() + + const proxy = createProxyServer() + const errors: Error[] = [] + const server = http.createServer() + server.on('upgrade', (req, socket, head) => { + proxy.ws(req, socket as import('net').Socket, head, {target: `http://localhost:${deadPort}`}, (err) => { + errors.push(err) + }) + }) + await listen(server, proxyPort) + + await new Promise((resolve) => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const {WebSocket} = require('ws') + const ws = new WebSocket(`ws://localhost:${proxyPort}`) + ws.on('error', () => resolve()) + ws.on('open', () => resolve()) + }) + + expect(errors.length).toBe(1) + expect((errors[0] as NodeJS.ErrnoException).code).toBe('ECONNREFUSED') + }) + }) +}) diff --git a/packages/app/src/cli/utilities/app/http-proxy.ts b/packages/app/src/cli/utilities/app/http-proxy.ts new file mode 100644 index 00000000000..0f6f1b0ee7c --- /dev/null +++ b/packages/app/src/cli/utilities/app/http-proxy.ts @@ -0,0 +1,145 @@ +import * as http from 'http' +import * as https from 'https' +import * as net from 'net' + +export interface ProxyServer { + web( + req: http.IncomingMessage, + res: http.ServerResponse, + options: {target: string}, + callback?: (err: Error) => void, + ): void + ws( + req: http.IncomingMessage, + socket: net.Socket, + head: Buffer, + options: {target: string}, + callback?: (err: Error) => void, + ): void +} + +/** + * Creates a lightweight reverse proxy server that supports HTTP and WebSocket forwarding. + * + * @returns A proxy server with web() and ws() methods. + */ +export function createProxyServer(): ProxyServer { + return {web, ws} +} + +function web( + req: http.IncomingMessage, + res: http.ServerResponse, + options: {target: string}, + callback?: (err: Error) => void, +): void { + const target = new URL(options.target) + const isSecure = target.protocol === 'https:' + + const outgoing: http.RequestOptions = { + hostname: target.hostname, + port: target.port || (isSecure ? 443 : 80), + path: req.url, + method: req.method, + headers: {...req.headers, connection: 'close'}, + agent: false, + } + + const transport = isSecure ? https : http + const proxyReq = transport.request(outgoing, (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers) + proxyRes.pipe(res) + }) + + proxyReq.on('error', (err) => { + if (callback) { + callback(err) + } + if (!res.headersSent) { + res.writeHead(502) + } + res.end() + }) + + req.pipe(proxyReq) +} + +function ws( + req: http.IncomingMessage, + socket: net.Socket, + head: Buffer, + options: {target: string}, + callback?: (err: Error) => void, +): void { + const target = new URL(options.target) + const isSecure = target.protocol === 'https:' + + socket.setTimeout(0) + socket.setNoDelay(true) + socket.setKeepAlive(true, 0) + + if (head && head.length) { + socket.unshift(head) + } + + const outgoing: http.RequestOptions = { + hostname: target.hostname, + port: target.port || (isSecure ? 443 : 80), + path: req.url, + method: req.method, + headers: {...req.headers}, + agent: false, + } + + const transport = isSecure ? https : http + const proxyReq = transport.request(outgoing) + + proxyReq.on('error', (err) => { + if (callback) { + callback(err) + } + socket.end() + }) + + proxyReq.on('response', (proxyRes) => { + if (!(proxyRes as http.IncomingMessage & {upgrade?: boolean}).upgrade) { + const headers = Object.entries(proxyRes.headers) + .map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(', ') : val}`) + .join('\r\n') + socket.write( + `HTTP/${proxyRes.httpVersion} ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n${headers}\r\n\r\n`, + ) + proxyRes.pipe(socket) + } + }) + + proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => { + proxySocket.on('error', (err) => { + if (callback) { + callback(err) + } + socket.end() + }) + + socket.on('error', () => { + proxySocket.destroy() + }) + + proxySocket.setTimeout(0) + proxySocket.setNoDelay(true) + proxySocket.setKeepAlive(true, 0) + + if (proxyHead && proxyHead.length) { + proxySocket.unshift(proxyHead) + } + + const headers = Object.entries(proxyRes.headers) + .map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(', ') : val}`) + .join('\r\n') + socket.write(`HTTP/1.1 101 Switching Protocols\r\n${headers}\r\n\r\n`) + + proxySocket.pipe(socket).pipe(proxySocket) + }) + + proxyReq.end() +} diff --git a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts index 9304bab8e84..f4ae3f54e18 100644 --- a/packages/app/src/cli/utilities/app/http-reverse-proxy.ts +++ b/packages/app/src/cli/utilities/app/http-reverse-proxy.ts @@ -1,10 +1,11 @@ +import {createProxyServer} from './http-proxy.js' import {AbortController} from '@shopify/cli-kit/node/abort' import {outputDebug, outputContent, outputToken, outputWarn} from '@shopify/cli-kit/node/output' import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' import * as http from 'http' import * as https from 'https' import {Writable} from 'stream' -import type Server from 'http-proxy-node16' +import type {ProxyServer} from './http-proxy.js' function isAggregateError(err: Error): err is Error & {errors: Error[]} { return 'errors' in err && Array.isArray((err as {errors?: unknown}).errors) @@ -22,10 +23,7 @@ export async function getProxyingWebServer( localhostCert?: LocalhostCert, stdout?: Writable, ) { - // Lazy-importing it because it's CJS and we don't want it - // to block the loading of the ESM module graph. - const httpProxy = await import('http-proxy-node16') - const proxy = httpProxy.default.createProxyServer() + const proxy = createProxyServer() const requestListener = getProxyServerRequestListener(rules, proxy, stdout) @@ -43,13 +41,13 @@ export async function getProxyingWebServer( function getProxyServerWebsocketUpgradeListener( rules: {[key: string]: string}, - proxy: Server, + proxy: ProxyServer, stdout?: Writable, ): (req: http.IncomingMessage, socket: import('stream').Duplex, head: Buffer) => void { return function (req, socket, head) { const target = match(rules, req, true) if (target) { - return proxy.ws(req, socket, head, {target}, (err) => { + return proxy.ws(req, socket as import('net').Socket, head, {target}, (err) => { useConcurrentOutputContext({outputPrefix: 'proxy', stripAnsi: false}, () => { const lastError = isAggregateError(err) ? err.errors[err.errors.length - 1] : undefined const error = lastError ?? err @@ -64,7 +62,7 @@ function getProxyServerWebsocketUpgradeListener( function getProxyServerRequestListener( rules: {[key: string]: string}, - proxy: Server, + proxy: ProxyServer, stdout?: Writable, ): http.RequestListener | undefined { return function (req, res) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 421355c50ea..43ce79abff3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,9 +190,6 @@ importers: h3: specifier: 1.15.9 version: 1.15.9 - http-proxy-node16: - specifier: 1.0.6 - version: 1.0.6 ignore: specifier: 6.0.2 version: 6.0.2 @@ -5511,9 +5508,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -6007,10 +6001,6 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} - http-proxy-node16@1.0.6: - resolution: {integrity: sha512-RLtYkbmbLmh+To4lmqLpiEitu0igXb/j1SOW8F6w/esXsapGT5dSShMF9vg7shtxGJSXaWiFE3wYWCoLOuiibg==} - engines: {node: '>=8.0.0'} - http2-wrapper@2.2.1: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} @@ -7673,9 +7663,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -15028,8 +15015,6 @@ snapshots: esutils@2.0.3: {} - eventemitter3@4.0.7: {} - eventemitter3@5.0.4: {} execa@7.2.0: @@ -15624,14 +15609,6 @@ snapshots: transitivePeerDependencies: - supports-color - http-proxy-node16@1.0.6: - dependencies: - eventemitter3: 4.0.7 - follow-redirects: 1.15.11 - requires-port: 1.0.0 - transitivePeerDependencies: - - debug - http2-wrapper@2.2.1: dependencies: quick-lru: 5.1.1 @@ -17427,8 +17404,6 @@ snapshots: require-from-string@2.0.2: {} - requires-port@1.0.0: {} - resolve-alpn@1.2.1: {} resolve-from@4.0.0: {}