Skip to content

Add Bun runtime support#378

Open
kriszyp wants to merge 50 commits intomainfrom
bun-runtime-support
Open

Add Bun runtime support#378
kriszyp wants to merge 50 commits intomainfrom
bun-runtime-support

Conversation

@kriszyp
Copy link
Copy Markdown
Member

@kriszyp kriszyp commented Apr 18, 2026

  • Replace node-unix-socket native addon with native reusePort (Node v22+)
  • Add BunRequest adapter wrapping Web Fetch API Request
  • Branch http.ts to use Bun.serve() with fetch handler when running on Bun
  • Bridge Operations API (Fastify) to Bun via inject() in bunDelegateToNodeServer
  • Add listenOnPortsBun() using Bun.serve({ reusePort: true }) per worker
  • Support TLS/secure ports on Bun via Bun.serve({ tls: { cert, key } })
  • Create UDS mirror sockets for secure ports via Bun.serve({ unix })
  • Guard Node-specific APIs: v8, inspector, worker.performance, TLS monkey-patches
  • Fix performance.eventLoopUtilization() NotImplementedError on Bun
  • Skip Node version check when running on Bun
  • Parameterize integration tests via HARPER_RUNTIME env var (node|bun)
  • Add CI jobs to run integration tests and API tests on Bun

This also includes work for Windows fixes and runs the core integration tests on Windows.

Neither Windows nor Bun is running the full integration:api-tests. There are failures, and there will need to be follow-up work to provide more comprehensive support of these platforms/runtimes. But this should ensure basis support.

kriszyp and others added 15 commits April 15, 2026 17:12
Creates per-thread UDS mirrors for secure HTTP/TCP servers, writing YAML metadata files containing certificate information. Includes cleanup helpers for socket lifecycle management and comprehensive unit tests for metadata serialization and file operations.
When symphony connects to a per-thread UDS mirror and sends a PROXY v1
header, strip it before the HTTP parser sees it and set socket.remoteAddress
/ socket.remotePort to the real client values.

Uses prependListener so our one-time data handler fires before Node's HTTP
parser. socket.unshift() returns any non-header bytes back to the read
buffer. Connections without a PROXY header pass through unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace node-unix-socket native addon with native reusePort (Node v22+)
- Add BunRequest adapter wrapping Web Fetch API Request
- Branch http.ts to use Bun.serve() with fetch handler when running on Bun
- Bridge Operations API (Fastify) to Bun via inject() in bunDelegateToNodeServer
- Add listenOnPortsBun() using Bun.serve({ reusePort: true }) per worker
- Support TLS/secure ports on Bun via Bun.serve({ tls: { cert, key } })
- Create UDS mirror sockets for secure ports via Bun.serve({ unix })
- Guard Node-specific APIs: v8, inspector, worker.performance, TLS monkey-patches
- Fix performance.eventLoopUtilization() NotImplementedError on Bun
- Skip Node version check when running on Bun
- Parameterize integration tests via HARPER_RUNTIME env var (node|bun)
- Add CI jobs to run integration tests and API tests on Bun

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Required by repo policy that all actions must be pinned to a full-length commit SHA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 4.x-upgrade test returns early from before() when HARPER_LEGACY_VERSION_PATH
is not set, leaving ctx.harper undefined. killHarper/teardownHarper now no-op
gracefully in that case instead of crashing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
listenOnPortsBun() was using +port directly on keys like "127.0.0.14:9926",
which produces NaN and hits the isNaN guard, skipping every port. Parse
host:port strings the same way listenOnPorts() does for Node — split on the
last colon and pass hostname + numeric port to Bun.serve().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On Node, Harper's httpChain auth middleware sets request._nodeRequest.user
for loopback connections in dev mode (AUTHORIZE_LOCAL), which Fastify picks up
via req.raw.user and bypasses its own auth check (fastifyAuth.js:35).

On Bun, bunDelegateToNodeServer calls fastify.inject() with a fresh synthetic
request — no req.raw.user, so Fastify re-runs auth and returns 401.

Fix: strip any incoming x-harper-internal-pre-auth-user header (preventing
forgery), then set it in the inject() call when Harper's auth middleware has
already authenticated the request. fastifyAuth.js trusts this header as an
equivalent to req.raw.user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 'send' module (used for static files) calls setHeader/writeHead on the
pipe destination. In the Bun handler, the destination must be a Writable with
a ServerResponse shim so those calls capture headers into the web Response.

The critical bug: 'on-finished' (a send dependency) calls isFinished() which
checks msg.finished. In Bun, Writable.finished is undefined (not a boolean),
so isFinished() returns undefined. Since undefined !== false, on-finished
immediately schedules cleanup() via setImmediate, destroying the ReadStream
before any data flows — causing the response to hang forever.

Fix: add finished: false to the shim object so isFinished() sees a boolean
false and correctly waits for the 'finish' event before calling cleanup().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@heskew
Copy link
Copy Markdown
Member

heskew commented Apr 18, 2026

👀

kriszyp added 14 commits April 18, 2026 19:35
…rove `process.exit` handling for Bun by force-closing server connections.
…to single HTTP worker

- Remove socket file descriptor passing and proxying logic from http.ts and threadServer.js
- Remove startSocketServer and session affinity implementation from socketRouter.ts
- Remove proxySocket, proxyRequest, and deliverSocket functions
- Simplify lite.js to only start HTTP threads without socket servers
- Add Windows-specific CI workflow for build and integration tests
- Limit Windows to single HTTP worker (no SO_REUSEPORT support)
- Fix package.json test scripts to use double quotes for Windows compatibility
- Disable segfault handler on Bun runtime
kriszyp added 5 commits April 23, 2026 09:13
…support

- Refactor static.ts to properly handle index.html paths, simplify path joining, and log received entries.
- Fix deriveURLPath to standardize Windows-style paths and eliminate incorrect path joining for URLs.
- Enhance checkAllowedModulePath for precise error reporting and improved URL-to-path conversion.
- Add MQTT and MQTTS port constants to harperLifecycle and update lifecycle variables to prevent port conflicts in integration tests.
@kriszyp kriszyp marked this pull request as ready for review April 24, 2026 17:40
@kriszyp kriszyp requested review from a team as code owners April 24, 2026 17:40
Base automatically changed from symphony-prep to main April 29, 2026 22:54
Comment thread bin/harper.js
Comment on lines +12 to +14
if (typeof process.setSourceMapsEnabled === 'function') {
process.setSourceMapsEnabled(true); // this is necessary for source maps to work, at least on the main thread.
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can make this a one-liner if you'd like:

Suggested change
if (typeof process.setSourceMapsEnabled === 'function') {
process.setSourceMapsEnabled(true); // this is necessary for source maps to work, at least on the main thread.
}
process.setSourceMapsEnabled?.(true); // this is necessary for source maps to work, at least on the main }

}
}
export let createReuseportFd: any;
if (platform() != 'win32') createReuseportFd = require('node-unix-socket').createReuseportFd;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove node-unix-socket from the package.json.

@cb1kenobi
Copy link
Copy Markdown
Member

I nuke my ~/harper directory, run bun dist/bin/harper.js install, then run HARPER_RUNTIME=bun npm run test:unit:all and it finishes with a few failed tests. But what's more interesting is if I try to start Harper with bun dist/bin/harper.js, I get:

663 |                         resolve();
664 |                         return;
665 |                     }
666 |                     for (const cert of databases.system.hdb_certificate.search([])) {
667 |                         const certificate = cert.certificate;
668 |                         const certParsed = new X509Certificate(certificate);
                                                 ^
error: error:0c00006d:ASN.1 encoding routines:OPENSSL_internal:DECODE_ERROR
 code: "ERR_CRYPTO_INVALID_STATE"

      at updateTLS (/Users/chris/projects/harper/dist/security/keys.js:668:44)
      at <anonymous> (/Users/chris/projects/harper/dist/security/keys.js:766:13)
      at new Promise (1:11)
      at <anonymous> (/Users/chris/projects/harper/dist/security/keys.js:656:37)
      at getReplicationCert (/Users/chris/projects/harper/dist/security/keys.js:124:23)
      at reviewSelfSignedCert (/Users/chris/projects/harper/dist/security/keys.js:544:32)
      at async initialize (/Users/chris/projects/harper/dist/bin/run.js:155:16)
      at async main (/Users/chris/projects/harper/dist/bin/run.js:173:15)

[main/0] [error]: Error: error:0c00006d:ASN.1 encoding routines:OPENSSL_internal:DECODE_ERROR
    at X509Certificate (unknown)
    at updateTLS (/Users/chris/projects/harper/dist/security/keys.js:668:48)
    at <anonymous> (/Users/chris/projects/harper/dist/security/keys.js:766:13)
    at new Promise (native:1:11)
    at <anonymous> (/Users/chris/projects/harper/dist/security/keys.js:656:41)
    at getReplicationCert (/Users/chris/projects/harper/dist/security/keys.js:124:23)
    at reviewSelfSignedCert (/Users/chris/projects/harper/dist/security/keys.js:544:32)
    at async initialize (/Users/chris/projects/harper/dist/bin/run.js:155:16)
    at async main (/Users/chris/projects/harper/dist/bin/run.js:173:15)
    at processTicksAndRejections (native:7:39)

Note that if I start Harper with Node.js, it works just fine.

@cb1kenobi
Copy link
Copy Markdown
Member

After nuking ~/harper and running bun dist/bin/harper.js, I get:

[http/1] [info]: Domain socket listening on /Users/chris/harper/operations-server
[http/1] [error]: unhandledRejection Error: listen ENOTSUP: operation not supported on socket :::1883
    at Server.setupListenHandle [as _listen2] (node:net:1918:21)
    at listenInCluster (node:net:1997:12)
    at node:net:2206:7
    at process.processTicksAndRejections (node:internal/process/task_queues:89:21) {
  code: 'ENOTSUP',
  errno: -45,
  syscall: 'listen',
  address: '::',
  port: 1883
}

I'm guessing that's not a surprise. :)

kriszyp and others added 3 commits May 5, 2026 06:03
# Conflicts:
#	integrationTests/upgrade/4.x-upgrade.test.ts
#	integrationTests/utils/harperLifecycle.ts
#	package.json
#	server/http.ts
#	server/threads/threadServer.js
Installs Bun alongside Node.js and adds an entrypoint script that
selects the runtime via HARPER_RUNTIME=bun at container start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bun's net.Server compat layer does not support the reusePort option on
macOS, returning ENOTSUP. Raw socket servers (e.g. MQTT on 1883) don't
need it — only the HTTP Bun.serve() workers do. Also handle EADDRINUSE
gracefully so workers 1+ silently skip ports already held by worker 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread security/fastifyAuth.js
// On Bun, Harper's auth middleware passes pre-authenticated users via this internal header.
// It is stripped from real network requests in bunDelegateToNodeServer, so it is safe to trust here.
const preAuthUser = req.headers?.[INTERNAL_USER_HEADER];
if (preAuthUser) return next(null, JSON.parse(preAuthUser));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth bypass on Node.js — blocker

The comment says "It is stripped from real network requests in bunDelegateToNodeServer" — that's true on Bun, but this code also runs on Node.js, where no such stripping occurs.

On Node.js, deliverSocket routes raw sockets directly to Fastify with all original headers intact. A client that sends x-harper-internal-pre-auth-user: {"role":{"permission":{"super_user":true}}} will have that header appear in req.headers here, req.raw?.user will be undefined (unauthenticated), and next(null, parsedUser) will be called — full auth bypass.

The check needs to be gated on Bun:

Suggested change
if (preAuthUser) return next(null, JSON.parse(preAuthUser));
if (typeof globalThis.Bun !== 'undefined') {
const preAuthUser = req.headers?.[INTERNAL_USER_HEADER];
if (preAuthUser) return next(null, JSON.parse(preAuthUser));
}

Comment thread server/static.ts

// Handle entry events for the default entry handler based on the `files` and `urlPath` options
scope.handleEntry((entry) => {
logger.error('static received entry', entry);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReferenceError at runtime — blocker

logger is not defined in this file (imports are realpathSync, existsSync, join, Scope, send). The scoped logger is scope.logger, used correctly on line 32. This will throw ReferenceError: logger is not defined the first time any static file is scanned, completely breaking static file serving.

This looks like a debugging line that was accidentally left in.

Suggested change
logger.error('static received entry', entry);

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 5, 2026

3 blockers remain unresolved — none were addressed in this push (commits up to 06108ef1).

1. Auth bypass on Node.js (security/fastifyAuth.js:41)

File: security/fastifyAuth.js:41
What: fastifyAuth.js trusts x-harper-internal-pre-auth-user unconditionally. On Bun this is safe because bunDelegateToNodeServer strips the header (http.ts:686) before calling fastify.inject(). On Node.js, Fastify receives the raw IncomingMessage via the unhandled event at http.ts:279 — no stripping occurs, so any client can forge this header to bypass auth. The comment added this push ("It is stripped from real network requests in bunDelegateToNodeServer") is correct for Bun only.
Why it matters: Any request to the operations API on Node.js with a forged x-harper-internal-pre-auth-user value is granted the user contained in the header — including superuser — without credentials.
Suggested fix: Gate on the Bun runtime: if (typeof globalThis.Bun !== 'undefined' && preAuthUser) return next(null, JSON.parse(preAuthUser));

2. ReferenceError breaks static file serving (server/static.ts:39)

File: server/static.ts:39
What: logger.error('static received entry', entry)logger is not defined in this module; the scoped logger is scope.logger. This line was introduced by this PR.
Why it matters: Throws ReferenceError: logger is not defined on the first entry event, preventing any static files from being registered and breaking static file serving entirely.
Suggested fix: Remove the line (looks like a debug leftover).

3. Windows CI silently ignores build failures (.github/workflows/integration-tests.yml)

File: .github/workflows/integration-tests.yml
What: continue-on-error: true on the Windows build step; comment now reads "we currently have type errors so just ignore that".
Why it matters: Per repo policy (AGENTS.md), Harper core's build must pass cleanly. Known type errors should be fixed or the Windows job marked informational at the job level — masking at the step level means compilation failures are silently swallowed.
Suggested fix: Fix the type errors and remove continue-on-error: true, or gate the entire Windows job as allowed-failure if it's explicitly a best-effort platform.

@kriszyp
Copy link
Copy Markdown
Member Author

kriszyp commented May 5, 2026

error: error:0c00006d:ASN.1 encoding routines:OPENSSL_internal:DECODE_ERROR
code: "ERR_CRYPTO_INVALID_STATE"

I noticed that on node.js I have been seeing new cert errors after running tests:

[http/2] [error] [tls]: Error applying TLS for valid-test-cert Error: ENOENT: no such file or directory, open '/home/kzyp/harper/keys/test.pem'

I wonder if this is Bun's manifestation of the same thing?

[http/1] [error]: unhandledRejection Error: listen ENOTSUP: operation not supported on socket :::1883
I'm guessing that's not a surprise. :)

Well, a little surprised. Apparently there is just a gap in Bun's support for reusePort and this just is implemented yet in Bun. Added code to tolerate this (runs MQTT on one thread).

…elink step

Inline env var syntax (VAR=val cmd) is not supported on Windows CMD,
breaking the Windows integration test job. Moving the variable into
each job's env: block fixes Windows and keeps things consistent.

Also adds the missing `npm install --ignore-scripts` relink step to the
Bun integration test job; artifact download does not preserve bin
symlinks so harper-integration-test-run was not found.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread security/fastifyAuth.js
// On Bun, Harper's auth middleware passes pre-authenticated users via this internal header.
// It is stripped from real network requests in bunDelegateToNodeServer, so it is safe to trust here.
const preAuthUser = req.headers?.[INTERNAL_USER_HEADER];
if (preAuthUser) return next(null, JSON.parse(preAuthUser));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth bypass on Node.js (same concern as prior review, still unaddressed)

fastifyAuth.js trusts x-harper-internal-pre-auth-user without a Bun guard. On Bun this is safe — bunDelegateToNodeServer strips the header before fastify.inject(). On Node.js, Fastify receives the raw IncomingMessage via the unhandled-event cascade, with all client headers intact. Any client can forge this header and bypass authentication.

Suggested fix: gate the header check on the Bun runtime:

Suggested change
if (preAuthUser) return next(null, JSON.parse(preAuthUser));
const preAuthUser = typeof globalThis.Bun !== 'undefined' && req.headers?.[INTERNAL_USER_HEADER];

Comment thread server/static.ts

// Handle entry events for the default entry handler based on the `files` and `urlPath` options
scope.handleEntry((entry) => {
logger.error('static received entry', entry);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReferenceError on every static file scan (same concern as prior review, still unaddressed)

logger is not defined in this scope — the scoped logger is scope.logger (used correctly on line 32). This will throw on the first entry event fired by the static plugin, breaking static file serving entirely. Looks like a debug line accidentally left in.

Suggested change
logger.error('static received entry', entry);
scope.logger.debug('static received entry', entry);

Or just remove the line.


- name: Build
run: npm run build
continue-on-error: true # we currently have type errors so just ignore that
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build failures silently ignored on Windows

continue-on-error: true suppresses all build failures (not just type warnings). Per repo policy, Harper core's build should pass cleanly — the tsc || true pattern is explicitly called out as not applicable here. A CI job that passes even when compilation fails provides false confidence.

The comment acknowledges existing type errors; those should be fixed before enabling the Windows build job, not masked. If this job is meant to be informational-only in the interim, consider making the whole job continue-on-error: true at the job level and documenting the known failures in a tracking issue.

kriszyp and others added 3 commits May 5, 2026 21:04
Windows job was missing `npm install --ignore-scripts` to recreate
node_modules/.bin wrappers after artifact download, causing
harper-integration-test-run to be unrecognized.

Both Windows and Bun jobs were calling test:integration which no longer
includes a glob pattern; switch to test:integration:all.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
# Conflicts:
#	server/serverHelpers/Request.ts
Comment thread security/fastifyAuth.js
// On Bun, Harper's auth middleware passes pre-authenticated users via this internal header.
// It is stripped from real network requests in bunDelegateToNodeServer, so it is safe to trust here.
const preAuthUser = req.headers?.[INTERNAL_USER_HEADER];
if (preAuthUser) return next(null, JSON.parse(preAuthUser));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker (re-raised from prior review — still unresolved): The comment says the header is stripped in bunDelegateToNodeServer, but that only covers the Bun code path. On Node.js, Fastify receives requests via the unhandled event at http.ts:279 with the raw nodeRequest — no header stripping occurs before this point. Any client can send x-harper-internal-pre-auth-user on Node.js to bypass auth.

Suggested change
if (preAuthUser) return next(null, JSON.parse(preAuthUser));
if (typeof globalThis.Bun !== 'undefined' && preAuthUser) return next(null, JSON.parse(preAuthUser));

Comment thread server/static.ts

// Handle entry events for the default entry handler based on the `files` and `urlPath` options
scope.handleEntry((entry) => {
logger.error('static received entry', entry);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker (re-raised from prior review — still unresolved): logger is not defined in this module. This throws ReferenceError: logger is not defined on the first entry event, breaking static file serving entirely. The available logger is scope.logger, but this line looks like a debug leftover and should simply be removed.

Suggested change
logger.error('static received entry', entry);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants