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

This commit is contained in:
2026-08-15 01:59:20 +05:00
commit aeb59e8eda
16 changed files with 862 additions and 0 deletions
+93
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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
+107
View File
@@ -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 httptoolkit-server added at b9f2810c5a
+1
Submodule httptoolkit-ui added at 237690ce48
+94
View File
@@ -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);
});
+37
View File
@@ -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']
});
}
}
+133
View File
@@ -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$/
]
: [
+50
View File
@@ -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[];
}
+44
View File
@@ -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."
+48
View File
@@ -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}"
+50
View File
@@ -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"