This commit is contained in:
@@ -22,12 +22,20 @@ echo "==> Building UI"
|
||||
( cd httptoolkit-ui && npm ci && npm run build )
|
||||
|
||||
echo "==> Preparing server + embedding UI"
|
||||
# overrides/ is the interception support tree (terminal shims, JVM agent, frida,
|
||||
# webextension). Upstream reads it off disk at APP_ROOT/overrides, so it has to be
|
||||
# embedded too - the binary extracts both archives on startup. -p keeps the +x bits
|
||||
# on the shims in overrides/path.
|
||||
( cd httptoolkit-server \
|
||||
&& npm ci \
|
||||
&& npm install --no-save tar@^7 \
|
||||
&& tar -czf ui.tar.gz -C ../httptoolkit-ui/dist . \
|
||||
&& tar -czpf overrides.tar.gz -C overrides . \
|
||||
&& rm -rf ui && cp -r ../httptoolkit-ui/dist ui )
|
||||
|
||||
echo "==> Patching installed deps for bun"
|
||||
bash scripts/patch-node-modules.sh
|
||||
|
||||
echo "==> Compiling single-file binary -> dist/httptoolkit"
|
||||
mkdir -p dist
|
||||
( cd httptoolkit-server && bun build ./htk-entry.ts --compile --outfile "$ROOT/dist/httptoolkit" )
|
||||
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Bun-compatibility fixups applied to installed dependencies. Runs AFTER `npm ci`
|
||||
# (it edits node_modules) and BEFORE `bun build --compile`.
|
||||
#
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT/httptoolkit-server"
|
||||
|
||||
# TypeScript's UMD emit wraps the module body in a factory that takes `require`
|
||||
# as a *parameter*:
|
||||
#
|
||||
# })(function (require, exports) {
|
||||
# var bplist_creator_1 = __importDefault(require("bplist-creator"));
|
||||
#
|
||||
# That parameter shadows the module-scope `require`, so bun's bundler can't
|
||||
# statically link the call and leaves it as a runtime require. In a --compile'd
|
||||
# binary there is no node_modules to fall back on, so the import fails at startup
|
||||
# ("Cannot find package 'bplist-creator'"). Note that merely forcing the package
|
||||
# into the bundle does NOT help: bun's runtime require only resolves call sites
|
||||
# it linked at build time.
|
||||
#
|
||||
# Renaming the parameter un-shadows `require`, so the calls bind to the
|
||||
# module-scope require that bun does link. The CJS branch still passes the real
|
||||
# require in as the (now unused) first argument, so behaviour is unchanged.
|
||||
deshadow_umd_require() {
|
||||
local dir="$1" found=0 already=0
|
||||
for file in "$dir"/*.js; do
|
||||
[ -e "$file" ] || continue
|
||||
if grep -q '})(function (require, exports) {' "$file"; then
|
||||
sed -i 's/})(function (require, exports) {/})(function (__umdRequire, exports) {/' "$file"
|
||||
found=$((found + 1))
|
||||
elif grep -q '__umdRequire' "$file"; then
|
||||
already=$((already + 1))
|
||||
fi
|
||||
done
|
||||
echo " - $dir: patched $found, already patched $already"
|
||||
# Idempotent: re-running over an existing tree is fine. Only a tree with
|
||||
# neither form means the dependency changed shape under us.
|
||||
[ $((found + already)) -gt 0 ] \
|
||||
|| { echo "!! no UMD wrappers found in $dir (dependency changed?)"; exit 1; }
|
||||
}
|
||||
|
||||
echo "==> [node_modules] de-shadowing UMD require() calls for bun"
|
||||
# Reached via @httptoolkit/browser-launcher -> simple-plist (darwin browser
|
||||
# detection). Bun bundles every platform branch regardless of target, so this
|
||||
# has to resolve even in a Linux build.
|
||||
deshadow_umd_require node_modules/simple-plist/dist
|
||||
|
||||
# Native .node addons only get embedded into the compiled binary if bun can
|
||||
# resolve the require statically. node-datachannel (via mockrtc) instead builds a
|
||||
# require with module.createRequire():
|
||||
#
|
||||
# const require$1 = module$1.createRequire(pathToFileURL(__filename).href);
|
||||
# const nodeDataChannel = require$1('../build/Release/node_datachannel.node');
|
||||
#
|
||||
# bun can't see through that, so the addon is left out and the binary resolves it
|
||||
# relative to the *build-time* __filename at runtime - working on the build
|
||||
# machine while failing everywhere else with "Cannot find module
|
||||
# '../build/Release/node_datachannel.node'". Using a plain literal require makes
|
||||
# bun embed the addon as an asset instead.
|
||||
replace_literal() {
|
||||
local file="$1" from="$2" to="$3"
|
||||
[ -f "$file" ] || { echo "!! expected $file to exist (dependency changed?)"; exit 1; }
|
||||
if ! grep -qF "$from" "$file"; then
|
||||
# Idempotent: already-applied is fine, a missing target is not.
|
||||
if grep -qF "$to" "$file"; then
|
||||
echo " - $file: already patched"
|
||||
return 0
|
||||
fi
|
||||
echo "!! pattern not found in $file: $from"; exit 1
|
||||
fi
|
||||
python3 - "$file" "$from" "$to" <<'PY'
|
||||
import sys
|
||||
path, old, new = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
with open(path) as f: content = f.read()
|
||||
with open(path, 'w') as f: f.write(content.replace(old, new))
|
||||
PY
|
||||
echo " - $file: $from -> $to"
|
||||
}
|
||||
|
||||
echo "==> [node_modules] making native addon requires statically resolvable"
|
||||
replace_literal node_modules/node-datachannel/lib/index.cjs \
|
||||
"require\$1('../build/Release/node_datachannel.node')" \
|
||||
"require('../build/Release/node_datachannel.node')"
|
||||
|
||||
# bun ships its own built-in `ws` shim and resolves the bare specifier "ws" to it,
|
||||
# shadowing the real npm package. That shim has no createWebSocketStream (and no
|
||||
# Receiver): it throws "Not supported yet in Bun". mockttp's admin server calls
|
||||
# Ws.createWebSocketStream() for every admin client connection, so the server
|
||||
# process died the moment the UI opened a session - after the UI had already
|
||||
# loaded and looked fine.
|
||||
#
|
||||
# Requiring a subpath instead of the bare name escapes bun's shim and gets the
|
||||
# real ws@8, which implements all of this. ws' exports map doesn't publish
|
||||
# ./index.js, so add it before pointing anything at it.
|
||||
redirect_ws_to_real_package() {
|
||||
local dir="$1" count=0
|
||||
# Publish the subpath we're about to require:
|
||||
if [ "$(jq -r '.exports["./index.js"] // "none"' node_modules/ws/package.json)" = "none" ]; then
|
||||
jq '.exports["./index.js"] = "./index.js"' node_modules/ws/package.json > node_modules/ws/package.json.tmp
|
||||
mv node_modules/ws/package.json.tmp node_modules/ws/package.json
|
||||
echo " - ws/package.json: exposed ./index.js subpath"
|
||||
else
|
||||
echo " - ws/package.json: subpath already exposed"
|
||||
fi
|
||||
|
||||
while IFS= read -r file; do
|
||||
sed -i 's|require("ws")|require("ws/index.js")|g' "$file"
|
||||
echo " - ${file#node_modules/}: -> ws/index.js"
|
||||
count=$((count + 1))
|
||||
done < <(grep -rl 'require("ws")' "$dir" --include=*.js 2>/dev/null || true)
|
||||
|
||||
local already
|
||||
already=$(grep -rl 'require("ws/index.js")' "$dir" --include=*.js 2>/dev/null | wc -l)
|
||||
[ "$already" -gt 0 ] \
|
||||
|| { echo "!! no bare ws requires found in $dir (dependency changed?)"; exit 1; }
|
||||
}
|
||||
|
||||
echo "==> [node_modules] redirecting ws requires past bun's built-in shim"
|
||||
redirect_ws_to_real_package node_modules/mockttp/dist
|
||||
|
||||
echo "==> patch-node-modules.sh complete."
|
||||
@@ -21,6 +21,56 @@ apply_patches() {
|
||||
apply_patches ui httptoolkit-ui
|
||||
apply_patches server httptoolkit-server
|
||||
|
||||
# `src/config.d.ts` is a types-only module: tsc erases `import { HtkConfig } from
|
||||
# './config'` entirely, but bun's bundler resolves imports for real and fails
|
||||
# ("Could not resolve: ./config") because a .d.ts isn't a bundleable source file.
|
||||
# Promote it to a real .ts module so bun can resolve it - it holds only exported
|
||||
# interfaces, so it compiles down to nothing. Derived from upstream rather than
|
||||
# vendored, so it can't drift out of sync with the real type.
|
||||
promote_dts_to_ts() {
|
||||
local dts="$1" ts="${1%.d.ts}.ts"
|
||||
[ -f "$dts" ] || { echo "!! expected $dts to exist (upstream moved?)"; exit 1; }
|
||||
# Anything other than exported type declarations would need real runtime code:
|
||||
if grep -qE '^\s*(declare|import|export\s+(const|let|var|function|class|default))' "$dts"; then
|
||||
echo "!! $dts is no longer types-only - it needs a real runtime module now"
|
||||
exit 1
|
||||
fi
|
||||
echo " - $dts -> $ts"
|
||||
mv "$dts" "$ts"
|
||||
}
|
||||
|
||||
echo "==> [server] promoting types-only modules to real modules (for bun)"
|
||||
promote_dts_to_ts httptoolkit-server/src/config.d.ts
|
||||
|
||||
# Upstream targets es2020 and never sets useDefineForClassFields, so tsc defaults
|
||||
# it to false: class field initialisers are emitted *inside* the constructor,
|
||||
# after parameter properties are assigned. Bun ignores that inferred default and
|
||||
# emits native ES class fields, which run BEFORE the constructor body - so any
|
||||
# class doing `constructor(private config: X)` plus a field initialiser that
|
||||
# reads `this.config` (e.g. ElectronInterceptor.certData) crashes with
|
||||
# "undefined is not an object". Set the flag explicitly to restore tsc semantics.
|
||||
# APP_ROOT is derived from __dirname, which in a compiled binary is a build-machine
|
||||
# path that doesn't exist for users. Everything hanging off it then breaks at
|
||||
# runtime - OVERRIDES_DIR most visibly ("Unable to access jarfile
|
||||
# .../overrides/java-agent.jar", "lstat .../overrides/webextension"), which takes
|
||||
# out JVM/terminal interception and the WebRTC webextension. Make it overridable so
|
||||
# htk-entry.ts can point it at the directory it extracts the embedded overrides to.
|
||||
echo "==> [server] making APP_ROOT overridable via HTK_APP_ROOT"
|
||||
APP_ROOT_LINE="export const APP_ROOT = path.join(__dirname, '..');"
|
||||
if grep -qF "$APP_ROOT_LINE" httptoolkit-server/src/constants.ts; then
|
||||
sed -i "s|$APP_ROOT_LINE|export const APP_ROOT = process.env.HTK_APP_ROOT \|\| path.join(__dirname, '..');|" \
|
||||
httptoolkit-server/src/constants.ts
|
||||
elif grep -q 'HTK_APP_ROOT' httptoolkit-server/src/constants.ts; then
|
||||
echo " - already applied"
|
||||
else
|
||||
echo "!! APP_ROOT definition not found in constants.ts (upstream moved?)"; exit 1
|
||||
fi
|
||||
|
||||
echo "==> [server] pinning useDefineForClassFields=false (tsc semantics for bun)"
|
||||
jq '.compilerOptions.useDefineForClassFields = false' \
|
||||
httptoolkit-server/tsconfig.json > httptoolkit-server/tsconfig.json.tmp
|
||||
mv httptoolkit-server/tsconfig.json.tmp httptoolkit-server/tsconfig.json
|
||||
|
||||
echo "==> Copying overlay 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
|
||||
|
||||
@@ -37,6 +37,25 @@ 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; }
|
||||
|
||||
# The WebSocket/subscription path is where bun's built-in `ws` shim used to kill
|
||||
# the server, and no amount of HTTP probing reaches it. Needs node + the server's
|
||||
# node_modules for the mockttp client, so it's skipped in the self-containment
|
||||
# run (where the source tree is deliberately hidden).
|
||||
if [ "${WS_CHECK:-0}" = "1" ]; then
|
||||
MOCKTTP_PORT="${MOCKTTP_PORT:-45456}"
|
||||
NM="$ROOT/httptoolkit-server/node_modules"
|
||||
if [ -d "$NM" ] && command -v node > /dev/null; then
|
||||
echo "==> checking admin WebSocket + subscription stream on :$MOCKTTP_PORT"
|
||||
NODE_PATH="$NM" node "$ROOT/scripts/ws-check.js" \
|
||||
"http://127.0.0.1:${MOCKTTP_PORT}" "http://localhost:${PORT}" \
|
||||
|| { echo "!! admin WebSocket check failed"; exit 1; }
|
||||
# That check is only meaningful if it didn't take the server down with it:
|
||||
kill -0 "$PID" 2>/dev/null || { echo "!! binary died during WebSocket check"; exit 1; }
|
||||
else
|
||||
echo "==> SKIP WebSocket check (needs node + httptoolkit-server/node_modules)"
|
||||
fi
|
||||
fi
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Exercises the admin-server WebSocket path that the UI uses, which the plain
|
||||
* HTTP smoke test never touches.
|
||||
*
|
||||
* mockttp's admin server wraps each admin client's connection with
|
||||
* Ws.createWebSocketStream(). Under bun's built-in `ws` shim that throws
|
||||
* "Not supported yet in Bun" and kills the whole server the moment the UI opens a
|
||||
* session - so a binary can pass every HTTP check and still die on first real use.
|
||||
*
|
||||
* Run with node (this is a *client*; the binary under test is the server).
|
||||
* Usage: node ws-check.js [adminServerUrl]
|
||||
*/
|
||||
const mockttp = require('mockttp');
|
||||
|
||||
const adminServerUrl = process.argv[2] || 'http://127.0.0.1:45456';
|
||||
// The admin server runs corsOptions.strict, so it requires an Origin from
|
||||
// MOCKTTP_ALLOWED_ORIGINS - the same one the browser UI sends.
|
||||
const origin = process.argv[3] || 'http://localhost:8099';
|
||||
|
||||
(async () => {
|
||||
const server = mockttp.getRemote({
|
||||
adminServerUrl,
|
||||
client: { headers: { origin } }
|
||||
});
|
||||
|
||||
// start() opens the admin WebSocket + subscription stream. This is the call
|
||||
// that used to crash the server process.
|
||||
await server.start();
|
||||
console.log(` admin session started, mock server on port ${server.port}`);
|
||||
|
||||
await server.forGet('/ws-check').thenReply(200, 'ws-check-ok');
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${server.port}/ws-check`);
|
||||
const body = await res.text();
|
||||
if (body !== 'ws-check-ok') {
|
||||
throw new Error(`unexpected proxied response: ${JSON.stringify(body)}`);
|
||||
}
|
||||
console.log(' proxied request through the mock server OK');
|
||||
|
||||
// Confirm the subscription stream is live, not just connected. Subscribing is
|
||||
// async on a remote client, so settle before generating traffic or we race it.
|
||||
const seen = new Promise((resolve) => server.on('request', (r) => resolve(r.path)));
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
await fetch(`http://127.0.0.1:${server.port}/ws-check`);
|
||||
const path = await Promise.race([
|
||||
seen,
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error('no event over subscription stream')), 10000))
|
||||
]);
|
||||
console.log(` received '${path}' event over the subscription stream`);
|
||||
|
||||
await server.stop();
|
||||
console.log(' admin session stopped cleanly');
|
||||
})().catch((e) => {
|
||||
console.error('!! ws-check FAILED:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user