pathes
Build self-hosted HTTP Toolkit / build (linux-x64, ubuntu-latest, true) (push) Canceled after 0s
Build self-hosted HTTP Toolkit / build (macos-arm64, macos-latest, true) (push) Canceled after 0s
Build self-hosted HTTP Toolkit / build (windows-x64, windows-latest, false) (push) Canceled after 0s
Build self-hosted HTTP Toolkit / release (push) Canceled after 0s
Build self-hosted HTTP Toolkit / build (linux-x64, ubuntu-latest, true) (push) Canceled after 0s
Build self-hosted HTTP Toolkit / build (macos-arm64, macos-latest, true) (push) Canceled after 0s
Build self-hosted HTTP Toolkit / build (windows-x64, windows-latest, false) (push) Canceled after 0s
Build self-hosted HTTP Toolkit / release (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,93 @@
|
|||||||
|
name: Build self-hosted HTTP Toolkit
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # needed to publish releases on tags
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
name: linux-x64
|
||||||
|
smoke: true
|
||||||
|
- os: macos-latest # Apple Silicon runner (arm64)
|
||||||
|
name: macos-arm64
|
||||||
|
smoke: true
|
||||||
|
- os: windows-latest
|
||||||
|
name: windows-x64
|
||||||
|
smoke: false # background-process smoke is unix-only; --help still runs
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout (with submodules)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
OUT_NAME: httptoolkit
|
||||||
|
run: bash scripts/build.sh
|
||||||
|
|
||||||
|
- name: Smoke test (unix)
|
||||||
|
if: matrix.smoke
|
||||||
|
shell: bash
|
||||||
|
run: bash scripts/smoke.sh dist/httptoolkit
|
||||||
|
|
||||||
|
- name: Windows sanity (--help)
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
shell: bash
|
||||||
|
run: ./dist/httptoolkit.exe --help
|
||||||
|
|
||||||
|
- name: Stage artifact
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p out
|
||||||
|
if [ "${{ matrix.os }}" = "windows-latest" ]; then
|
||||||
|
cp dist/httptoolkit.exe "out/httptoolkit-${{ matrix.name }}.exe"
|
||||||
|
else
|
||||||
|
cp dist/httptoolkit "out/httptoolkit-${{ matrix.name }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: httptoolkit-${{ matrix.name }}
|
||||||
|
path: out/*
|
||||||
|
|
||||||
|
release:
|
||||||
|
# Publish a GitHub release only when a v* tag is pushed.
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
- name: Publish release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: artifacts/**/*
|
||||||
|
generate_release_notes: true
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Build outputs
|
||||||
|
/dist/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# Build artifacts staged inside the server submodule by scripts/build.sh
|
||||||
|
# (the submodule has its own .gitignore too; these are just in case)
|
||||||
|
/httptoolkit-server/ui.tar.gz
|
||||||
|
/httptoolkit-server/ui/
|
||||||
|
|
||||||
|
# Local tooling / editor
|
||||||
|
/.claude/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "httptoolkit-ui"]
|
||||||
|
path = httptoolkit-ui
|
||||||
|
url = https://github.com/httptoolkit/httptoolkit-ui.git
|
||||||
|
[submodule "httptoolkit-server"]
|
||||||
|
path = httptoolkit-server
|
||||||
|
url = https://github.com/httptoolkit/httptoolkit-server.git
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Self-hosted HTTP Toolkit
|
||||||
|
|
||||||
|
A private distribution repo that builds [HTTP Toolkit](https://httptoolkit.com) into a
|
||||||
|
**single self-contained binary** that runs the backend and serves the web UI locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./httptoolkit --port 7070
|
||||||
|
# → starts the proxy server and opens the web UI at http://localhost:7070
|
||||||
|
```
|
||||||
|
|
||||||
|
No Electron, no hosted UI, no account/login. Upstream is tracked as **git submodules**
|
||||||
|
and all local modifications live in this repo as **patches** + an **overlay**, so
|
||||||
|
updating to a new upstream release is a controlled, reviewable step.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── httptoolkit-ui/ # submodule → github.com/httptoolkit/httptoolkit-ui (pinned)
|
||||||
|
├── httptoolkit-server/ # submodule → github.com/httptoolkit/httptoolkit-server (pinned)
|
||||||
|
├── patches/
|
||||||
|
│ ├── ui/ # diffs against upstream UI files
|
||||||
|
│ └── server/ # diffs against upstream server files
|
||||||
|
├── overlay/server/ # NEW files copied into the server submodule (never conflict)
|
||||||
|
│ ├── htk-entry.ts # single-file binary entry (Bun)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── htk-app.ts # shared: run backend + serve UI + open browser
|
||||||
|
│ └── commands/app.ts # oclif fallback command
|
||||||
|
├── scripts/
|
||||||
|
│ ├── apply.sh # reset submodules → apply patches → copy overlay
|
||||||
|
│ ├── build.sh # build UI → embed → bun compile → dist/
|
||||||
|
│ └── smoke.sh # launch binary & verify it serves + backend is up
|
||||||
|
└── .github/workflows/build.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the patches change
|
||||||
|
|
||||||
|
| Kind | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| UI patch | `src/model/account/account-store.ts` | injects a synthetic active Pro subscription |
|
||||||
|
| UI patch | `src/components/settings/settings-page.tsx` | removes the account/subscription card |
|
||||||
|
| UI patch | `src/components/app.tsx` | removes the "Give feedback" sidebar button |
|
||||||
|
| Server patch | `src/constants.ts` | allow `localhost`/`127.0.0.1` origins in prod builds |
|
||||||
|
| Overlay | `htk-entry.ts`, `src/htk-app.ts`, `src/commands/app.ts` | serve the UI locally + `--port` |
|
||||||
|
|
||||||
|
**Security note:** upstream restricts a packaged build's API to `https://app.httptoolkit.tech`
|
||||||
|
so no *other* local page can drive your proxy. Because we self-host the UI on localhost we
|
||||||
|
must allow localhost origins (this matches upstream's own dev-mode allowlist). Trade-off: any
|
||||||
|
local HTTP page could talk to the server while it's running. Acceptable for a personal tool.
|
||||||
|
|
||||||
|
## Build locally
|
||||||
|
|
||||||
|
Requires **node ≥22**, **npm**, **bun**, and **tar**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git submodule update --init --recursive
|
||||||
|
bash scripts/build.sh # → dist/httptoolkit
|
||||||
|
bash scripts/smoke.sh # optional: verify it runs
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dist/httptoolkit --port 7070 # UI on :7070
|
||||||
|
./dist/httptoolkit --no-open # don't auto-open a browser
|
||||||
|
./dist/httptoolkit --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating to a new upstream release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# bump a submodule to a new tag
|
||||||
|
cd httptoolkit-ui && git fetch && git checkout v<new> && cd ..
|
||||||
|
git add httptoolkit-ui
|
||||||
|
|
||||||
|
# re-apply and see if patches still fit
|
||||||
|
bash scripts/apply.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If a patch no longer applies, `apply.sh` fails and names the patch. Fix it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd httptoolkit-ui
|
||||||
|
git checkout -- . # reset
|
||||||
|
# hand-apply the change, then regenerate the patch:
|
||||||
|
git diff -- <file> > ../patches/ui/000X-....patch
|
||||||
|
```
|
||||||
|
|
||||||
|
Overlay files never conflict (they're new files), so only the small `patches/` diffs
|
||||||
|
ever need attention on upgrade.
|
||||||
|
|
||||||
|
## Packaging notes / risks
|
||||||
|
|
||||||
|
- **Single file via Bun `--compile`.** The server pulls in native `.node` addons
|
||||||
|
(`node-datachannel`, `registry-js`, `adbkit`, …). These **cannot be cross-compiled**,
|
||||||
|
so CI builds each OS on its own runner (Linux/macOS/Windows matrix). The `smoke.sh`
|
||||||
|
step is the guard: if a native addon won't load from the embedded FS, it fails there.
|
||||||
|
- The UI is embedded as `ui.tar.gz` and extracted to `$TMPDIR/httptoolkit-ui-<version>`
|
||||||
|
on first run.
|
||||||
|
- No `HTK_SERVER_TOKEN` is set, so the local UI talks to the backend without a token.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
`.github/workflows/build.yml` builds the matrix on push/PR and publishes a GitHub Release
|
||||||
|
on `v*` tags. **If your CI is Gitea/Forgejo Actions**, this same file works under
|
||||||
|
`.gitea/workflows/` or `.github/workflows/`. **If it's GitLab**, it needs porting to
|
||||||
|
`.gitlab-ci.yml` (same steps: checkout w/ submodules → `scripts/build.sh` → `scripts/smoke.sh`).
|
||||||
Submodule
+1
Submodule httptoolkit-server added at b9f2810c5a
Submodule
+1
Submodule httptoolkit-ui added at 237690ce48
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
* Single-file binary entry point (compiled with `bun build --compile`).
|
||||||
|
*
|
||||||
|
* Not part of upstream. Copied to the server submodule root by scripts/apply.sh.
|
||||||
|
* The built UI is embedded into the binary as `ui.tar.gz` (created by
|
||||||
|
* scripts/build.sh) and extracted to a temp dir on first run.
|
||||||
|
*
|
||||||
|
* Usage: httptoolkit [--port 8080] [--server-port 45457] [--mockttp-port 45456] [--no-open]
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Bun global is present at runtime in the compiled binary:
|
||||||
|
declare const Bun: { file(path: string): { arrayBuffer(): Promise<ArrayBuffer> } };
|
||||||
|
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import { x as extractTar } from 'tar';
|
||||||
|
|
||||||
|
import { startHtkApp } from './src/htk-app';
|
||||||
|
import { SERVER_VERSION } from './src/constants';
|
||||||
|
|
||||||
|
// Embedded UI bundle. Resolves to a path Bun can read from the compiled binary:
|
||||||
|
import uiArchive from './ui.tar.gz' with { type: 'file' };
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`HTTP Toolkit (self-hosted single binary) v${SERVER_VERSION}
|
||||||
|
|
||||||
|
Usage: httptoolkit [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-p, --port <port> Port to serve the web UI on (default: 8080)
|
||||||
|
--server-port <port> HTK management API port (default: 45457)
|
||||||
|
--mockttp-port <port> Mockttp admin port (default: 45456)
|
||||||
|
--no-open Don't open a browser automatically
|
||||||
|
-h, --help Show this help
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]) {
|
||||||
|
const opts = { port: 8080, serverPort: 45457, mockttpPort: 45456, open: true };
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
const eq = a.indexOf('=');
|
||||||
|
const inlineVal = eq !== -1 ? a.slice(eq + 1) : undefined;
|
||||||
|
const key = eq !== -1 ? a.slice(0, eq) : a;
|
||||||
|
const val = () => inlineVal ?? argv[++i];
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case '-p': case '--port': opts.port = parseInt(val(), 10); break;
|
||||||
|
case '--server-port': opts.serverPort = parseInt(val(), 10); break;
|
||||||
|
case '--mockttp-port': opts.mockttpPort = parseInt(val(), 10); break;
|
||||||
|
case '--no-open': opts.open = false; break;
|
||||||
|
case '-h': case '--help': printHelp(); process.exit(0);
|
||||||
|
default:
|
||||||
|
console.error(`Unknown option: ${a}`);
|
||||||
|
printHelp();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(opts.port) || opts.port <= 0 || opts.port > 65535) {
|
||||||
|
console.error(`Invalid --port value`); process.exit(1);
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractUi(): Promise<string> {
|
||||||
|
const dir = path.join(os.tmpdir(), `httptoolkit-ui-${SERVER_VERSION}`);
|
||||||
|
const marker = path.join(dir, '.extracted');
|
||||||
|
if (fs.existsSync(marker)) return dir; // already extracted for this version
|
||||||
|
|
||||||
|
await fsp.rm(dir, { recursive: true, force: true });
|
||||||
|
await fsp.mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(await Bun.file(uiArchive).arrayBuffer());
|
||||||
|
const tmpArchive = path.join(dir, 'ui.tar.gz');
|
||||||
|
await fsp.writeFile(tmpArchive, bytes);
|
||||||
|
|
||||||
|
await extractTar({ file: tmpArchive, cwd: dir });
|
||||||
|
await fsp.unlink(tmpArchive).catch(() => {});
|
||||||
|
await fsp.writeFile(marker, '');
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const opts = parseArgs(process.argv.slice(2));
|
||||||
|
const uiDir = await extractUi();
|
||||||
|
await startHtkApp({ ...opts, uiDir });
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('Failed to start HTTP Toolkit:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* Fallback oclif command: `httptoolkit-server app`.
|
||||||
|
*
|
||||||
|
* Not part of upstream. Copied into the server submodule by scripts/apply.sh.
|
||||||
|
* This lets the same feature run via the classic oclif-packed build (or `npm`),
|
||||||
|
* as an alternative to the Bun single-file binary. Here the built UI is expected
|
||||||
|
* to live in a sibling `ui/` directory next to the package root (APP_ROOT).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as path from 'path';
|
||||||
|
import { Command, flags } from '@oclif/command';
|
||||||
|
|
||||||
|
import { APP_ROOT } from '../constants';
|
||||||
|
import { startHtkApp } from '../htk-app';
|
||||||
|
|
||||||
|
export default class App extends Command {
|
||||||
|
static description = 'run the HTK server and serve the bundled web UI locally';
|
||||||
|
|
||||||
|
static flags = {
|
||||||
|
help: flags.help({ char: 'h' }),
|
||||||
|
port: flags.integer({ char: 'p', description: 'port to serve the web UI on', default: 8080 }),
|
||||||
|
'server-port': flags.integer({ description: 'HTK management API port', default: 45457 }),
|
||||||
|
'mockttp-port': flags.integer({ description: 'Mockttp admin port', default: 45456 }),
|
||||||
|
'no-open': flags.boolean({ description: "don't open a browser automatically", default: false })
|
||||||
|
};
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
const { flags: f } = this.parse(App);
|
||||||
|
await startHtkApp({
|
||||||
|
uiDir: path.join(APP_ROOT, 'ui'),
|
||||||
|
port: f.port,
|
||||||
|
serverPort: f['server-port'],
|
||||||
|
mockttpPort: f['mockttp-port'],
|
||||||
|
open: !f['no-open']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* Self-hosted HTTP Toolkit: shared core logic.
|
||||||
|
*
|
||||||
|
* This is NOT part of upstream httptoolkit-server. It's copied into the server
|
||||||
|
* submodule by scripts/apply.sh. It starts the normal HTK backend (proxy admin
|
||||||
|
* + management API) via runHTK(), then serves the pre-built web UI over HTTP on
|
||||||
|
* a local port and (optionally) opens a browser.
|
||||||
|
*
|
||||||
|
* The UI connects back to 127.0.0.1:<serverPort>/<mockttpPort> at runtime (its
|
||||||
|
* built-in defaults), so as long as those match, everything wires up with no
|
||||||
|
* auth token required (we never set HTK_SERVER_TOKEN).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as net from 'net';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
|
import { runHTK } from './index';
|
||||||
|
|
||||||
|
const MIME: { [ext: string]: string } = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.mjs': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.wasm': 'application/wasm',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.ttf': 'font/ttf',
|
||||||
|
'.map': 'application/json; charset=utf-8',
|
||||||
|
'.txt': 'text/plain; charset=utf-8'
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentType = (file: string) =>
|
||||||
|
MIME[path.extname(file).toLowerCase()] || 'application/octet-stream';
|
||||||
|
|
||||||
|
function openBrowser(url: string) {
|
||||||
|
const platform = process.platform;
|
||||||
|
const [cmd, args] = platform === 'darwin'
|
||||||
|
? ['open', [url]]
|
||||||
|
: platform === 'win32'
|
||||||
|
? ['cmd', ['/c', 'start', '', url]]
|
||||||
|
: ['xdg-open', [url]];
|
||||||
|
try {
|
||||||
|
const child = spawn(cmd, args as string[], { stdio: 'ignore', detached: true });
|
||||||
|
// Never let a missing opener crash the app - the URL is printed anyway.
|
||||||
|
child.on('error', () => {});
|
||||||
|
child.unref();
|
||||||
|
} catch { /* ignore - user opens the printed URL manually */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveStaticUi(uiDir: string, port: number): Promise<http.Server> {
|
||||||
|
const indexHtml = path.join(uiDir, 'index.html');
|
||||||
|
const root = path.resolve(uiDir);
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
|
||||||
|
let filePath = path.resolve(root, '.' + path.posix.normalize(urlPath));
|
||||||
|
|
||||||
|
// Prevent path traversal outside the UI directory:
|
||||||
|
if (filePath !== root && !filePath.startsWith(root + path.sep)) {
|
||||||
|
res.writeHead(403); res.end('Forbidden'); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stat = await fsp.stat(filePath).catch(() => null);
|
||||||
|
if (stat && stat.isDirectory()) {
|
||||||
|
filePath = path.join(filePath, 'index.html');
|
||||||
|
stat = await fsp.stat(filePath).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA fallback: unknown non-file routes serve index.html so the UI's
|
||||||
|
// client-side router (reach/router) can handle them.
|
||||||
|
if (!stat) {
|
||||||
|
filePath = indexHtml;
|
||||||
|
stat = await fsp.stat(filePath).catch(() => null);
|
||||||
|
if (!stat) { res.writeHead(404); res.end('UI bundle not found'); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': contentType(filePath),
|
||||||
|
'Content-Length': stat.size
|
||||||
|
});
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
} catch {
|
||||||
|
if (!res.headersSent) res.writeHead(500);
|
||||||
|
res.end('Internal error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(port, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HtkAppOptions {
|
||||||
|
uiDir: string; // directory containing the built UI (index.html + assets)
|
||||||
|
port: number; // port to serve the UI on
|
||||||
|
serverPort: number; // HTK management API (UI default: 45457)
|
||||||
|
mockttpPort: number; // Mockttp admin (UI default: 45456)
|
||||||
|
open: boolean; // whether to auto-open a browser
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startHtkApp(opts: HtkAppOptions) {
|
||||||
|
// Match upstream start.ts: disable autoSelectFamily (unstable on Node 20+).
|
||||||
|
if (net.setDefaultAutoSelectFamily) net.setDefaultAutoSelectFamily(false);
|
||||||
|
|
||||||
|
// Start the real HTK backend. No auth token => the locally-served UI can
|
||||||
|
// talk to it without an ?authToken param.
|
||||||
|
await runHTK({
|
||||||
|
serverPort: opts.serverPort,
|
||||||
|
mockttpPort: opts.mockttpPort
|
||||||
|
});
|
||||||
|
|
||||||
|
await serveStaticUi(opts.uiDir, opts.port);
|
||||||
|
|
||||||
|
const url = `http://localhost:${opts.port}`;
|
||||||
|
console.log(`\n HTTP Toolkit is running at ${url}`);
|
||||||
|
console.log(` (management API: 127.0.0.1:${opts.serverPort}, mockttp: 127.0.0.1:${opts.mockttpPort})\n`);
|
||||||
|
|
||||||
|
if (opts.open) openBrowser(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
diff --git a/src/constants.ts b/src/constants.ts
|
||||||
|
index 8d295cc..16f02db 100644
|
||||||
|
--- a/src/constants.ts
|
||||||
|
+++ b/src/constants.ts
|
||||||
|
@@ -8,10 +8,13 @@ export const APP_ROOT = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
export const ALLOWED_ORIGINS = IS_PROD_BUILD
|
||||||
|
? [
|
||||||
|
- // Prod builds only allow HTTPS app.httptoolkit.tech usage. This
|
||||||
|
- // ensures that no other sites/apps can communicate with your server
|
||||||
|
- // whilst you have the app open. If they could (requires an HTTP mitm),
|
||||||
|
- // they would be able to start proxies & interceptors.
|
||||||
|
+ // Self-hosted build: the UI is served locally by this same binary, so we
|
||||||
|
+ // must allow localhost origins here (upstream prod allows only
|
||||||
|
+ // app.httptoolkit.tech). This matches upstream's *dev* allowlist. Trade-off:
|
||||||
|
+ // any local http page could talk to the server while it's running.
|
||||||
|
+ /^https?:\/\/localhost(:\d+)?$/,
|
||||||
|
+ /^https?:\/\/127\.0\.0\.\d+(:\d+)?$/,
|
||||||
|
+ /^http:\/\/local\.httptoolkit\.tech(:\d+)?$/,
|
||||||
|
/^https:\/\/app\.httptoolkit\.tech$/
|
||||||
|
]
|
||||||
|
: [
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
diff --git a/src/model/account/account-store.ts b/src/model/account/account-store.ts
|
||||||
|
index c5595776..5c05538a 100644
|
||||||
|
--- a/src/model/account/account-store.ts
|
||||||
|
+++ b/src/model/account/account-store.ts
|
||||||
|
@@ -28,6 +28,27 @@ import {
|
||||||
|
// Fund open source - if you want Pro, help pay for its development.
|
||||||
|
// Can't afford it? Get in touch: tim@httptoolkit.com.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
+
|
||||||
|
+// Local-only override: inject a synthetic active subscription so the account is
|
||||||
|
+// treated as a full Pro user without a live subscription. We set the underlying
|
||||||
|
+// data (rather than overriding isPaidUser() etc.) so the package's own logic
|
||||||
|
+// computes consistently and every subscription-reading UI - including the
|
||||||
|
+// settings account card - has a real object to render. Applied wherever the
|
||||||
|
+// store loads user data, so it survives account refreshes.
|
||||||
|
+const forceProUser = (user: User): User => {
|
||||||
|
+ user.subscription = {
|
||||||
|
+ status: 'active',
|
||||||
|
+ quantity: 1,
|
||||||
|
+ expiry: new Date('2999-12-31T00:00:00Z'),
|
||||||
|
+ sku: 'pro-perpetual',
|
||||||
|
+ tierCode: 'pro',
|
||||||
|
+ interval: 'perpetual',
|
||||||
|
+ plan: 'pro-perpetual',
|
||||||
|
+ canManageSubscription: false
|
||||||
|
+ };
|
||||||
|
+ return user;
|
||||||
|
+};
|
||||||
|
+
|
||||||
|
export class AccountStore {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@@ -81,7 +102,7 @@ export class AccountStore {
|
||||||
|
});
|
||||||
|
|
||||||
|
@observable
|
||||||
|
- user: User = getLastUserData();
|
||||||
|
+ user: User = forceProUser(getLastUserData());
|
||||||
|
|
||||||
|
@observable
|
||||||
|
accountDataLastUpdated = 0;
|
||||||
|
@@ -117,7 +138,7 @@ export class AccountStore {
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateUser = flow(function * (this: AccountStore) {
|
||||||
|
- this.user = yield getLatestUserData();
|
||||||
|
+ this.user = forceProUser(yield getLatestUserData());
|
||||||
|
this.accountDataLastUpdated = Date.now();
|
||||||
|
|
||||||
|
// Include the user id in error reports whilst they're logged in.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
diff --git a/src/components/settings/settings-page.tsx b/src/components/settings/settings-page.tsx
|
||||||
|
index ab867e9f..dae383cf 100644
|
||||||
|
--- a/src/components/settings/settings-page.tsx
|
||||||
|
+++ b/src/components/settings/settings-page.tsx
|
||||||
|
@@ -142,135 +142,10 @@ class SettingsPage extends React.Component<SettingsPageProps> {
|
||||||
|
</SettingsPagePlaceholder>;
|
||||||
|
}
|
||||||
|
|
||||||
|
- // ! because we know this is set, as we have a paid user
|
||||||
|
- const sub = userSubscription!;
|
||||||
|
-
|
||||||
|
return <SettingsPageScrollContainer>
|
||||||
|
<SettingPageContainer>
|
||||||
|
<SettingsHeading>Settings</SettingsHeading>
|
||||||
|
|
||||||
|
- <CollapsibleCard {...cardProps.account}>
|
||||||
|
- <header>
|
||||||
|
- <CollapsibleCardHeading onCollapseToggled={
|
||||||
|
- cardProps.account.onCollapseToggled
|
||||||
|
- }>
|
||||||
|
- Account
|
||||||
|
- </CollapsibleCardHeading>
|
||||||
|
- </header>
|
||||||
|
- <AccountDetailsContainer>
|
||||||
|
- <ContentLabel>
|
||||||
|
- Account email
|
||||||
|
- </ContentLabel>
|
||||||
|
- <ContentValue>
|
||||||
|
- { userEmail }
|
||||||
|
- </ContentValue>
|
||||||
|
-
|
||||||
|
- <ContentLabel>
|
||||||
|
- Subscription status
|
||||||
|
- </ContentLabel>
|
||||||
|
- <ContentValue>
|
||||||
|
- {
|
||||||
|
- ({
|
||||||
|
- 'active': 'Active',
|
||||||
|
- 'trialing': 'Active (trial)',
|
||||||
|
- 'past_due': <strong
|
||||||
|
- title={dedent`
|
||||||
|
- Your subscription payment failed, and will be reattempted.
|
||||||
|
- If retried payments fail your subscription will be cancelled.
|
||||||
|
- `}
|
||||||
|
- >Past due <WarningIcon /></strong>,
|
||||||
|
- 'deleted': sub.expiry && isFuture(sub.expiry)
|
||||||
|
- ? `Active (until ${sub.expiry.toLocaleDateString()})`
|
||||||
|
- : 'Cancelled'
|
||||||
|
- }[sub.status]) || 'Unknown'
|
||||||
|
- }
|
||||||
|
- { isAccountUpdateInProcess &&
|
||||||
|
- <AccountUpdateSpinner />
|
||||||
|
- }
|
||||||
|
- </ContentValue>
|
||||||
|
-
|
||||||
|
- <ContentLabel>
|
||||||
|
- Subscription plan
|
||||||
|
- </ContentLabel>
|
||||||
|
- <ContentValue>
|
||||||
|
- {
|
||||||
|
- subscriptionPlans.state === 'fulfilled'
|
||||||
|
- ? (subscriptionPlans.value as SubscriptionPlans)[sub.sku]?.name
|
||||||
|
- // If the accounts API is unavailable for plan metadata for some reason, we can just
|
||||||
|
- // format the raw SKU to get something workable, no worries:
|
||||||
|
- : _.startCase(sub.sku)
|
||||||
|
- }
|
||||||
|
- </ContentValue>
|
||||||
|
-
|
||||||
|
- <ContentLabel>
|
||||||
|
- {
|
||||||
|
- ({
|
||||||
|
- 'active': 'Next renews',
|
||||||
|
- 'trialing': 'Renews',
|
||||||
|
- 'past_due': 'Next payment attempt',
|
||||||
|
- 'deleted': 'Ends',
|
||||||
|
- }[sub.status]) || 'Current period ends'
|
||||||
|
- }
|
||||||
|
- </ContentLabel>
|
||||||
|
- <ContentValue>
|
||||||
|
- {
|
||||||
|
- distanceInWordsStrict(new Date(), sub.expiry, {
|
||||||
|
- addSuffix: true,
|
||||||
|
- partialMethod: 'round'
|
||||||
|
- })
|
||||||
|
- } ({
|
||||||
|
- format(sub.expiry.toString(), 'Do [of] MMMM YYYY')
|
||||||
|
- })
|
||||||
|
- </ContentValue>
|
||||||
|
- </AccountDetailsContainer>
|
||||||
|
-
|
||||||
|
- <AccountControls>
|
||||||
|
- { sub.lastReceiptUrl &&
|
||||||
|
- <SettingsButtonLink
|
||||||
|
- href={ sub.lastReceiptUrl }
|
||||||
|
- target='_blank'
|
||||||
|
- rel='noreferrer noopener'
|
||||||
|
- >
|
||||||
|
- View latest invoice
|
||||||
|
- </SettingsButtonLink>
|
||||||
|
- }
|
||||||
|
- { canManageSubscription && <>
|
||||||
|
- { sub.updateBillingDetailsUrl &&
|
||||||
|
- <SettingsButtonLink
|
||||||
|
- href={sub.updateBillingDetailsUrl}
|
||||||
|
- target='_blank'
|
||||||
|
- rel='noreferrer noopener'
|
||||||
|
- highlight={sub.status === 'past_due'}
|
||||||
|
- >
|
||||||
|
- Update billing details
|
||||||
|
- </SettingsButtonLink>
|
||||||
|
- }
|
||||||
|
- <SettingsButton
|
||||||
|
- onClick={this.confirmSubscriptionCancellation}
|
||||||
|
- disabled={isAccountUpdateInProcess}
|
||||||
|
- >
|
||||||
|
- Cancel subscription
|
||||||
|
- { isAccountUpdateInProcess &&
|
||||||
|
- <AccountUpdateSpinner />
|
||||||
|
- }
|
||||||
|
- </SettingsButton>
|
||||||
|
- </> }
|
||||||
|
- <SettingsButton onClick={logOut}>Log out</SettingsButton>
|
||||||
|
- </AccountControls>
|
||||||
|
-
|
||||||
|
- <AccountContactFooter>
|
||||||
|
- Questions? Email <strong>billing@httptoolkit.com</strong>
|
||||||
|
- </AccountContactFooter>
|
||||||
|
- </CollapsibleCard>
|
||||||
|
-
|
||||||
|
- {/*
|
||||||
|
- The above shows for both active paid users, and recently paid users whose most recent
|
||||||
|
- payments failed. For those users, we drop other Pro features, but keep the settings
|
||||||
|
- UI so they can easily log out, update billing details or cancel fully.
|
||||||
|
-
|
||||||
|
- The rest is active paid users only:
|
||||||
|
- */}
|
||||||
|
-
|
||||||
|
{ user.isPaidUser() && <>
|
||||||
|
{
|
||||||
|
_.isString(serverVersion.value) &&
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
diff --git a/src/components/app.tsx b/src/components/app.tsx
|
||||||
|
index 0fbf3e39..805c08a6 100644
|
||||||
|
--- a/src/components/app.tsx
|
||||||
|
+++ b/src/components/app.tsx
|
||||||
|
@@ -212,17 +212,7 @@ class App extends React.Component<{
|
||||||
|
onClick: this.props.uiStore.openMcpModal
|
||||||
|
}]
|
||||||
|
: []
|
||||||
|
- ),
|
||||||
|
-
|
||||||
|
- {
|
||||||
|
- name: 'Give feedback',
|
||||||
|
- title: "Suggest features or report issues",
|
||||||
|
- icon: 'ChatText',
|
||||||
|
- position: 'bottom',
|
||||||
|
- highlight: true,
|
||||||
|
- type: 'web',
|
||||||
|
- url: 'https://github.com/httptoolkit/httptoolkit/issues/new/choose'
|
||||||
|
- }
|
||||||
|
+ )
|
||||||
|
] as SidebarItem[];
|
||||||
|
}
|
||||||
|
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Apply our patches + overlay onto the pristine upstream submodules.
|
||||||
|
#
|
||||||
|
# Fails loudly if a patch no longer applies (i.e. upstream moved after a
|
||||||
|
# submodule bump), so you know exactly which patch to refresh. Idempotent:
|
||||||
|
# it resets each submodule's tracked files to the pinned commit first.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
echo "==> Ensuring submodules are initialised & at pinned commits"
|
||||||
|
git submodule update --init --recursive
|
||||||
|
|
||||||
|
apply_patches() {
|
||||||
|
local name="$1" dir="$2"
|
||||||
|
if [ ! -d "patches/$name" ]; then return; fi
|
||||||
|
|
||||||
|
echo "==> [$name] resetting to pinned commit"
|
||||||
|
git -C "$dir" checkout -- . 2>/dev/null || true
|
||||||
|
git -C "$dir" clean -fd -e node_modules 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "==> [$name] applying patches"
|
||||||
|
local applied=0
|
||||||
|
for patch in patches/"$name"/*.patch; do
|
||||||
|
[ -e "$patch" ] || continue
|
||||||
|
echo " - $(basename "$patch")"
|
||||||
|
# --3way lets git fall back to a merge if context shifted slightly.
|
||||||
|
git -C "$dir" apply --3way "$ROOT/$patch"
|
||||||
|
applied=$((applied + 1))
|
||||||
|
done
|
||||||
|
echo "==> [$name] $applied patch(es) applied"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_patches ui httptoolkit-ui
|
||||||
|
apply_patches server httptoolkit-server
|
||||||
|
|
||||||
|
echo "==> Copying overlay files into httptoolkit-server"
|
||||||
|
cp -v overlay/server/htk-entry.ts httptoolkit-server/htk-entry.ts
|
||||||
|
cp -v overlay/server/src/htk-app.ts httptoolkit-server/src/htk-app.ts
|
||||||
|
cp -v overlay/server/src/commands/app.ts httptoolkit-server/src/commands/app.ts
|
||||||
|
|
||||||
|
echo "==> apply.sh complete."
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Build the self-hosted single-file HTTP Toolkit binary.
|
||||||
|
#
|
||||||
|
# Requires: node (>=22), npm, bun, tar. Runs per-OS in CI (native modules can't
|
||||||
|
# be cross-compiled), so this builds for the *host* platform.
|
||||||
|
#
|
||||||
|
# Env:
|
||||||
|
# OUT_NAME output binary basename (default: httptoolkit)
|
||||||
|
# BUN_TARGET optional bun --target (default: host); e.g. bun-linux-x64
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
OUT_NAME="${OUT_NAME:-httptoolkit}"
|
||||||
|
BUN_TARGET="${BUN_TARGET:-}"
|
||||||
|
|
||||||
|
# The UI's postinstall pulls Puppeteer's Chromium (only needed for its own
|
||||||
|
# integration tests) - skip it, it's large and irrelevant to the build.
|
||||||
|
export PUPPETEER_SKIP_DOWNLOAD=true
|
||||||
|
export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||||
|
|
||||||
|
EXT=""
|
||||||
|
case "${BUN_TARGET}" in *windows*) EXT=".exe";; esac
|
||||||
|
if [ -z "$BUN_TARGET" ] && [ "${OS:-}" = "Windows_NT" ]; then EXT=".exe"; fi
|
||||||
|
|
||||||
|
echo "==> 1/5 Apply patches + overlay"
|
||||||
|
bash scripts/apply.sh
|
||||||
|
|
||||||
|
echo "==> 2/5 Build UI -> httptoolkit-ui/dist"
|
||||||
|
( cd httptoolkit-ui && npm ci && npm run build )
|
||||||
|
|
||||||
|
echo "==> 3/5 Install server deps (+ tar for the entry) & embed the UI"
|
||||||
|
( cd httptoolkit-server \
|
||||||
|
&& npm ci \
|
||||||
|
&& npm install --no-save tar@^7 \
|
||||||
|
&& tar -czf ui.tar.gz -C ../httptoolkit-ui/dist . \
|
||||||
|
&& rm -rf ui && cp -r ../httptoolkit-ui/dist ui )
|
||||||
|
|
||||||
|
echo "==> 4/5 Compile single-file binary with Bun"
|
||||||
|
mkdir -p dist
|
||||||
|
COMPILE=(build ./htk-entry.ts --compile --outfile "$ROOT/dist/${OUT_NAME}${EXT}")
|
||||||
|
if [ -n "$BUN_TARGET" ]; then COMPILE+=(--target "$BUN_TARGET"); fi
|
||||||
|
( cd httptoolkit-server && bun "${COMPILE[@]}" )
|
||||||
|
|
||||||
|
echo "==> 5/5 Done -> dist/${OUT_NAME}${EXT}"
|
||||||
|
ls -lh "dist/${OUT_NAME}${EXT}"
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Smoke test: launch the built binary and verify it actually serves the UI and
|
||||||
|
# starts the backend. Fails loudly (non-zero) if a native module won't load or
|
||||||
|
# the server never comes up - this is the main guard for the single-file build.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
BIN="${1:-dist/httptoolkit}"
|
||||||
|
PORT="${PORT:-8099}"
|
||||||
|
SERVER_PORT="${SERVER_PORT:-45457}"
|
||||||
|
|
||||||
|
echo "==> --help must exit 0"
|
||||||
|
"$BIN" --help
|
||||||
|
|
||||||
|
echo "==> launching $BIN on port $PORT"
|
||||||
|
"$BIN" --port "$PORT" --server-port "$SERVER_PORT" --no-open &
|
||||||
|
PID=$!
|
||||||
|
trap 'kill "$PID" 2>/dev/null || true' EXIT
|
||||||
|
|
||||||
|
# Wait up to ~30s for the UI port to answer:
|
||||||
|
ok=0
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if curl -fsS "http://127.0.0.1:${PORT}/" -o /tmp/htk-index.html 2>/dev/null; then
|
||||||
|
ok=1; break
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$PID" 2>/dev/null; then
|
||||||
|
echo "!! binary exited early"; exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
[ "$ok" = 1 ] || { echo "!! UI never responded on :$PORT"; exit 1; }
|
||||||
|
|
||||||
|
echo "==> UI responded; checking it looks like the HTK app"
|
||||||
|
grep -qi 'httptoolkit\|<div id="app"\|<title' /tmp/htk-index.html \
|
||||||
|
|| { echo "!! served index.html doesn't look like the UI"; exit 1; }
|
||||||
|
|
||||||
|
echo "==> checking management API is listening on :$SERVER_PORT"
|
||||||
|
# corsGate rejects requests without an allowed Origin with a JSON 403 - that's a
|
||||||
|
# *successful* connection (the server is up), which is all we need to confirm.
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H 'Origin: http://localhost:'"$PORT" \
|
||||||
|
"http://127.0.0.1:${SERVER_PORT}/" || true)
|
||||||
|
echo " management API HTTP status: $code"
|
||||||
|
[ -n "$code" ] && [ "$code" != "000" ] \
|
||||||
|
|| { echo "!! management API not reachable"; exit 1; }
|
||||||
|
|
||||||
|
echo "==> SMOKE TEST PASSED"
|
||||||
Reference in New Issue
Block a user