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