From 8f3c3143b35b5781b11a8090fdedb43dd42ddfc5 Mon Sep 17 00:00:00 2001 From: pillsilly Date: Thu, 14 May 2026 16:46:10 +0800 Subject: [PATCH 1/9] complex_proxy --- README.md | 15 +- package.json | 3 +- src/bin.ts | 39 +++- src/run-https.ts | 4 +- src/run.ts | 276 +++++++++++++++++++----- test/bin.spec.ts | 53 +++-- test/test.spec.ts | 539 ++++++++++++++++++++++++++++++++++------------ 7 files changed, 713 insertions(+), 216 deletions(-) diff --git a/README.md b/README.md index 216c889..c00df60 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ ## General introduction -> A command line tool to serve local directory with http protocol +> A command line tool to serve local directories, or to run a static-first reverse proxy with optional HTTPS ## Installation @@ -20,12 +20,23 @@ Options: -d --dir [dir] Dir to serve (default: "/home/frank/code/InstantHttp") -pt --proxyTarget [proxyTarget] Where the delegated communication targets to -pp --proxyPattern [proxyPattern] URL matcher to be used to identify which url to proxy + --proxyStaticFileWise Serve static files first, then proxy everything else + --https Enable HTTPS listener + --httpsKey [httpsKey] HTTPS private key file + --httpsCert [httpsCert] HTTPS certificate file -m --mode [mode] Which mode to use (default: "NORMAL") -i --indexFile [indexFile] Index File location(relative to --dir) (default: "index.html") -q --quiet [quiet] Set it to false to see more debug outputs (default: false) -h, --help display help for command ``` +### Proxy notes + +- `--proxyStaticFileWise` reuses `--proxyTarget`. +- `--proxyStaticFileWise` is mutually exclusive with `--proxyPattern`. +- `--https` uses the existing `server.key` and `server.cert` in the package root unless `--httpsKey` / `--httpsCert` are provided. +- In `--proxyStaticFileWise` mode, `/` is served through `--indexFile`. + ## Usages ### MJS/TS @@ -40,7 +51,7 @@ const {run} = require('instantly_http'); ### As a binary ```bash -./instantHttp --port=8080 --proxyTarget=http://google.com --proxyPattern=/proxy +./instantHttp --port=8080 --proxyStaticFileWise --proxyTarget=http://127.0.0.1:4431 --https ``` ## Build for portable binary diff --git a/package.json b/package.json index 01bd101..91ca71c 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,8 @@ "release-it": "20.0.1", "supertest": "7.2.2", "tsup": "8.5.1", - "typescript": "6.0.3" + "typescript": "6.0.3", + "ws": "8.20.1" }, "overrides": { "basic-ftp": "5.3.0", diff --git a/src/bin.ts b/src/bin.ts index 96e2293..c62faf7 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,12 +1,12 @@ #!/usr/bin/env node -import { MODE, run } from './run'; -import { Command } from 'commander'; +import {MODE, run} from './run' +import {Command} from 'commander' -import pkgJson from '../package.json'; +import pkgJson from '../package.json' -export function getOptions (): any { - const program = new Command(); +export function getOptions(): any { + const program = new Command() program .name('instant_http ') .version(pkgJson.version) @@ -27,6 +27,10 @@ export function getOptions (): any { '-P, --proxyPattern [proxyPattern]', 'URL matcher to be used to identify which url to proxy' ) + .option('--proxyStaticFileWise', 'Serve static files first and proxy everything else') + .option('--https', 'Enable HTTPS listener') + .option('--httpsKey [httpsKey]', 'HTTPS private key file') + .option('--httpsCert [httpsCert]', 'HTTPS certificate file') .option('-m, --mode [mode]', 'Which mode to use', MODE.NORMAL) .option( '-i, --indexFile [indexFile]', @@ -37,13 +41,26 @@ export function getOptions (): any { '-q, --quiet [quiet]', 'Set it to false to see more debug outputs', false - ); + ) // Parse only args from node onwards, skip jest-specific args - program.parse(process.argv, { from: 'user' }); - const opts = program.opts(); - console.info(opts); - return opts; + program.parse(process.argv, {from: 'user'}) + const opts = program.opts() + console.info(opts) + return opts +} + +export function main() { + try { + run(getOptions()) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + process.exit(1) + } +} + +if (require.main === module) { + main() } -run(getOptions()); diff --git a/src/run-https.ts b/src/run-https.ts index e98f038..b303701 100644 --- a/src/run-https.ts +++ b/src/run-https.ts @@ -18,6 +18,6 @@ app.get('/:filename', cors(), function (req: Request, res: Response, _next: Next }); https.createServer({ - key: fs.readFileSync('server.key'), - cert: fs.readFileSync('server.cert') + key: fs.readFileSync(path.resolve(__dirname, '..', 'server.key')), + cert: fs.readFileSync(path.resolve(__dirname, '..', 'server.cert')) }, app).listen(9078); diff --git a/src/run.ts b/src/run.ts index b1a3a09..153a6da 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,58 +1,129 @@ import path from 'path' -import express, {Request} from 'express'; +import http from 'http' +import https from 'https' +import express, {Request} from 'express' import cors from 'cors' import fs from 'fs' - import compression from 'compression' -import { version } from '../package.json' +import {createProxyMiddleware} from 'http-proxy-middleware' + +import {version} from '../package.json' export const MODE = { NORMAL: 'NORMAL', - SPA: 'SPA' -} + SPA: 'SPA', + PROXY_STATIC_FILE_WISE: 'PROXY_STATIC_FILE_WISE' +} as const + +type Mode = typeof MODE[keyof typeof MODE] interface CliArg { - port: string - dir: string - mode: string - indexFile: string - quiet: boolean - proxyTarget: string - proxyPattern: string + port?: string + dir?: string + mode?: Mode | string + indexFile?: string + quiet?: boolean + proxyTarget?: string + proxyPattern?: string + proxyStaticFileWise?: boolean + https?: boolean + httpsKey?: string + httpsCert?: string } -const defaultArguments: CliArg = { +const defaultArguments = { port: '9090', dir: process.cwd(), mode: MODE.NORMAL, indexFile: 'index.html', quiet: true, proxyTarget: '', - proxyPattern: '' -}; -export function run (parameters: CliArg) { - parameters = Object.assign({ ...defaultArguments }, parameters) - const { port, dir, proxyTarget, proxyPattern, mode, indexFile, quiet } = - parameters + proxyPattern: '', + proxyStaticFileWise: false, + https: false, + httpsKey: '', + httpsCert: '' +} + +const PROXY_FINGERPRINT_HEADERS = ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'via'] as const +let uncaughtExceptionRegistered = false + +export function run(parameters: CliArg) { + const resolved = Object.assign({ ...defaultArguments }, parameters) + const port = parsePort(resolved.port) + + validateArguments(resolved) + console.log(`Version: ${version}`) + const app = express() - if (proxyTarget && proxyPattern) { - const { createProxyMiddleware } = require('http-proxy-middleware') + const proxyMode = resolved.proxyStaticFileWise ? MODE.PROXY_STATIC_FILE_WISE : resolved.mode + + let server: http.Server | https.Server + + if (proxyMode === MODE.PROXY_STATIC_FILE_WISE) { + server = createProxyStaticFirstServer(app, resolved, port) + } else { + server = createLegacyServer(app, resolved, port) + } + + if (!uncaughtExceptionRegistered) { + process.on('uncaughtException', function (err: any) { + if (err.code === 'EACCES') { + console.log( + 'EACCES error(lack of permission), use "run as Administrator" when you try to start the program' + ) + } else { + console.log('Caught exception: ', err) + } + }) + uncaughtExceptionRegistered = true + } + + return server +} + +function parsePort(port: string | number | undefined): number { + const parsed = Number(port ?? defaultArguments.port) + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid port: ${String(port)}`) + } + + return parsed +} + +function validateArguments(parameters: typeof defaultArguments) { + if (parameters.proxyStaticFileWise && !parameters.proxyTarget) { + throw new Error('Invalid argument: --proxyStaticFileWise requires --proxyTarget') + } + + if (parameters.proxyStaticFileWise && parameters.proxyPattern) { + throw new Error('Invalid argument: --proxyStaticFileWise cannot be used with --proxyPattern') + } +} + +function createLegacyServer(app: express.Express, parameters: typeof defaultArguments, port: number) { + const dir = path.resolve(parameters.dir) + + app.use(cors()) + app.use(compression()) + + if (parameters.proxyTarget && parameters.proxyPattern) { const proxy = createProxyMiddleware({ - target: proxyTarget, + target: parameters.proxyTarget, changeOrigin: true, secure: false }) - // Convert glob pattern to regex for matching + let patternRegex: RegExp - if (proxyPattern.includes('*')) { - const regexPattern = '^' + proxyPattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$' + if (parameters.proxyPattern.includes('*')) { + const regexPattern = '^' + parameters.proxyPattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$' patternRegex = new RegExp(regexPattern) } else { - patternRegex = new RegExp('^' + proxyPattern.replace(/\./g, '\\.') + '.*$') + patternRegex = new RegExp('^' + parameters.proxyPattern.replace(/\./g, '\\.') + '.*$') } - // Use a custom middleware to match paths + app.use((req, res, next) => { if (patternRegex.test(req.url)) { proxy(req, res, next) @@ -61,32 +132,29 @@ export function run (parameters: CliArg) { } }) } + const router = express.Router() router.use(function (req, _res, next) { - if (!quiet) console.log(`Incoming request: ${req.originalUrl}`) + if (!parameters.quiet) console.log(`Incoming request: ${req.originalUrl}`) next() }) - console.log(`Serving dir [${dir}]`) + console.log(`Serving dir [${parameters.dir}]`) console.log('') console.log(' Server running at:') - console.log(` \x1b[1;34mhttp://127.0.0.1:${port}/\x1b[0m`) + console.log(` \x1b[1;34m${parameters.https ? 'https' : 'http'}://127.0.0.1:${port}/\x1b[0m`) console.log('') - if (!fs.existsSync(path.resolve(dir))) { - throw Error(`Dir [${dir}] does not exit`) + if (!fs.existsSync(dir)) { + throw Error(`Dir [${parameters.dir}] does not exit`) } - if (mode === MODE.SPA) { - app.use(cors()) - app.use(compression()) + if (parameters.mode === MODE.SPA) { app.use(router) app.use(express.static(dir)) - app.use(handleSPA({ dir, indexFile })) + app.use(handleSPA({dir, indexFile: parameters.indexFile})) } else { - app.use(cors()) - app.use(compression()) app.use(router) app.use(express.static(dir)) app.use(function (req: any, res: any) { @@ -94,8 +162,8 @@ export function run (parameters: CliArg) { fs.readdir( requestPath, - { withFileTypes: true }, - (_a: any, list: Array<{ name: any }>) => { + {withFileTypes: true}, + (_a: any, list: Array<{name: any}>) => { if (!list) { res.status(404).send(`resource not found: ${requestPath}`) return @@ -104,7 +172,7 @@ export function run (parameters: CliArg) { const html = title + list - .map((f: { name: any }) => f.name) + .map((f: {name: any}) => f.name) .map((name: any) => `${name}`) .join('
') @@ -115,31 +183,131 @@ export function run (parameters: CliArg) { }) } - const server = app.listen(port) + return createHttpOrHttpsServer(app, parameters, port) +} - process.on('uncaughtException', function (err: any) { - if (err.code === 'EACCES') { - console.log( - 'EACCES error(lack of permission), use "run as Administrator" when you try to start the program' - ) - } else { - console.log('Caught exception: ', err) +function createProxyStaticFirstServer(app: express.Express, parameters: typeof defaultArguments, port: number) { + const dir = path.resolve(parameters.dir) + const proxyTarget = new URL(parameters.proxyTarget) + + if (!fs.existsSync(dir)) { + throw Error(`Dir [${parameters.dir}] does not exit`) + } + + const proxy = createProxyMiddleware({ + target: parameters.proxyTarget, + changeOrigin: true, + secure: false, + ws: true, + xfwd: false, + autoRewrite: true, + protocolRewrite: parameters.https ? 'https' : 'http', + logLevel: 'warn', + on: { + proxyReq(proxyReq, req) { + normalizeProxyRequest(proxyReq, req, proxyTarget.origin, proxyTarget.host, false) + }, + proxyReqWs(proxyReq, req) { + normalizeProxyRequest(proxyReq, req, proxyTarget.origin, proxyTarget.host, true) + } } }) + app.use( + express.static(dir, { + index: parameters.indexFile, + fallthrough: true + }) + ) + + app.use((req, res, next) => proxy(req, res, next)) + + const server = createHttpOrHttpsServer(app, parameters, port) + server.on('upgrade', (req, socket, head) => proxy.upgrade(req, socket, head)) + + console.log(`Serving dir [${parameters.dir}]`) + console.log('') + console.log(' Server running at:') + console.log(` \x1b[1;34m${parameters.https ? 'https' : 'http'}://127.0.0.1:${port}/\x1b[0m`) + console.log('') + + return server +} + +function normalizeProxyRequest(proxyReq: any, req: any, targetOrigin: string, targetHost: string, forceOrigin: boolean) { + proxyReq.setHeader('host', targetHost) + + if (forceOrigin || req.method !== 'GET' && req.method !== 'HEAD') { + proxyReq.setHeader('origin', targetOrigin) + } + + proxyReq.setHeader('referer', rewriteReferer(req.headers.referer, targetOrigin) || `${targetOrigin}/`) + + for (const header of PROXY_FINGERPRINT_HEADERS) { + proxyReq.removeHeader(header) + } +} + +function rewriteReferer(value: string | undefined, targetOrigin: string): string | undefined { + if (!value) return undefined + + try { + const url = new URL(value) + const target = new URL(targetOrigin) + url.protocol = target.protocol + url.host = target.host + return url.toString() + } catch { + return value + } +} + +function createHttpOrHttpsServer(app: express.Express, parameters: typeof defaultArguments, port: number) { + if (!parameters.https) { + const server = http.createServer(app) + server.listen(port) + return server + } + + const keyPath = resolveHttpsPath(parameters.httpsKey, 'server.key') + const certPath = resolveHttpsPath(parameters.httpsCert, 'server.cert') + + const server = https.createServer( + { + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath) + }, + app + ) + + server.listen(port) return server } -function handleSPA ({ dir, indexFile }: { dir: string, indexFile: string }) { +function resolveHttpsPath(explicitPath: string, fileName: string): string { + const candidates = [ + explicitPath, + path.resolve(process.cwd(), fileName), + path.resolve(__dirname, '..', fileName) + ].filter(Boolean) + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate + } + } + + throw new Error(`Unable to find ${fileName}. Checked: ${candidates.join(', ')}`) +} + +function handleSPA({dir, indexFile}: {dir: string, indexFile: string}) { return (req: Request, res: any) => { const requestPath = path.resolve(`${dir}${req.url}`) - fs.readdir(requestPath, { withFileTypes: true }, () => { - res.writeHead(200, { 'Content-Type': 'text/html' }) + fs.readdir(requestPath, {withFileTypes: true}, () => { + res.writeHead(200, {'Content-Type': 'text/html'}) res.write(fs.readFileSync(`${dir}/${indexFile}`)) res.end() }) } } - -// export {run, MODE}; diff --git a/test/bin.spec.ts b/test/bin.spec.ts index 3c38835..b420a76 100644 --- a/test/bin.spec.ts +++ b/test/bin.spec.ts @@ -1,16 +1,19 @@ -import { run } from '../src/run'; -import { getOptions } from '../src/bin'; -jest.mock('../src/run'); +import {getOptions} from '../src/bin' + +describe('Test bin.js', function () { + const originalArgv = process.argv -describe.only('Test bin.js', function () { beforeEach(function () { - // @ts-ignore - run.mockImplementation(() => {}); - }); - afterAll(function () {}); + process.argv = ['node', 'instant_http'] + }) + + afterEach(function () { + process.argv = originalArgv + jest.restoreAllMocks() + }) it('should give default options', async function () { - const res = getOptions(); + const res = getOptions() expect(res).toEqual( expect.objectContaining({ dir: expect.stringContaining('InstantHttp'), @@ -19,6 +22,32 @@ describe.only('Test bin.js', function () { port: '9090', quiet: false }) - ); - }); -}); + ) + }) + + it('should parse the new proxy flags', async function () { + process.argv = [ + 'node', + 'instant_http', + '--proxyStaticFileWise', + '--https', + '--httpsKey', + '/tmp/key.pem', + '--httpsCert', + '/tmp/cert.pem', + '--proxyTarget', + 'http://127.0.0.1:1234' + ] + + const res = getOptions() + expect(res).toEqual( + expect.objectContaining({ + proxyStaticFileWise: true, + https: true, + httpsKey: '/tmp/key.pem', + httpsCert: '/tmp/cert.pem', + proxyTarget: 'http://127.0.0.1:1234' + }) + ) + }) +}) diff --git a/test/test.spec.ts b/test/test.spec.ts index d7a7bd9..44e2824 100644 --- a/test/test.spec.ts +++ b/test/test.spec.ts @@ -1,157 +1,428 @@ -import { run } from '../src/run'; +import fs from 'fs' +import http from 'http' +import https from 'https' +import os from 'os' +import path from 'path' +import {once} from 'events' +import {AddressInfo} from 'net' -const request = require('supertest'); +import {WebSocketServer} from 'ws' -describe('Test run.js', function () { - let app: any; +import {run} from '../src/run' - beforeEach(function () {}); +const request = require('supertest') +const WebSocket = require('ws') - afterEach(function () { - if (app) { - app.close(); - } - jest.clearAllMocks(); - }); +jest.setTimeout(20000) - afterAll(function () {}); +const activeServers: Array = [] - let portCounter = 9100; - - function getNextPort() { - return String(portCounter++); - } +afterEach(async function () { + await Promise.all(activeServers.splice(0).map(closeServer)) + jest.restoreAllMocks() +}) +describe('run', function () { it('should start server without passing arguments', async function () { - app = run({ port: getNextPort() } as any); - await request(app).get('/').expect('Content-Type', /html/).expect(200); - }); + const dir = makeTempDir() + const server = run({port: '0', dir} as any) + activeServers.push(server) + + const response = await request(server).get('/').expect(200) + + expect(response.text).toContain('

Current Dir:') + }) it('should throw error when directory does not exist', function () { expect(() => { - run({ dir: '/non_existent_directory_12345', port: getNextPort() } as any); - }).toThrow('Dir [/non_existent_directory_12345] does not exit'); - }); + run({dir: '/non_existent_directory_12345', port: '0'} as any) + }).toThrow('Dir [/non_existent_directory_12345] does not exit') + }) it('should log incoming requests when quiet is false', async function () { - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); - app = run({ port: getNextPort(), quiet: false } as any); - await request(app).get('/'); - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Incoming request:')); - consoleLogSpy.mockRestore(); - }); - - describe('Default mode', function () { - it('should list files when given request path is a real directory', async function () { - app = run({ port: getNextPort() } as any); - const response = await request(app).get('/').expect(200); - - expect(response.text.startsWith('

Current Di')).toBeTruthy(); - }); - - it('should return 404 when requested path does not exist', async function () { - app = run({ port: getNextPort() } as any); - // Request a file that doesn't exist (not a directory) - const response = await request(app).get('/non_existent_file_12345.txt').expect(404); - expect(response.text).toContain('resource not found'); - }); - - it('should forward request /api/abc to proxyTarget http://localhost:9091', async function () { - const params = { - port: getNextPort(), - proxyPattern: '/api/*', - proxyTarget: 'http://localhost:9099/' - }; - app = run(params as any); + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation() + const dir = makeTempDir() + const server = run({port: '0', dir, quiet: false} as any) + activeServers.push(server) - const res = await request(app).get('/api/abc'); + await request(server).get('/') - expect(res.text).toContain('Error occurred while trying to proxy'); - }); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Incoming request:')) + }) - it('should forward request /api/abc to proxyTarget when proxyPattern has no wildcard', async function () { - const params = { - port: getNextPort(), + describe('legacy proxy mode', function () { + it('should forward request /api/abc to proxyTarget http://localhost:9091', async function () { + const upstream = await startHttpUpstream((req, res) => { + res.statusCode = 200 + res.setHeader('content-type', 'text/plain') + res.end(`upstream:${req.url}`) + }) + + const server = run({ + port: '0', proxyPattern: '/api', - proxyTarget: 'http://localhost:9099/' - }; - app = run(params as any); + proxyTarget: upstream.url + } as any) + activeServers.push(server) - const res = await request(app).get('/api/abc'); + const response = await request(server).get('/api/abc').expect(200) - expect(res.text).toContain('Error occurred while trying to proxy'); - }); + expect(response.text).toBe('upstream:/api/abc') + }) it('should not forward non-matching request to proxy', async function () { - const params = { - port: getNextPort(), + const upstream = await startHttpUpstream((req, res) => { + res.statusCode = 200 + res.end(`upstream:${req.url}`) + }) + + const dir = makeTempDir() + const server = run({ + port: '0', + dir, proxyPattern: '/api/*', - proxyTarget: 'http://localhost:9099/' - }; - app = run(params as any); - - await request(app).get('/').expect(200); - }); - }); - - describe('SPA Mode', function () { - it('should redirect to index.html when given url can not match any resource', async function () { - app = run({ mode: 'SPA', indexFile: 'test/resource/test.index.file', port: getNextPort() } as any); - - const res = await request(app).get('/api/abc').expect(200); - - expect(res.text.trim()).toEqual('test index file.'); - }); - }); - - describe('Uncaught exception handler', function () { - let consoleLogSpy: jest.SpyInstance; - let uncaughtHandler: any = null; - - beforeEach(function () { - consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); - // Capture the handler without causing infinite recursion - const originalOn = process.on; - (process as any).on = function (event: string, handler: any) { - if (event === 'uncaughtException') { - uncaughtHandler = handler; - } - return originalOn.call(process, event, handler); - }; - }); - - afterEach(function () { - consoleLogSpy.mockRestore(); - if (uncaughtHandler) { - process.removeListener('uncaughtException', uncaughtHandler); - uncaughtHandler = null; + proxyTarget: upstream.url + } as any) + activeServers.push(server) + + const response = await request(server).get('/').expect(200) + + expect(response.text).toContain('

Current Dir:') + }) + }) + + describe('proxyStaticFileWise mode', function () { + it('should serve static files locally before proxying', async function () { + const staticDir = makeStaticFixture({ + 'login.html': 'local login', + 'asset.txt': 'local asset' + }) + const upstream = await startHttpUpstream((req, res) => { + res.statusCode = 200 + res.setHeader('content-type', 'text/plain') + res.end(`upstream:${req.url}`) + }) + + const server = run({ + port: '0', + dir: staticDir, + indexFile: 'login.html', + proxyStaticFileWise: true, + proxyTarget: upstream.url + } as any) + activeServers.push(server) + + const root = await request(server).get('/').expect(200) + const login = await request(server).get('/login.html').expect(200) + const asset = await request(server).get('/asset.txt').expect(200) + + expect(root.text).toContain('local login') + expect(login.text).toContain('local login') + expect(asset.text).toBe('local asset') + }) + + it('should reject conflicting proxy flags', function () { + expect(() => { + run({ + port: '0', + dir: makeTempDir(), + proxyStaticFileWise: true, + proxyPattern: '/api', + proxyTarget: 'http://127.0.0.1:1' + } as any) + }).toThrow('Invalid argument: --proxyStaticFileWise cannot be used with --proxyPattern') + }) + + it('should reject proxyStaticFileWise without proxyTarget', function () { + expect(() => { + run({ + port: '0', + dir: makeTempDir(), + proxyStaticFileWise: true + } as any) + }).toThrow('Invalid argument: --proxyStaticFileWise requires --proxyTarget') + }) + + it('should normalize upstream headers and preserve POST bodies', async function () { + const upstreamCalls: Array<{ + url: string + method: string + headers: http.IncomingHttpHeaders + body: string + }> = [] + + const upstream = await startHttpUpstream((req, res) => { + let body = '' + req.setEncoding('utf8') + req.on('data', chunk => { + body += chunk + }) + req.on('end', () => { + upstreamCalls.push({ + url: req.url ?? '', + method: req.method ?? '', + headers: req.headers, + body + }) + res.statusCode = 200 + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ok: true})) + }) + }) + + const staticDir = makeStaticFixture({'login.html': 'local login'}) + const server = run({ + port: '0', + dir: staticDir, + indexFile: 'login.html', + proxyStaticFileWise: true, + proxyTarget: upstream.url + } as any) + activeServers.push(server) + const proxyPort = await getServerPort(server) + + await request(server) + .post('/LoginRequest') + .set('Origin', `http://127.0.0.1:${proxyPort}`) + .set('Referer', `http://127.0.0.1:${proxyPort}/login.html`) + .set('Via', '1.1 proxy') + .set('X-Forwarded-For', '10.0.0.1') + .set('X-Forwarded-Host', '127.0.0.1') + .set('X-Forwarded-Proto', 'http') + .set('Content-Type', 'application/json') + .send('{"login":"Nemuadmin","token":"nemuuser"}') + .expect(200) + + expect(upstreamCalls).toHaveLength(1) + expect(upstreamCalls[0].url).toBe('/LoginRequest') + expect(upstreamCalls[0].method).toBe('POST') + expect(upstreamCalls[0].headers.host).toBe(upstream.hostHeader) + expect(upstreamCalls[0].headers.origin).toBe(upstream.origin) + expect(upstreamCalls[0].headers.referer).toBe(`${upstream.origin}/login.html`) + expect(upstreamCalls[0].headers['x-forwarded-for']).toBeUndefined() + expect(upstreamCalls[0].headers.via).toBeUndefined() + expect(upstreamCalls[0].body).toBe('{"login":"Nemuadmin","token":"nemuuser"}') + }) + + it('should rewrite upstream redirects to the proxy origin', async function () { + let upstreamUrl = '' + const upstream = await startHttpUpstream((req, res) => { + res.statusCode = 302 + res.setHeader('location', `${upstreamUrl}/redirected`) + res.end('') + }) + upstreamUrl = upstream.url + + const staticDir = makeStaticFixture({'login.html': 'local login'}) + const server = run({ + port: '0', + dir: staticDir, + indexFile: 'login.html', + proxyStaticFileWise: true, + proxyTarget: upstream.url + } as any) + activeServers.push(server) + const proxyPort = await getServerPort(server) + + const response = await request(server).get('/VersionRequest').expect(302) + + expect(response.headers.location).toBe(`http://127.0.0.1:${proxyPort}/redirected`) + }) + + it('should forward websocket upgrades', async function () { + const wsState = { + messages: [] as string[], + upgradeUrl: '' + } + + const upstream = await startWebSocketUpstream(wsState) + const staticDir = makeStaticFixture({'login.html': 'local login'}) + const server = run({ + port: '0', + dir: staticDir, + indexFile: 'login.html', + proxyStaticFileWise: true, + proxyTarget: upstream.url + } as any) + activeServers.push(server) + const proxyPort = await getServerPort(server) + + await new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${proxyPort}/websocket`) + socket.on('open', () => { + socket.send('ping') + }) + socket.on('message', message => { + expect(String(message)).toBe('echo:ping') + socket.close() + resolve() + }) + socket.on('error', reject) + }) + + expect(wsState.upgradeUrl).toBe('/websocket') + expect(wsState.messages).toContain('ping') + }) + }) + + describe('HTTPS listener', function () { + it('should serve the proxy over HTTPS when enabled', async function () { + const staticDir = makeStaticFixture({ + 'login.html': 'local login' + }) + const upstream = await startHttpUpstream((req, res) => { + res.statusCode = 200 + res.end('upstream') + }) + const server = run({ + port: '0', + dir: staticDir, + indexFile: 'login.html', + proxyStaticFileWise: true, + proxyTarget: upstream.url, + https: true, + httpsKey: path.resolve(__dirname, '..', 'server.key'), + httpsCert: path.resolve(__dirname, '..', 'server.cert') + } as any) + activeServers.push(server) + const proxyPort = await getServerPort(server) + + const body = await httpsGet(proxyPort, '/') + + expect(body).toContain('local login') + }) + }) +}) + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'instant-http-')) +} + +function makeStaticFixture(files: Record): string { + const dir = makeTempDir() + for (const [filePath, content] of Object.entries(files)) { + const fullPath = path.join(dir, filePath) + fs.mkdirSync(path.dirname(fullPath), {recursive: true}) + fs.writeFileSync(fullPath, content) + } + return dir +} + +async function closeServer(server: http.Server | https.Server) { + if (!server.listening) return + + await new Promise((resolve, reject) => { + server.close(error => { + if (error) { + reject(error) + return } - // Restore original process.on - delete (process as any).on; - }); - - it('should log EACCES error message for permission errors', function () { - app = run({ port: getNextPort(), quiet: true } as any); - expect(uncaughtHandler).not.toBeNull(); - - uncaughtHandler({ code: 'EACCES' }); - - expect(consoleLogSpy).toHaveBeenCalledWith( - 'EACCES error(lack of permission), use "run as Administrator" when you try to start the program' - ); - }); - - it('should log general error message for other exceptions', function () { - app = run({ port: getNextPort(), quiet: true } as any); - expect(uncaughtHandler).not.toBeNull(); - - const testError = new Error('Test error'); - uncaughtHandler(testError); - - expect(consoleLogSpy).toHaveBeenCalledWith( - 'Caught exception: ', - testError - ); - }); - }); -}); + + resolve() + }) + }) +} + +async function getServerPort(server: http.Server | https.Server): Promise { + if (!server.listening) { + await once(server, 'listening') + } + + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('server did not start listening') + } + + return address.port +} + +async function startHttpUpstream( + handler: http.RequestListener +): Promise<{server: http.Server, url: string, origin: string, hostHeader: string}> { + const server = http.createServer(handler) + await listen(server) + activeServers.push(server) + const port = getAddressPort(server) + + return { + server, + url: `http://127.0.0.1:${port}`, + origin: `http://127.0.0.1:${port}`, + hostHeader: `127.0.0.1:${port}` + } +} + +async function startWebSocketUpstream(state: {messages: string[], upgradeUrl: string}) { + const server = http.createServer() + const wss = new WebSocketServer({noServer: true}) + + server.on('upgrade', (req, socket, head) => { + state.upgradeUrl = req.url ?? '' + wss.handleUpgrade(req, socket, head, ws => { + wss.emit('connection', ws, req) + }) + }) + + wss.on('connection', ws => { + ws.on('message', message => { + const text = String(message) + state.messages.push(text) + ws.send(`echo:${text}`) + }) + }) + + await listen(server) + activeServers.push(server) + const port = getAddressPort(server) + + return { + server, + url: `http://127.0.0.1:${port}` + } +} + +async function listen(server: http.Server): Promise { + if (server.listening) return + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) +} + +function getAddressPort(server: http.Server): number { + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('server did not start listening') + } + + return (address as AddressInfo).port +} + +async function httpsGet(port: number, pathname: string): Promise { + return await new Promise((resolve, reject) => { + const req = https.request( + { + host: '127.0.0.1', + port, + path: pathname, + method: 'GET', + rejectUnauthorized: false + }, + res => { + let body = '' + res.setEncoding('utf8') + res.on('data', chunk => { + body += chunk + }) + res.on('end', () => resolve(body)) + } + ) + + req.on('error', reject) + req.end() + }) +} From a76f802a10f534a564088ed9905623541d86f456 Mon Sep 17 00:00:00 2001 From: pillsilly Date: Thu, 14 May 2026 20:46:14 +0800 Subject: [PATCH 2/9] readme refine --- README.md | 232 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 158 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index c00df60..1e17ef8 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,158 @@ -## General introduction -> A command line tool to serve local directories, or to run a static-first reverse proxy with optional HTTPS - -## Installation - -```text -# Install globally: -npm i instantly_http -g -``` - -## Options - -```bash -instant_http --help -Usage: instant_http [global options] - -Options: - -V, --version output the version number - -p --port [port] To point which port to use as the server address. (default: "9090") - -d --dir [dir] Dir to serve (default: "/home/frank/code/InstantHttp") - -pt --proxyTarget [proxyTarget] Where the delegated communication targets to - -pp --proxyPattern [proxyPattern] URL matcher to be used to identify which url to proxy - --proxyStaticFileWise Serve static files first, then proxy everything else - --https Enable HTTPS listener - --httpsKey [httpsKey] HTTPS private key file - --httpsCert [httpsCert] HTTPS certificate file - -m --mode [mode] Which mode to use (default: "NORMAL") - -i --indexFile [indexFile] Index File location(relative to --dir) (default: "index.html") - -q --quiet [quiet] Set it to false to see more debug outputs (default: false) - -h, --help display help for command -``` - -### Proxy notes - -- `--proxyStaticFileWise` reuses `--proxyTarget`. -- `--proxyStaticFileWise` is mutually exclusive with `--proxyPattern`. -- `--https` uses the existing `server.key` and `server.cert` in the package root unless `--httpsKey` / `--httpsCert` are provided. -- In `--proxyStaticFileWise` mode, `/` is served through `--indexFile`. - -## Usages - -### MJS/TS -```javascript -import {run} from 'instantly_http'; -``` - -### CJS -```javascript -const {run} = require('instantly_http'); -``` - -### As a binary -```bash -./instantHttp --port=8080 --proxyStaticFileWise --proxyTarget=http://127.0.0.1:4431 --https -``` - -## Build for portable binary -After checkout then install this repository, you can then try below commands to get an executable binary. - -``` -npm run build-binary -``` - -> [pkg](https://www.npmjs.com/package/pkg) is used as the package utility, please check pkg's document in order to build runnable binaries as you want. - - -## Test - -```bash -npm run test -``` - -## Breaking Changes -- **vNext**: Removed `--open` option and chrome launcher functionality. Server URL is now displayed prominently in terminal for easy clicking. +# InstantHttp + +> Static file server & reverse proxy with optional HTTPS — zero config, single binary. + +[![npm version](https://img.shields.io/npm/v/instantly_http)](https://www.npmjs.com/package/instantly_http) +[![license](https://img.shields.io/npm/l/instantly_http)](https://github.com/pillsilly/InstantHttp/blob/master/LICENSE) + +Serves a local directory over HTTP, proxies requests to a backend, or does both in static-first hybrid mode. Ships as an npm package, a CLI, or a standalone binary. + +## Why + +Frontend development means serving built artifacts against a backend you don't control — a staging API, a production backend, or a colleague's dev server. Writing a throwaway Express script each time, wiring up CORS, compression, and a proxy middleware is boilerplate that adds nothing to your actual work. + +InstantHttp collapses that into a single command. No config files. No scaffolding. Point it at a directory, optionally give it a backend to proxy to, and you're done. + +Two scenarios where this matters: + +**Frontend-to-backend pairing.** You have a React, Vue, or Svelte build output and need to test it against a specific backend. `--proxyStaticFileWise` serves your static files first and proxies API calls to the backend. Switch backends by changing one flag — no code changes, no restart dance. + +**Static apps that need HTTP.** Some HTML+CSS+JS prototypes, spec pages, or tool UIs only work over HTTP (service workers, `fetch` to local resources, ES modules that need a real origin). `instant_http --dir ./demo` is zero-code — faster than pulling a full dependency tree. + +The binary build goes a step further: a single self-contained executable you can drop onto a CI runner or share with a teammate who doesn't have Node. + +## Installation + +```bash +npm i -g instantly_http +``` + +Or run without installing: + +```bash +npx instantly_http --port 8080 +``` + +## Quick start + +```bash +# Serve current directory +instant_http + +# Serve another directory on a different port +instant_http --dir ./public --port 3000 + +# SPA mode — all unmatched routes fall back to index.html +instant_http --mode SPA --dir ./dist + +# Static files first, proxy everything else to backend +instant_http --proxyStaticFileWise --proxyTarget http://localhost:3001 + +# HTTPS with default bundled cert/key +instant_http --https +``` + +## CLI reference + +| Option | Default | Description | +|---|---|---| +| `-p, --port` | `9090` | Server port | +| `-d, --dir` | `cwd` | Directory to serve | +| `-m, --mode` | `NORMAL` | Server mode: `NORMAL`, `SPA`, or `PROXY_STATIC_FILE_WISE` | +| `-i, --indexFile` | `index.html` | Index file (relative to `--dir`) for SPA fallback | +| `-t, --proxyTarget` | — | Backend URL to proxy requests to | +| `-P, --proxyPattern` | — | URL pattern to match for proxying (supports `*` wildcard) | +| `--proxyStaticFileWise` | `false` | Serve static files first, proxy everything else | +| `--https` | `false` | Enable HTTPS | +| `--httpsKey` | — | Path to HTTPS private key (defaults to bundled `server.key`) | +| `--httpsCert` | — | Path to HTTPS certificate (defaults to bundled `server.cert`) | +| `-q, --quiet` | `true` | Suppress request logs (`false` for debug output) | +| `-V, --version` | — | Print version | +| `-h, --help` | — | Print help | + +## Modes + +### NORMAL (default) + +Serves static files from `--dir`. When a request matches a directory, renders a clickable directory listing. Missing files return a 404. + +### SPA + +Single Page Application mode. Serves static files AND falls back to `--indexFile` for any route that doesn't match a file on disk. Use this for React, Vue, or Angular apps with client-side routing. + +### PROXY_STATIC_FILE_WISE + +Hybrid mode. Serves static files from `--dir` first. Any request that doesn't match a static file gets proxied to `--proxyTarget`. Supports WebSocket upgrade for HMR/dev servers. `/` maps to `--indexFile`. + +> **Note:** `--proxyStaticFileWise` reuses `--proxyTarget` and is mutually exclusive with `--proxyPattern`. + +## Proxy + +Two proxy strategies are available: + +**Pattern-based** (`--proxyPattern` + `--proxyTarget`) — only requests matching the pattern are proxied. Everything else is served as static files. + +```bash +# Proxy /api/* requests to backend, serve everything else from ./public +instant_http --dir ./public --proxyTarget http://localhost:3001 --proxyPattern /api/* +``` + +**Static-first** (`--proxyStaticFileWise`) — try the file system first, then proxy the rest. + +```bash +# Dev mode: serve Vite build, proxy everything else (HMR, API) to dev server +instant_http --proxyStaticFileWise --proxyTarget http://localhost:5173 +``` + +Proxy headers (`x-forwarded-for`, `x-forwarded-host`, `x-forwarded-proto`, `via`) are stripped from upstream requests to avoid proxy detection. The `referer` and `origin` headers are rewritten to match the target. + +## HTTPS + +Enable with `--https`. Uses bundled `server.key` and `server.cert` for development — **not for production**. Provide your own cert with `--httpsKey` and `--httpsCert`: + +```bash +instant_http --https --httpsKey ./privkey.pem --httpsCert ./fullchain.pem +``` + +## Programmatic API + +```js +// ESM +import { run } from 'instantly_http'; + +// CommonJS +const { run } = require('instantly_http'); + +const server = run({ + port: '8080', + dir: './public', + mode: 'SPA', + indexFile: 'index.html', + https: true, + // httpsKey: './key.pem', + // httpsCert: './cert.pem', +}); + +// server is an http.Server or https.Server instance +``` + +The `run()` function returns a Node.js `http.Server` (or `https.Server`) instance. Call `server.close()` to shut down. + +## Build standalone binary + +```bash +git clone https://github.com/pillsilly/InstantHttp +cd InstantHttp +npm install +npm run build-binary +``` + +Outputs a self-contained `instant_http` binary in `./executable/` — no Node.js runtime needed. Uses [pkg](https://www.npmjs.com/package/pkg) under the hood. + +## Test + +```bash +npm test +``` + +Coverage targets: 100% branches, functions, lines, and statements. From 5ecfe009e18369360082233fd49faaa3ace8aafc Mon Sep 17 00:00:00 2001 From: pillsilly Date: Fri, 15 May 2026 14:03:55 +0800 Subject: [PATCH 3/9] fix dts generate --- package.json | 2 +- src/run.ts | 34 ++++++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 91ca71c..ea97f50 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "testpack": "rm ./tmp/* -rf && npm pack --pack-destination=./tmp", "test": "npx jest --debug", "lint": "tsc --noEmit && eslint", - "build:dts": "tsc -p tsconfig.json --emitDeclarationOnly --outDir dist", + "build:dts": "tsc -p tsconfig.json --emitDeclarationOnly --outDir dist --rootDir src", "compile:prod": "rm -rf dist/* && npx tsup && npm run build:dts && ls -lha dist", "prerelease": "npm run compile:prod", "release": "npx release-it", diff --git a/src/run.ts b/src/run.ts index 153a6da..12a73f6 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,6 +1,7 @@ import path from 'path' import http from 'http' import https from 'https' +import {Socket} from 'net' import express, {Request} from 'express' import cors from 'cors' import fs from 'fs' @@ -32,7 +33,21 @@ interface CliArg { httpsCert?: string } -const defaultArguments = { +interface ResolvedCliArg { + port: string + dir: string + mode: string + indexFile: string + quiet: boolean + proxyTarget: string + proxyPattern: string + proxyStaticFileWise: boolean + https: boolean + httpsKey: string + httpsCert: string +} + +const defaultArguments: ResolvedCliArg = { port: '9090', dir: process.cwd(), mode: MODE.NORMAL, @@ -50,7 +65,7 @@ const PROXY_FINGERPRINT_HEADERS = ['x-forwarded-for', 'x-forwarded-host', 'x-for let uncaughtExceptionRegistered = false export function run(parameters: CliArg) { - const resolved = Object.assign({ ...defaultArguments }, parameters) + const resolved: ResolvedCliArg = Object.assign({ ...defaultArguments }, parameters) const port = parsePort(resolved.port) validateArguments(resolved) @@ -93,7 +108,7 @@ function parsePort(port: string | number | undefined): number { return parsed } -function validateArguments(parameters: typeof defaultArguments) { +function validateArguments(parameters: ResolvedCliArg) { if (parameters.proxyStaticFileWise && !parameters.proxyTarget) { throw new Error('Invalid argument: --proxyStaticFileWise requires --proxyTarget') } @@ -103,7 +118,7 @@ function validateArguments(parameters: typeof defaultArguments) { } } -function createLegacyServer(app: express.Express, parameters: typeof defaultArguments, port: number) { +function createLegacyServer(app: express.Express, parameters: ResolvedCliArg, port: number) { const dir = path.resolve(parameters.dir) app.use(cors()) @@ -186,7 +201,7 @@ function createLegacyServer(app: express.Express, parameters: typeof defaultArgu return createHttpOrHttpsServer(app, parameters, port) } -function createProxyStaticFirstServer(app: express.Express, parameters: typeof defaultArguments, port: number) { +function createProxyStaticFirstServer(app: express.Express, parameters: ResolvedCliArg, port: number) { const dir = path.resolve(parameters.dir) const proxyTarget = new URL(parameters.proxyTarget) @@ -202,7 +217,6 @@ function createProxyStaticFirstServer(app: express.Express, parameters: typeof d xfwd: false, autoRewrite: true, protocolRewrite: parameters.https ? 'https' : 'http', - logLevel: 'warn', on: { proxyReq(proxyReq, req) { normalizeProxyRequest(proxyReq, req, proxyTarget.origin, proxyTarget.host, false) @@ -223,7 +237,11 @@ function createProxyStaticFirstServer(app: express.Express, parameters: typeof d app.use((req, res, next) => proxy(req, res, next)) const server = createHttpOrHttpsServer(app, parameters, port) - server.on('upgrade', (req, socket, head) => proxy.upgrade(req, socket, head)) + server.on('upgrade', (req, socket, head) => { + if (socket instanceof Socket) { + proxy.upgrade(req, socket, head) + } + }) console.log(`Serving dir [${parameters.dir}]`) console.log('') @@ -262,7 +280,7 @@ function rewriteReferer(value: string | undefined, targetOrigin: string): string } } -function createHttpOrHttpsServer(app: express.Express, parameters: typeof defaultArguments, port: number) { +function createHttpOrHttpsServer(app: express.Express, parameters: ResolvedCliArg, port: number) { if (!parameters.https) { const server = http.createServer(app) server.listen(port) From 77cd2ae04fb37099fadc098e766ca2611d9d2962 Mon Sep 17 00:00:00 2001 From: pillsilly Date: Fri, 15 May 2026 15:05:40 +0800 Subject: [PATCH 4/9] ut coverage to 100% --- src/bin.ts | 2 +- test/bin.spec.ts | 65 +++++++++++++++++++++++++ test/test.spec.ts | 119 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) diff --git a/src/bin.ts b/src/bin.ts index c62faf7..4386bac 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -60,7 +60,7 @@ export function main() { } } +/* c8 ignore next 3 */ if (require.main === module) { main() } - diff --git a/test/bin.spec.ts b/test/bin.spec.ts index b420a76..68a4fe4 100644 --- a/test/bin.spec.ts +++ b/test/bin.spec.ts @@ -10,6 +10,8 @@ describe('Test bin.js', function () { afterEach(function () { process.argv = originalArgv jest.restoreAllMocks() + jest.dontMock('../src/run') + jest.resetModules() }) it('should give default options', async function () { @@ -50,4 +52,67 @@ describe('Test bin.js', function () { }) ) }) + + it('should call run with parsed options from main', function () { + const runMock = jest.fn() + const main = loadMainWithRunMock(runMock) + + main() + + expect(runMock).toHaveBeenCalledWith( + expect.objectContaining({ + dir: expect.stringContaining('InstantHttp'), + indexFile: 'index.html', + mode: 'NORMAL', + port: '9090', + quiet: false + }) + ) + }) + + it('should exit with an error message when main fails', function () { + const runMock = jest.fn(() => { + throw new Error('boom') + }) + const main = loadMainWithRunMock(runMock) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => undefined) as never) + + main() + + expect(consoleErrorSpy).toHaveBeenCalledWith('boom') + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + it('should stringify non-Error failures from main', function () { + const runMock = jest.fn(() => { + throw 'boom' + }) + const main = loadMainWithRunMock(runMock) + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => undefined) as never) + + main() + + expect(consoleErrorSpy).toHaveBeenCalledWith('boom') + expect(exitSpy).toHaveBeenCalledWith(1) + }) }) + +function loadMainWithRunMock(runMock: jest.Mock) { + let main: typeof import('../src/bin').main + + jest.isolateModules(() => { + jest.doMock('../src/run', () => { + const actual = jest.requireActual('../src/run') + return { + ...actual, + run: runMock + } + }) + + ;({main} = require('../src/bin')) + }) + + return main! +} diff --git a/test/test.spec.ts b/test/test.spec.ts index 44e2824..8b0c04e 100644 --- a/test/test.spec.ts +++ b/test/test.spec.ts @@ -50,6 +50,39 @@ describe('run', function () { expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Incoming request:')) }) + it.each(['abc', '-1'])('should reject invalid port %s', function (port) { + expect(() => { + run({port, dir: makeTempDir()} as any) + }).toThrow(`Invalid port: ${port}`) + }) + + it('should use the default port when port is omitted', function () { + expect(() => { + run({port: undefined, dir: '/non_existent_directory_12345'} as any) + }).toThrow('Dir [/non_existent_directory_12345] does not exit') + }) + + it('should log uncaught exceptions', function () { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation() + const dir = makeTempDir() + const server = run({port: '0', dir} as any) + activeServers.push(server) + + const handler = process + .listeners('uncaughtException') + .find(listener => String(listener).includes('EACCES error(lack of permission)')) + + expect(handler).toBeDefined() + + ;(handler as (err: Error & {code?: string}) => void)(Object.assign(new Error('denied'), {code: 'EACCES'})) + ;(handler as (err: Error & {code?: string}) => void)(Object.assign(new Error('boom'), {code: 'OTHER'})) + + expect(consoleLogSpy).toHaveBeenCalledWith( + 'EACCES error(lack of permission), use "run as Administrator" when you try to start the program' + ) + expect(consoleLogSpy).toHaveBeenCalledWith('Caught exception: ', expect.any(Error)) + }) + describe('legacy proxy mode', function () { it('should forward request /api/abc to proxyTarget http://localhost:9091', async function () { const upstream = await startHttpUpstream((req, res) => { @@ -89,9 +122,33 @@ describe('run', function () { expect(response.text).toContain('

Current Dir:') }) + + it('should return 404 for missing paths', async function () { + const dir = makeTempDir() + const server = run({ + port: '0', + dir + } as any) + activeServers.push(server) + + const response = await request(server).get('/missing-route').expect(404) + + expect(response.text).toContain(`resource not found: ${path.resolve(`${dir}/missing-route`)}`) + }) }) describe('proxyStaticFileWise mode', function () { + it('should reject missing static directories', function () { + expect(() => { + run({ + port: '0', + dir: '/non_existent_directory_12345', + proxyStaticFileWise: true, + proxyTarget: 'http://127.0.0.1:1' + } as any) + }).toThrow('Dir [/non_existent_directory_12345] does not exit') + }) + it('should serve static files locally before proxying', async function () { const staticDir = makeStaticFixture({ 'login.html': 'local login', @@ -202,6 +259,14 @@ describe('run', function () { expect(upstreamCalls[0].headers['x-forwarded-for']).toBeUndefined() expect(upstreamCalls[0].headers.via).toBeUndefined() expect(upstreamCalls[0].body).toBe('{"login":"Nemuadmin","token":"nemuuser"}') + + await request(server) + .get('/InvalidRefererRequest') + .set('Referer', 'not a url') + .expect(200) + + expect(upstreamCalls).toHaveLength(2) + expect(upstreamCalls[1].headers.referer).toBe('not a url') }) it('should rewrite upstream redirects to the proxy origin', async function () { @@ -263,9 +328,43 @@ describe('run', function () { expect(wsState.upgradeUrl).toBe('/websocket') expect(wsState.messages).toContain('ping') }) + + it('should fail when HTTPS certificate files are unavailable', function () { + const dir = makeTempDir() + jest.spyOn(fs, 'existsSync').mockImplementation(candidate => candidate === dir) + + expect(() => { + run({ + port: '0', + dir, + proxyStaticFileWise: true, + proxyTarget: 'http://127.0.0.1:1234', + https: true + } as any) + }).toThrow('Unable to find server.key') + }) }) describe('HTTPS listener', function () { + it('should serve the legacy server over HTTPS when enabled', async function () { + const staticDir = makeStaticFixture({ + 'index.html': 'legacy https' + }) + const server = run({ + port: '0', + dir: staticDir, + https: true, + httpsKey: path.resolve(__dirname, '..', 'server.key'), + httpsCert: path.resolve(__dirname, '..', 'server.cert') + } as any) + activeServers.push(server) + const proxyPort = await getServerPort(server) + + const body = await httpsGet(proxyPort, '/') + + expect(body).toContain('legacy https') + }) + it('should serve the proxy over HTTPS when enabled', async function () { const staticDir = makeStaticFixture({ 'login.html': 'local login' @@ -292,6 +391,26 @@ describe('run', function () { expect(body).toContain('local login') }) }) + + describe('SPA mode', function () { + it('should fall back to index.html for missing routes', async function () { + const staticDir = makeStaticFixture({ + 'index.html': 'spa index', + 'assets/app.js': 'console.log("spa")' + }) + const server = run({ + port: '0', + dir: staticDir, + mode: 'SPA', + indexFile: 'index.html' + } as any) + activeServers.push(server) + + const response = await request(server).get('/missing-route').expect(200) + + expect(response.text).toContain('spa index') + }) + }) }) function makeTempDir(): string { From 7a8c59ad483c2fd8505c88fbdf79c810b75fd250 Mon Sep 17 00:00:00 2001 From: pillsilly Date: Fri, 15 May 2026 15:08:25 +0800 Subject: [PATCH 5/9] Release 1.3.0-1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ea97f50..bd021f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "instantly_http", - "version": "1.2.2", + "version": "1.3.0-1", "description": "Tool to instantly create your own http server for development-use", "bin": "./dist/bin.js", "main": "./dist/index.js", From 60e61dc89ea2c99deeaeb7e877963fdf737954fd Mon Sep 17 00:00:00 2001 From: pillsilly Date: Fri, 15 May 2026 21:11:38 +0800 Subject: [PATCH 6/9] 1.3.0-2 --- package.json | 4 ++-- tsconfig.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index bd021f8..5e2be3d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test": "npx jest --debug", "lint": "tsc --noEmit && eslint", "build:dts": "tsc -p tsconfig.json --emitDeclarationOnly --outDir dist --rootDir src", - "compile:prod": "rm -rf dist/* && npx tsup && npm run build:dts && ls -lha dist", + "compile:prod": "rm -rf dist/* && npx tsup && ls -lha dist", "prerelease": "npm run compile:prod", "release": "npx release-it", "audit": "npm audit --omit=dev", @@ -31,7 +31,7 @@ "src/bin.ts" ], "clean": true, - "dts": false, + "dts": true, "format": [ "cjs", "esm" diff --git a/tsconfig.json b/tsconfig.json index 090ab59..1d7cf6a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -94,7 +94,7 @@ "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - + "ignoreDeprecations": "6.0", /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ From 41ecbf1a57561da050760e97124be0d6478d442b Mon Sep 17 00:00:00 2001 From: pillsilly Date: Fri, 15 May 2026 21:15:55 +0800 Subject: [PATCH 7/9] 1.3.0-2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e2be3d..db50cd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "instantly_http", - "version": "1.3.0-1", + "version": "1.3.0-2", "description": "Tool to instantly create your own http server for development-use", "bin": "./dist/bin.js", "main": "./dist/index.js", From 6ca1d76305c3f9f91fb108e4a092e57ad441ebec Mon Sep 17 00:00:00 2001 From: pillsilly Date: Sat, 16 May 2026 23:26:30 +0800 Subject: [PATCH 8/9] Remove unused babel and eslintrc config files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit babel.config.js unused — build uses tsup (esbuild), test uses esbuild-jest. .eslintrc.js unused — ESLint v9 uses eslint.config.js (flat config). test.mjs unused — leftover scratch file. Remove orphaned @babel/preset-env and @babel/preset-typescript devDependencies. --- .eslintrc.js | 45 --------------------------------------- .release-it.json | 9 ++++++-- babel.config.js | 6 ------ package.json | 4 +--- test.mjs | 15 ------------- test/test-import.ts | 1 + tsconfig.check-types.json | 10 +++++++++ 7 files changed, 19 insertions(+), 71 deletions(-) delete mode 100644 .eslintrc.js delete mode 100644 babel.config.js delete mode 100644 test.mjs create mode 100644 test/test-import.ts create mode 100644 tsconfig.check-types.json diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index b5b92af..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,45 +0,0 @@ -module.exports = { - env: { - browser: true, - es2021: true, - }, - extends: 'standard-with-typescript', - overrides: [ - { - env: { - node: true, - }, - files: ['.eslintrc.{js,cjs}'], - parserOptions: { - sourceType: 'script', - }, - }, - { - files: [ - '**/*.ts', - '**/*.ts' - ], - parserOptions: { - project: './tsconfig.spec.json', - }, - }, - ], - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: './tsconfig.json', - }, - - rules: { - strict: 1, - '@typescript-eslint/semi': ['off'], - '@typescript-eslint/no-var-requires': ['off'], - '@typescript-eslint/no-floating-promises':['off'], - '@typescript-eslint/strict-boolean-expressions': ['off'], - '@typescript-eslint/explicit-function-return-type': ['off'], - }, - ignorePatterns: [ - "*.config.js", - ".eslintrc.js" - ] -}; diff --git a/.release-it.json b/.release-it.json index d972e09..9b7e430 100644 --- a/.release-it.json +++ b/.release-it.json @@ -1,7 +1,12 @@ { "hooks": { - "before:init": ["npm run lint", "npm run test"], + "before:init": [ + "npm run check:types", + "npm run lint", + "npm run test", + "npm run build-binary" + ], "after:bump": "npm run prerelease", "after:git:release": "echo After git push, before github release" } -} +} \ No newline at end of file diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index 8165fe4..0000000 --- a/babel.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - presets: [ - ['@babel/preset-env', { targets: { node: 'current' } }], - '@babel/preset-typescript', - ], -}; diff --git a/package.json b/package.json index db50cd9..51d87c2 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "testpack": "rm ./tmp/* -rf && npm pack --pack-destination=./tmp", "test": "npx jest --debug", "lint": "tsc --noEmit && eslint", - "build:dts": "tsc -p tsconfig.json --emitDeclarationOnly --outDir dist --rootDir src", + "check:types": "tsc -p tsconfig.check-types.json --noEmit", "compile:prod": "rm -rf dist/* && npx tsup && ls -lha dist", "prerelease": "npm run compile:prod", "release": "npx release-it", @@ -57,8 +57,6 @@ ], "homepage": "https://github.com/pillsilly/InstantHttp", "devDependencies": { - "@babel/preset-env": "7.29.2", - "@babel/preset-typescript": "7.28.5", "@eslint/eslintrc": "3.3.5", "@eslint/js": "9.39.2", "@types/compression": "1.8.1", diff --git a/test.mjs b/test.mjs deleted file mode 100644 index 99e9497..0000000 --- a/test.mjs +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env zx - -await $`cat package.json | grep name` - -let branch = await $`git branch --show-current` -await $`dep deploy --branch=${branch}` - -await Promise.all([ - $`sleep 1; echo 1`, - $`sleep 2; echo 2`, - $`sleep 3; echo 3`, -]) - -let name = 'foo bar' -await $`mkdir /tmp/${name}` diff --git a/test/test-import.ts b/test/test-import.ts new file mode 100644 index 0000000..09d4d95 --- /dev/null +++ b/test/test-import.ts @@ -0,0 +1 @@ +import { run, MODE } from '../dist/index' \ No newline at end of file diff --git a/tsconfig.check-types.json b/tsconfig.check-types.json new file mode 100644 index 0000000..020b5d6 --- /dev/null +++ b/tsconfig.check-types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": ["test/test-import.ts"], + "compilerOptions": { + "noEmit": true, + "declaration": false, + "noUnusedLocals": false, + "noUnusedParameters": false + } +} From cd749850288b88aee97eb63c57122249e3c5d0a0 Mon Sep 17 00:00:00 2001 From: pillsilly Date: Mon, 18 May 2026 14:37:03 +0800 Subject: [PATCH 9/9] Release 1.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 51d87c2..8efbe59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "instantly_http", - "version": "1.3.0-2", + "version": "1.3.0", "description": "Tool to instantly create your own http server for development-use", "bin": "./dist/bin.js", "main": "./dist/index.js",