Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
164 changes: 164 additions & 0 deletions packages/app/src/cli/utilities/app/http-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void>((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<void>((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')
})
})
})
145 changes: 145 additions & 0 deletions packages/app/src/cli/utilities/app/http-proxy.ts
Original file line number Diff line number Diff line change
@@ -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()
}
14 changes: 6 additions & 8 deletions packages/app/src/cli/utilities/app/http-reverse-proxy.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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) {
Expand Down
Loading
Loading