commit aeb59e8eda4791623a21728802843baa92e803c0 Author: Shihaam Abdul Rahman Date: Sat Aug 15 01:59:20 2026 +0500 pathes diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..0be9465 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..303ced4 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4e6f803 --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dade4b6 --- /dev/null +++ b/README.md @@ -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 && 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 -- > ../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-` + 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`). diff --git a/httptoolkit-server b/httptoolkit-server new file mode 160000 index 0000000..b9f2810 --- /dev/null +++ b/httptoolkit-server @@ -0,0 +1 @@ +Subproject commit b9f2810c5abcaed5463a1d906db6f8d458275711 diff --git a/httptoolkit-ui b/httptoolkit-ui new file mode 160000 index 0000000..237690c --- /dev/null +++ b/httptoolkit-ui @@ -0,0 +1 @@ +Subproject commit 237690ce485c6a2592aadaf92c5bbbcd1008430f diff --git a/overlay/server/htk-entry.ts b/overlay/server/htk-entry.ts new file mode 100644 index 0000000..8f2ea24 --- /dev/null +++ b/overlay/server/htk-entry.ts @@ -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 } }; + +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 to serve the web UI on (default: 8080) + --server-port HTK management API port (default: 45457) + --mockttp-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 { + 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); +}); diff --git a/overlay/server/src/commands/app.ts b/overlay/server/src/commands/app.ts new file mode 100644 index 0000000..a5ec38e --- /dev/null +++ b/overlay/server/src/commands/app.ts @@ -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'] + }); + } +} diff --git a/overlay/server/src/htk-app.ts b/overlay/server/src/htk-app.ts new file mode 100644 index 0000000..e64e6ee --- /dev/null +++ b/overlay/server/src/htk-app.ts @@ -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:/ 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 { + 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((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); +} diff --git a/patches/server/0001-allow-localhost-origins.patch b/patches/server/0001-allow-localhost-origins.patch new file mode 100644 index 0000000..b994cf5 --- /dev/null +++ b/patches/server/0001-allow-localhost-origins.patch @@ -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$/ + ] + : [ diff --git a/patches/ui/0001-stub-pro-account.patch b/patches/ui/0001-stub-pro-account.patch new file mode 100644 index 0000000..279985d --- /dev/null +++ b/patches/ui/0001-stub-pro-account.patch @@ -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. diff --git a/patches/ui/0002-remove-settings-account-card.patch b/patches/ui/0002-remove-settings-account-card.patch new file mode 100644 index 0000000..5a1f84b --- /dev/null +++ b/patches/ui/0002-remove-settings-account-card.patch @@ -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 { + ; + } + +- // ! because we know this is set, as we have a paid user +- const sub = userSubscription!; +- + return + + Settings + +- +-
+- +- Account +- +-
+- +- +- Account email +- +- +- { userEmail } +- +- +- +- Subscription status +- +- +- { +- ({ +- 'active': 'Active', +- 'trialing': 'Active (trial)', +- 'past_due': Past due , +- 'deleted': sub.expiry && isFuture(sub.expiry) +- ? `Active (until ${sub.expiry.toLocaleDateString()})` +- : 'Cancelled' +- }[sub.status]) || 'Unknown' +- } +- { isAccountUpdateInProcess && +- +- } +- +- +- +- Subscription plan +- +- +- { +- 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) +- } +- +- +- +- { +- ({ +- 'active': 'Next renews', +- 'trialing': 'Renews', +- 'past_due': 'Next payment attempt', +- 'deleted': 'Ends', +- }[sub.status]) || 'Current period ends' +- } +- +- +- { +- distanceInWordsStrict(new Date(), sub.expiry, { +- addSuffix: true, +- partialMethod: 'round' +- }) +- } ({ +- format(sub.expiry.toString(), 'Do [of] MMMM YYYY') +- }) +- +- +- +- +- { sub.lastReceiptUrl && +- +- View latest invoice +- +- } +- { canManageSubscription && <> +- { sub.updateBillingDetailsUrl && +- +- Update billing details +- +- } +- +- Cancel subscription +- { isAccountUpdateInProcess && +- +- } +- +- } +- Log out +- +- +- +- Questions? Email billing@httptoolkit.com +- +-
+- +- {/* +- 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) && diff --git a/patches/ui/0003-remove-feedback-sidebar.patch b/patches/ui/0003-remove-feedback-sidebar.patch new file mode 100644 index 0000000..3735a5f --- /dev/null +++ b/patches/ui/0003-remove-feedback-sidebar.patch @@ -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[]; + } + diff --git a/scripts/apply.sh b/scripts/apply.sh new file mode 100755 index 0000000..805c5c0 --- /dev/null +++ b/scripts/apply.sh @@ -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." diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..18cd312 --- /dev/null +++ b/scripts/build.sh @@ -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}" diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..80d49a9 --- /dev/null +++ b/scripts/smoke.sh @@ -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\|
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"