@thezzisu/droidnode 0.140.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +30 -0
- package/README.md +136 -0
- package/bin/droidnode.js +86 -0
- package/lib/extract.js +220 -0
- package/lib/locate.js +83 -0
- package/package.json +54 -0
- package/src/shims/bun-ffi.mjs +62 -0
- package/src/shims/bun-jsc.mjs +3 -0
- package/src/shims/bun-serve.mjs +224 -0
- package/src/shims/bun-shim.mjs +159 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thezzisu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
This license covers ONLY the wrapper code in this repository (bin/, lib/,
|
|
26
|
+
src/, README.md). It does NOT cover the `droid` CLI from Factory AI, its
|
|
27
|
+
JavaScript bundle, the `@factory/cli-*` binaries, or any other proprietary
|
|
28
|
+
artifact that this wrapper extracts at runtime — those remain subject to
|
|
29
|
+
Factory AI's own terms. This project does not redistribute any Factory
|
|
30
|
+
intellectual property.
|
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# @thezzisu/droidnode
|
|
2
|
+
|
|
3
|
+
A Node.js-side launcher for [Factory's `droid` CLI](https://www.npmjs.com/package/droid). It extracts the JS bundle from the official droid binary and runs it under plain Node — not Bun — to sidestep two long-standing Bun 1.3.x issues that hurt droid users on long sessions:
|
|
4
|
+
|
|
5
|
+
1. **NULL-allocator race in standalone init.** `droid --resume <id>` of a large mission session crashes with `Segmentation fault at address 0x0` — Bun's standalone fork/fanout pressure leaves `bun.default_allocator` with a NULL vtable, and the first `hash.update(<long string>)` call segfaults inside `Allocator.rawAlloc`. Backtrace decoded from [bun.report](https://bun.report):
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Allocator.zig:129 mem.Allocator.rawAlloc ← NULL vtable.alloc
|
|
9
|
+
array_list.zig:57 AlignedManaged.initCapacity
|
|
10
|
+
unicode.zig:315 toUTF8AllocWithType
|
|
11
|
+
encoding.zig:483 bun.js.webcore.encoding.constructFromU16
|
|
12
|
+
encoding.zig:63 Bun__encoding__constructFromUTF16
|
|
13
|
+
JSBuffer.cpp:551 WebCore::constructFromEncoding
|
|
14
|
+
JSHash.cpp:182 Bun::jsHashProtoFuncUpdate ← hash.update(<string>)
|
|
15
|
+
<JS> droid session-resume / digest pipeline
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Related upstream reports: [oven-sh/bun#25798](https://github.com/oven-sh/bun/issues/25798), [oven-sh/bun#14254](https://github.com/oven-sh/bun/issues/14254), [anthropics/claude-code#17546](https://github.com/anthropics/claude-code/issues/17546). The race only fires inside Bun's standalone-executable init under spawn pressure; running the same JS bundle through a fresh Bun (or Node) process never enters that path.
|
|
19
|
+
|
|
20
|
+
2. **Bun memory growth on long-lived sessions.** Mission-mode droid sessions running for hours accumulate RSS in Bun beyond what the bundle's logical heap explains. Node + V8 doesn't exhibit the same drift.
|
|
21
|
+
|
|
22
|
+
> **droid is © Factory AI.** This repository ships zero proprietary code — no JS bundle, no native binaries, no assets. We depend on the official `droid` npm package as the sole source of truth and apply a small set of Node-compat patches at extraction time. **All trademarks and copyrights belong to their respective owners.**
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install -g @thezzisu/droidnode
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or run ad-hoc (downloads droid + @factory/cli-<platform> + koffi the first time):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx -y @thezzisu/droidnode --resume <session-id>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Both forms transparently install the `droid` npm package as a dependency, so a separate `npm install -g droid` is not required.
|
|
37
|
+
|
|
38
|
+
## Use
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
droidnode --version # tracks the underlying droid version
|
|
42
|
+
droidnode --help # full droid help (passthrough)
|
|
43
|
+
droidnode --resume <id> # the case this exists for
|
|
44
|
+
droidnode --fork <id> # forks
|
|
45
|
+
droidnode exec "do thing" # non-interactive
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Every droid flag/subcommand is passed through verbatim.
|
|
49
|
+
|
|
50
|
+
### Introspection
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
droidnode --print-paths # JSON: resolved droid binary, Node, shim dir, cache dir
|
|
54
|
+
droidnode --reextract # wipe the cache for the current droid binary and re-extract
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Environment
|
|
58
|
+
|
|
59
|
+
| Variable | Purpose |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `DROID_BIN` | Override droid binary location |
|
|
62
|
+
| `DROIDNODE_VERBOSE` | Print extraction progress on first run |
|
|
63
|
+
| `XDG_CACHE_HOME` | Cache base (defaults to `~/.cache`) |
|
|
64
|
+
|
|
65
|
+
Cache lives at `$XDG_CACHE_HOME/droidnode/<binary-fingerprint>/`. The key includes the droid binary's size + head/tail hash + the shim directory path, so multiple droid versions and multiple `droidnode` installs coexist without collisions.
|
|
66
|
+
|
|
67
|
+
## How it works
|
|
68
|
+
|
|
69
|
+
1. Resolve `droid` via `require('droid/platform.js').getBinaryPathWithInfo()` — the same logic Factory's own shim uses, picking `@factory/cli-<platform>{,-baseline}` based on the host CPU's AVX2 support.
|
|
70
|
+
2. Locate the `.bun` ELF section by scanning for the `/$bunfs/root/droid\0// @bun\n` opener. Files inside are packed sequentially as `<path>\0<content>`; we walk the list by looking for `\0/$bunfs/root/` boundaries.
|
|
71
|
+
3. Extract every non-`droid` entry to `<cache>/embedded/<basename>` and chmod +x the natives (`rg`, `librust_pty-*.so`, `agent-browser`, install shell scripts).
|
|
72
|
+
4. Apply the following patches to the JS bundle and write it as `<cache>/droid.node.mjs`:
|
|
73
|
+
- Truncate at the last `//# debugId=<hex>\n` line. Bun appends raw Zstd sourcemap blobs past it that fail Node's JS parser on the embedded NULL bytes.
|
|
74
|
+
- Replace every `/$bunfs/root/` literal with the absolute path to `<cache>/embedded/`.
|
|
75
|
+
- `import.meta.require` → `globalThis.__bunRequire` (installed by `bun-shim.mjs`; Node has no `import.meta.require`).
|
|
76
|
+
- `"bun:ffi"` → absolute path to `src/shims/bun-ffi.mjs` (koffi-backed dlopen for the PTY .so).
|
|
77
|
+
- `"bun:jsc"` → absolute path to `src/shims/bun-jsc.mjs` (empty `heapStats` stub; droid only calls it inside try/catch).
|
|
78
|
+
- `from"ws"` → absolute path to the `ws` package's entry (so the bundle's `import X from "ws"` resolves outside any `node_modules` tree).
|
|
79
|
+
- `this.server=Bun.serve(` → `this.server=await Bun.serve(` ×2. Our Bun.serve polyfill is async; Bun's original is sync.
|
|
80
|
+
5. Spawn `node --import bun-shim.mjs --enable-source-maps <cache>/droid.node.mjs <argv>`. The shim preload:
|
|
81
|
+
- Overrides `process.execPath` / `argv0` / `argv[0]` so droid's self-spawn (subagent fanout, restart-after-update) re-enters us via `basename(execPath).includes("droid")`.
|
|
82
|
+
- Installs `globalThis.Bun` with subset of `spawn`/`spawnSync`/`file`/`which`/`gc`/`connect`/`serve`/`fileURLToPath`/`version`.
|
|
83
|
+
- Implements `Bun.serve` over `node:http` + `ws` (the only thing `droid daemon` needs that Bun has and Node doesn't).
|
|
84
|
+
- Routes `import.meta.require` through `createRequire(import.meta.url)`, intercepting `node-fetch` and `abort-controller` to the Node 18+ / 15+ globals so neither package needs to be installed.
|
|
85
|
+
- Aliases `Buffer.SlowBuffer = Buffer` (Node 22+ removed `SlowBuffer`; the bundled `buffer-equal-constant-time` still references it).
|
|
86
|
+
|
|
87
|
+
Subsequent invocations hit the cache and skip extraction.
|
|
88
|
+
|
|
89
|
+
## Cross-version compatibility
|
|
90
|
+
|
|
91
|
+
The patch set is stable across the droid versions we've tested (0.135.1 through 0.140.0 — every published version we could install at the time). The CI smoke job runs extraction + `--version` on every push; the `auto-track` workflow re-runs it daily against the latest droid release before bumping our pin.
|
|
92
|
+
|
|
93
|
+
Despite the patches being stable, the `dependencies.droid` range is pinned to `^<latest-tested>` so a stale install never silently runs against an untested droid major. The bot opens a new tag/release whenever upstream droid moves.
|
|
94
|
+
|
|
95
|
+
## Automation
|
|
96
|
+
|
|
97
|
+
- `.github/workflows/ci.yml` — runs `scripts/smoke.js` (extract + `--version`) on Node 20 and 22, on every push and PR.
|
|
98
|
+
- `.github/workflows/auto-track.yml` — runs daily; if `npm view droid version` exceeds our `package.json` version, runs the smoke against the new droid, bumps both `version` and `dependencies.droid`, commits to `main`, tags `v<version>`, and creates a GitHub release.
|
|
99
|
+
- `.github/workflows/publish.yml` — triggered on `v*.*.*` tag push (or manual dispatch). Publishes to npm via **Trusted Publisher (OIDC)** — no long-lived `NPM_TOKEN`, no static credentials. The workflow exchanges GitHub Actions' built-in OIDC token for an npm publish token at request time, and emits a [provenance attestation](https://docs.npmjs.com/generating-provenance-statements) so users can verify the published tarball was built from this exact commit.
|
|
100
|
+
|
|
101
|
+
### One-time Trusted Publisher setup
|
|
102
|
+
|
|
103
|
+
1. Sign in to npmjs.com as the publisher account (must have publish rights on `@thezzisu`).
|
|
104
|
+
2. Go to **Settings → Trusted Publishers**, click **Add Trusted Publisher**.
|
|
105
|
+
3. Fill in:
|
|
106
|
+
- Publisher: `GitHub Actions`
|
|
107
|
+
- Organization or username: `thezzisu`
|
|
108
|
+
- Repository: `droidnode`
|
|
109
|
+
- Workflow filename: `publish.yml`
|
|
110
|
+
- Environment: *leave blank* (or pin to `release` if you create that environment for required reviewers)
|
|
111
|
+
4. Save. From the next tag push, the publish workflow authenticates via OIDC — no secrets in the repo, no token rotation, and the published version page on npmjs.com shows a "Built and signed on GitHub Actions" badge linking back to the workflow run.
|
|
112
|
+
|
|
113
|
+
The very first publish of a new package name can be done either by manually dispatching `publish.yml` once the trusted publisher is configured (npm now supports OIDC-based namespace claims), or by a single manual `npm publish` from a maintainer's machine to register the name, after which OIDC takes over.
|
|
114
|
+
|
|
115
|
+
## Platform support
|
|
116
|
+
|
|
117
|
+
Verified: **Linux x64** (the configuration that hits the bug hardest, especially under WSL2). The extractor and shims are platform-agnostic; `darwin-arm64` and `darwin-x64` are wired in `package.json` — community testing welcome.
|
|
118
|
+
|
|
119
|
+
Windows is not supported in this release. droid's standalone uses a different process model on win32 and we haven't reproduced or fixed the same crash there.
|
|
120
|
+
|
|
121
|
+
## Limitations
|
|
122
|
+
|
|
123
|
+
- Subagent fanout in long missions hasn't been stress-tested end-to-end. Session restore + UI/Plan reload + cwd restoration are confirmed working.
|
|
124
|
+
- `droid update` (the in-place updater) tries to overwrite its own binary. Through this wrapper it would target the resolved `@factory/cli-*/bin/droid` inside our `node_modules`; for npm-managed installs `npm update -g @thezzisu/droidnode` is the supported upgrade path.
|
|
125
|
+
- `keytar`-backed credential storage uses droid's own Linux fallback when keytar can't load (the bundle wraps it in try/catch). The wrapper does not provide it separately.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
Wrapper code (`bin/`, `lib/`, `src/`, `scripts/`, this README) is MIT — see [LICENSE](./LICENSE).
|
|
130
|
+
|
|
131
|
+
**The droid CLI, its JS bundle, the `@factory/cli-*` binaries, and all related Factory AI intellectual property are not covered by this license** and remain subject to Factory's own terms. This project does not redistribute any of it.
|
|
132
|
+
|
|
133
|
+
## Acknowledgements
|
|
134
|
+
|
|
135
|
+
- Factory AI — the actual CLI we're patching around.
|
|
136
|
+
- The oven-sh/bun team — fixing this upstream eventually will retire this shim.
|
package/bin/droidnode.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// droidnode — run Factory's droid CLI on plain Node.js (no Bun runtime).
|
|
3
|
+
//
|
|
4
|
+
// Why Node and not Bun: Bun 1.3.x has both the standalone-init NULL-allocator
|
|
5
|
+
// race that crashes `droid --resume`, and broader memory-leak issues during
|
|
6
|
+
// long sessions. We extract the JS bundle Factory ships, apply a small set of
|
|
7
|
+
// Node-compatible patches (Bun.* globals shimmed, import.meta.require routed
|
|
8
|
+
// through createRequire, Bun.serve polyfilled via node:http + ws, Bun.FFI
|
|
9
|
+
// dlopen routed through koffi), and run it under whatever `node` invoked us.
|
|
10
|
+
//
|
|
11
|
+
// © Factory AI for droid itself, its JS bundle, and `@factory/cli-*` binaries
|
|
12
|
+
// — see README.md. This launcher is MIT.
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { dirname, join, resolve } from 'node:path';
|
|
17
|
+
import { findDroidBinary } from '../lib/locate.js';
|
|
18
|
+
import { ensureExtracted, cacheDirFor, SHIM_DIR } from '../lib/extract.js';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const PKG_ROOT = resolve(__dirname, '..');
|
|
22
|
+
const PRELOAD_URL = `file://${join(SHIM_DIR, 'bun-shim.mjs')}`;
|
|
23
|
+
|
|
24
|
+
function die(msg, code = 1) {
|
|
25
|
+
process.stderr.write(`droidnode: ${msg}\n`);
|
|
26
|
+
process.exit(code);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
|
|
31
|
+
if (args[0] === '--print-paths') {
|
|
32
|
+
const droid = findDroidBinary();
|
|
33
|
+
const dir = droid.path ? cacheDirFor(droid.path) : null;
|
|
34
|
+
console.log(JSON.stringify({
|
|
35
|
+
droid,
|
|
36
|
+
node: process.execPath,
|
|
37
|
+
shimDir: SHIM_DIR,
|
|
38
|
+
cacheDir: dir,
|
|
39
|
+
}, null, 2));
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
if (args[0] === '--reextract') {
|
|
43
|
+
const droid = findDroidBinary();
|
|
44
|
+
if (!droid.path) die('droid binary not found; cannot re-extract');
|
|
45
|
+
const { extract } = await import('../lib/extract.js');
|
|
46
|
+
extract(droid.path, cacheDirFor(droid.path));
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const droid = findDroidBinary();
|
|
51
|
+
if (!droid.path) {
|
|
52
|
+
die(
|
|
53
|
+
`cannot locate the real droid binary.\n` +
|
|
54
|
+
` - Set $DROID_BIN to an explicit path, OR\n` +
|
|
55
|
+
` - Install droid: \`npm install -g droid\` (recommended), OR\n` +
|
|
56
|
+
` - Place the original binary at ~/.local/bin/droid.bun-orig`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cacheDir = ensureExtracted(droid.path, { quiet: !process.env.DROIDNODE_VERBOSE });
|
|
61
|
+
const droidMjs = join(cacheDir, 'droid.node.mjs');
|
|
62
|
+
|
|
63
|
+
const wrapperPath = process.env.DROIDNODE_WRAPPER_PATH
|
|
64
|
+
?? process.argv[1]
|
|
65
|
+
?? join(PKG_ROOT, 'bin', 'droidnode.js');
|
|
66
|
+
|
|
67
|
+
const env = {
|
|
68
|
+
...process.env,
|
|
69
|
+
DROIDNODE_WRAPPER_PATH: wrapperPath,
|
|
70
|
+
DROIDNODE_DROID_BIN: droid.path,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// `--import` (Node 19+, stable 20.6+) loads bun-shim.mjs before bundle.
|
|
74
|
+
// `--enable-source-maps` keeps stack traces readable across our patches.
|
|
75
|
+
const result = spawnSync(
|
|
76
|
+
process.execPath,
|
|
77
|
+
['--import', PRELOAD_URL, '--enable-source-maps', droidMjs, ...args],
|
|
78
|
+
{ stdio: 'inherit', env },
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (result.error) die(`failed to spawn node: ${result.error.message}`);
|
|
82
|
+
if (result.signal) {
|
|
83
|
+
process.kill(process.pid, result.signal);
|
|
84
|
+
} else {
|
|
85
|
+
process.exit(result.status ?? 0);
|
|
86
|
+
}
|
package/lib/extract.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Extract the JS bundle and embedded resources from a droid Bun-standalone
|
|
2
|
+
// binary, then transform the bundle so it runs on plain Node.js (no Bun).
|
|
3
|
+
//
|
|
4
|
+
// Bun's `bun build --compile` lays the bundle into a dedicated `.bun` ELF
|
|
5
|
+
// section. Inside that section files are packed sequentially as
|
|
6
|
+
// `<path>\0<content><next-path>\0<content>...`. We walk the packed list by
|
|
7
|
+
// scanning for the `/$bunfs/root/` path prefix; ELF parsing isn't required.
|
|
8
|
+
//
|
|
9
|
+
// Transformations applied to the JS bundle, in order:
|
|
10
|
+
// 1. Truncate at `//# debugId=<hex>\n` — Bun appends raw Zstd-compressed
|
|
11
|
+
// sourcemap blobs past that line, which break Node's JS parser.
|
|
12
|
+
// 2. `/$bunfs/root/` -> absolute path of the embedded dir, so runtime
|
|
13
|
+
// filesystem lookups resolve.
|
|
14
|
+
// 3. `import.meta.require` -> `globalThis.__bunRequire` (installed by
|
|
15
|
+
// bun-shim.mjs preload). Node has no `import.meta.require`.
|
|
16
|
+
// 4. `"bun:ffi"` -> absolute path to our koffi-backed shim.
|
|
17
|
+
// 5. `"bun:jsc"` -> absolute path to our heapStats() stub.
|
|
18
|
+
// 6. `this.server=Bun.serve(` -> `this.server=await Bun.serve(` ×2.
|
|
19
|
+
// Our Bun.serve polyfill is async; Bun's original is sync.
|
|
20
|
+
|
|
21
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync, statSync, chmodSync, rmSync, openSync, readSync, closeSync } from 'node:fs';
|
|
22
|
+
import { join, dirname, resolve } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const PKG_ROOT = resolve(__dirname, '..');
|
|
31
|
+
const SHIM_DIR = join(PKG_ROOT, 'src', 'shims');
|
|
32
|
+
|
|
33
|
+
const BUNFS_PREFIX = '/$bunfs/root/';
|
|
34
|
+
const PATH_PREFIX_BYTES = Buffer.from(BUNFS_PREFIX, 'utf8');
|
|
35
|
+
|
|
36
|
+
function cacheKeyFor(binaryPath) {
|
|
37
|
+
const st = statSync(binaryPath);
|
|
38
|
+
// Sample head + tail + size of binary; XOR-style includes shim path so
|
|
39
|
+
// that moving / reinstalling the npm package invalidates stale caches.
|
|
40
|
+
const fd = openSync(binaryPath, 'r');
|
|
41
|
+
const head = Buffer.alloc(64 * 1024);
|
|
42
|
+
const tail = Buffer.alloc(64 * 1024);
|
|
43
|
+
readSync(fd, head, 0, head.length, 0);
|
|
44
|
+
readSync(fd, tail, 0, tail.length, Math.max(0, st.size - tail.length));
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
const h = createHash('sha256');
|
|
47
|
+
h.update(`${st.size}\0`);
|
|
48
|
+
h.update(head);
|
|
49
|
+
h.update(tail);
|
|
50
|
+
h.update(`\0${SHIM_DIR}`);
|
|
51
|
+
return h.digest('hex').slice(0, 16);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function cacheRootDir() {
|
|
55
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
56
|
+
const base = xdg ? xdg : join(homedir(), '.cache');
|
|
57
|
+
return join(base, 'droidnode');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function cacheDirFor(binaryPath) {
|
|
61
|
+
return join(cacheRootDir(), cacheKeyFor(binaryPath));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function findBunSection(buf) {
|
|
65
|
+
// The `.bun` ELF section opens with `/$bunfs/root/droid\0// @bun\n`.
|
|
66
|
+
// Locating it by needle works across droid versions without ELF parsing.
|
|
67
|
+
const needle = Buffer.from(BUNFS_PREFIX + 'droid\0// @bun\n', 'utf8');
|
|
68
|
+
const idx = buf.indexOf(needle);
|
|
69
|
+
if (idx < 0) throw new Error('embedded bundle not found — is this really a droid Bun standalone?');
|
|
70
|
+
return idx;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findFileEntries(buf, startOffset) {
|
|
74
|
+
const entries = [{ pathStart: startOffset }];
|
|
75
|
+
let from = startOffset + 1;
|
|
76
|
+
while (true) {
|
|
77
|
+
const next = buf.indexOf(PATH_PREFIX_BYTES, from);
|
|
78
|
+
if (next < 0) break;
|
|
79
|
+
if (next === 0 || buf[next - 1] !== 0) {
|
|
80
|
+
from = next + 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
entries.push({ pathStart: next });
|
|
84
|
+
from = next + 1;
|
|
85
|
+
}
|
|
86
|
+
const resolved = [];
|
|
87
|
+
for (let i = 0; i < entries.length; i++) {
|
|
88
|
+
const pStart = entries[i].pathStart;
|
|
89
|
+
const pEnd = buf.indexOf(0, pStart);
|
|
90
|
+
if (pEnd < 0) break;
|
|
91
|
+
const path = buf.slice(pStart, pEnd).toString('utf8');
|
|
92
|
+
const contentStart = pEnd + 1;
|
|
93
|
+
const contentEnd = (i + 1 < entries.length) ? entries[i + 1].pathStart : buf.length;
|
|
94
|
+
resolved.push({ path, contentStart, contentEnd });
|
|
95
|
+
}
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function trimBundleAtDebugId(jsBundle) {
|
|
100
|
+
const m = jsBundle.toString('binary').match(/\/\/# debugId=[0-9A-Fa-f]+\n/);
|
|
101
|
+
if (!m) return jsBundle;
|
|
102
|
+
return jsBundle.slice(0, m.index + m[0].length);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function bufferReplaceAll(buf, src, dst) {
|
|
106
|
+
const parts = [];
|
|
107
|
+
let last = 0;
|
|
108
|
+
while (true) {
|
|
109
|
+
const next = buf.indexOf(src, last);
|
|
110
|
+
if (next < 0) { parts.push(buf.slice(last)); break; }
|
|
111
|
+
parts.push(buf.slice(last, next));
|
|
112
|
+
parts.push(dst);
|
|
113
|
+
last = next + src.length;
|
|
114
|
+
}
|
|
115
|
+
return Buffer.concat(parts);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function applyNodePatches(jsBundle, embeddedDir) {
|
|
119
|
+
let out = jsBundle;
|
|
120
|
+
// 1. /$bunfs/root/ → embedded dir
|
|
121
|
+
out = bufferReplaceAll(
|
|
122
|
+
out,
|
|
123
|
+
PATH_PREFIX_BYTES,
|
|
124
|
+
Buffer.from(embeddedDir.replace(/\/+$/, '') + '/', 'utf8'),
|
|
125
|
+
);
|
|
126
|
+
// 2. import.meta.require → globalThis.__bunRequire (length-preserved-ish)
|
|
127
|
+
out = bufferReplaceAll(
|
|
128
|
+
out,
|
|
129
|
+
Buffer.from('import.meta.require', 'utf8'),
|
|
130
|
+
Buffer.from('globalThis.__bunRequire', 'utf8'),
|
|
131
|
+
);
|
|
132
|
+
// 3. "bun:ffi" → "<SHIM_DIR>/bun-ffi.mjs"
|
|
133
|
+
out = bufferReplaceAll(
|
|
134
|
+
out,
|
|
135
|
+
Buffer.from('"bun:ffi"', 'utf8'),
|
|
136
|
+
Buffer.from(`"${join(SHIM_DIR, 'bun-ffi.mjs')}"`, 'utf8'),
|
|
137
|
+
);
|
|
138
|
+
// 4. "bun:jsc" → "<SHIM_DIR>/bun-jsc.mjs"
|
|
139
|
+
out = bufferReplaceAll(
|
|
140
|
+
out,
|
|
141
|
+
Buffer.from('"bun:jsc"', 'utf8'),
|
|
142
|
+
Buffer.from(`"${join(SHIM_DIR, 'bun-jsc.mjs')}"`, 'utf8'),
|
|
143
|
+
);
|
|
144
|
+
// 5. this.server=Bun.serve( → this.server=await Bun.serve( (DaemonServer only)
|
|
145
|
+
out = bufferReplaceAll(
|
|
146
|
+
out,
|
|
147
|
+
Buffer.from('this.server=Bun.serve(', 'utf8'),
|
|
148
|
+
Buffer.from('this.server=await Bun.serve(', 'utf8'),
|
|
149
|
+
);
|
|
150
|
+
// 6. `from"ws"` -> absolute path to the ws package we ship as a dep.
|
|
151
|
+
// Bundle has a single static `import X from "ws"` to set global.WebSocket;
|
|
152
|
+
// resolving it from the cache dir would fail (no node_modules there).
|
|
153
|
+
// We resolve once at extract time and bake in the absolute path.
|
|
154
|
+
try {
|
|
155
|
+
const wsEntry = require.resolve('ws');
|
|
156
|
+
out = bufferReplaceAll(
|
|
157
|
+
out,
|
|
158
|
+
Buffer.from('from"ws"', 'utf8'),
|
|
159
|
+
Buffer.from(`from"${wsEntry}"`, 'utf8'),
|
|
160
|
+
);
|
|
161
|
+
} catch {
|
|
162
|
+
// ws not installed — bundle will throw at module load; that's OK if user
|
|
163
|
+
// never reaches the code path. We don't fail extraction.
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function isExtracted(dir) {
|
|
169
|
+
return existsSync(join(dir, 'droid.node.mjs')) && existsSync(join(dir, '.complete'));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function extract(binaryPath, dir, { quiet = false } = {}) {
|
|
173
|
+
const embedded = join(dir, 'embedded');
|
|
174
|
+
rmSync(dir, { recursive: true, force: true });
|
|
175
|
+
mkdirSync(embedded, { recursive: true });
|
|
176
|
+
|
|
177
|
+
const log = (...args) => { if (!quiet) console.error('[droidnode]', ...args); };
|
|
178
|
+
|
|
179
|
+
log(`reading ${binaryPath}`);
|
|
180
|
+
const buf = readFileSync(binaryPath);
|
|
181
|
+
const sectionStart = findBunSection(buf);
|
|
182
|
+
log(`bundle section starts at 0x${sectionStart.toString(16)}`);
|
|
183
|
+
|
|
184
|
+
const entries = findFileEntries(buf, sectionStart);
|
|
185
|
+
log(`found ${entries.length} embedded entries`);
|
|
186
|
+
|
|
187
|
+
let droidEntry = null;
|
|
188
|
+
for (const e of entries) {
|
|
189
|
+
const base = e.path.slice(BUNFS_PREFIX.length);
|
|
190
|
+
const raw = buf.slice(e.contentStart, e.contentEnd);
|
|
191
|
+
if (base === 'droid') {
|
|
192
|
+
droidEntry = { ...e, raw };
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const outPath = join(embedded, base);
|
|
196
|
+
writeFileSync(outPath, raw);
|
|
197
|
+
if (/^(rg-|agent-browser-|librust_pty-|rust_pty)/.test(base) || /\.sh-/.test(base)) {
|
|
198
|
+
chmodSync(outPath, 0o755);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!droidEntry) throw new Error('droid JS bundle entry missing from .bun section');
|
|
202
|
+
|
|
203
|
+
log(`applying Node patches (paths, bun: shims, import.meta.require, Bun.serve async)`);
|
|
204
|
+
let bundle = trimBundleAtDebugId(droidEntry.raw);
|
|
205
|
+
bundle = applyNodePatches(bundle, embedded);
|
|
206
|
+
// .mjs so Node treats it as ESM unconditionally (cache dir has no package.json)
|
|
207
|
+
writeFileSync(join(dir, 'droid.node.mjs'), bundle);
|
|
208
|
+
|
|
209
|
+
writeFileSync(join(dir, '.complete'), `${Date.now()}\n${binaryPath}\nshims=${SHIM_DIR}\n`);
|
|
210
|
+
log(`extracted ${entries.length} files → ${dir}`);
|
|
211
|
+
return dir;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function ensureExtracted(binaryPath, { quiet = false } = {}) {
|
|
215
|
+
const dir = cacheDirFor(binaryPath);
|
|
216
|
+
if (isExtracted(dir)) return dir;
|
|
217
|
+
return extract(binaryPath, dir, { quiet });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export { SHIM_DIR, PKG_ROOT };
|
package/lib/locate.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Find the real droid binary on this system.
|
|
2
|
+
//
|
|
3
|
+
// Resolution order:
|
|
4
|
+
// 1. $DROID_BIN explicit override
|
|
5
|
+
// 2. droid npm package's platform.js → @factory/cli-<platform>/bin/droid
|
|
6
|
+
// 3. `droid` in PATH — skipped if it's our own wrapper (size/ELF heuristic)
|
|
7
|
+
// 4. Common manual-install locations (~/.local/bin/droid{,.bun-orig})
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { delimiter, join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
const MIN_REAL_DROID_SIZE = 50 * 1024 * 1024; // real binary ≈150 MB; wrappers are tiny
|
|
17
|
+
|
|
18
|
+
function magicMatches(p) {
|
|
19
|
+
try {
|
|
20
|
+
const fd = openSync(p, 'r');
|
|
21
|
+
const buf = Buffer.alloc(4);
|
|
22
|
+
readSync(fd, buf, 0, 4, 0);
|
|
23
|
+
closeSync(fd);
|
|
24
|
+
// ELF (Linux), Mach-O thin / fat (macOS), PE / MZ (Windows)
|
|
25
|
+
if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) return true;
|
|
26
|
+
const m = buf.readUInt32BE(0);
|
|
27
|
+
if (m === 0xcafebabe || m === 0xcffaedfe || m === 0xfeedfacf) return true;
|
|
28
|
+
if (buf[0] === 0x4d && buf[1] === 0x5a) return true;
|
|
29
|
+
return false;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isLikelyRealDroidBinary(p) {
|
|
36
|
+
try {
|
|
37
|
+
const s = statSync(p);
|
|
38
|
+
if (!s.isFile()) return false;
|
|
39
|
+
if (s.size < MIN_REAL_DROID_SIZE) return false;
|
|
40
|
+
return magicMatches(p);
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function searchPath(name) {
|
|
47
|
+
const PATH = process.env.PATH || '';
|
|
48
|
+
const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
|
|
49
|
+
const found = [];
|
|
50
|
+
for (const dir of PATH.split(delimiter)) {
|
|
51
|
+
if (!dir) continue;
|
|
52
|
+
for (const ext of exts) {
|
|
53
|
+
const p = join(dir, name + ext);
|
|
54
|
+
if (existsSync(p)) found.push(p);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function findDroidBinary() {
|
|
61
|
+
const tried = [];
|
|
62
|
+
const push = (label, p) => { if (p) tried.push({ label, path: p }); };
|
|
63
|
+
|
|
64
|
+
if (process.env.DROID_BIN) push('$DROID_BIN', process.env.DROID_BIN);
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const platform = require('droid/platform.js');
|
|
68
|
+
const info = platform.getBinaryPathWithInfo?.() ?? null;
|
|
69
|
+
if (info?.path) push('droid npm package', info.path);
|
|
70
|
+
} catch { /* ignore */ }
|
|
71
|
+
|
|
72
|
+
for (const p of searchPath('droid')) push('PATH', p);
|
|
73
|
+
|
|
74
|
+
push('~/.local/bin/droid.bun-orig', join(homedir(), '.local/bin/droid.bun-orig'));
|
|
75
|
+
push('~/.local/bin/droid', join(homedir(), '.local/bin/droid'));
|
|
76
|
+
|
|
77
|
+
for (const c of tried) {
|
|
78
|
+
if (isLikelyRealDroidBinary(c.path)) {
|
|
79
|
+
return { path: c.path, source: c.label };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { path: null, source: null, tried };
|
|
83
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thezzisu/droidnode",
|
|
3
|
+
"version": "0.140.0",
|
|
4
|
+
"description": "Run Factory's droid CLI on plain Node.js. Sidesteps the Bun 1.3.x standalone-init NULL-allocator race that crashes `droid --resume`, and Bun's long-session memory leaks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"droidnode": "bin/droidnode.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"src/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"droid": "^0.140.0",
|
|
18
|
+
"koffi": "^2.10.0",
|
|
19
|
+
"ws": "^8.18.0"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20.6.0"
|
|
23
|
+
},
|
|
24
|
+
"os": [
|
|
25
|
+
"linux",
|
|
26
|
+
"darwin"
|
|
27
|
+
],
|
|
28
|
+
"cpu": [
|
|
29
|
+
"x64",
|
|
30
|
+
"arm64"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/thezzisu/droidnode.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/thezzisu/droidnode/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/thezzisu/droidnode#readme",
|
|
40
|
+
"keywords": [
|
|
41
|
+
"droid",
|
|
42
|
+
"factory",
|
|
43
|
+
"factory-ai",
|
|
44
|
+
"bun",
|
|
45
|
+
"workaround",
|
|
46
|
+
"shim",
|
|
47
|
+
"cli"
|
|
48
|
+
],
|
|
49
|
+
"author": "thezzisu",
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Shim for `import { dlopen, FFIType, ptr } from "bun:ffi"`
|
|
2
|
+
// Backed by koffi so droid's PTY (librust_pty-*.so) loads under plain Node.
|
|
3
|
+
//
|
|
4
|
+
// Bun's FFI API contract used by droid (see droid.js @ ~6549438):
|
|
5
|
+
// dlopen(path, { fnName: { args:[FFIType.*], returns: FFIType.* } })
|
|
6
|
+
// → { symbols: { fnName: callable }, close() }
|
|
7
|
+
// FFIType.cstring / .pointer / .i32 / .void → koffi type strings
|
|
8
|
+
// ptr(buffer) → returns a native pointer / koffi pointer wrapper
|
|
9
|
+
//
|
|
10
|
+
// librust_pty-*.so ABI notes (probed against the bundled Linux .so):
|
|
11
|
+
// bun_pty_write(id, ptr, len) -> i32: 0 = submitted, -1 = error (invalid id/NULL buf/neg len)
|
|
12
|
+
// NOT a byte count; droid ignores the return value.
|
|
13
|
+
// bun_pty_read (id, ptr, len) -> i32: >0 = bytes read, -1 = invalid id, -2 = would-block (no data)
|
|
14
|
+
// bun_pty_spawn(...) -> i32: >=1 = pty_id, -1 = error
|
|
15
|
+
// bun_pty_get_pid / _exit_code / _kill / _resize -> i32 status
|
|
16
|
+
// bun_pty_close(id) -> void
|
|
17
|
+
|
|
18
|
+
import koffi from 'koffi';
|
|
19
|
+
|
|
20
|
+
// Map Bun's FFIType enum → koffi type names
|
|
21
|
+
export const FFIType = Object.freeze({
|
|
22
|
+
void: 'void',
|
|
23
|
+
i8: 'int8_t',
|
|
24
|
+
u8: 'uint8_t',
|
|
25
|
+
i16: 'int16_t',
|
|
26
|
+
u16: 'uint16_t',
|
|
27
|
+
i32: 'int32_t',
|
|
28
|
+
u32: 'uint32_t',
|
|
29
|
+
i64: 'int64_t',
|
|
30
|
+
u64: 'uint64_t',
|
|
31
|
+
f32: 'float',
|
|
32
|
+
f64: 'double',
|
|
33
|
+
bool: 'bool',
|
|
34
|
+
ptr: 'void *',
|
|
35
|
+
pointer: 'void *',
|
|
36
|
+
cstring: 'const char *',
|
|
37
|
+
buffer: 'void *',
|
|
38
|
+
char: 'char',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export function dlopen(libPath, defs) {
|
|
42
|
+
const lib = koffi.load(libPath);
|
|
43
|
+
const symbols = {};
|
|
44
|
+
for (const [name, spec] of Object.entries(defs)) {
|
|
45
|
+
const ret = FFIType[spec.returns] ?? spec.returns ?? 'void';
|
|
46
|
+
const args = (spec.args || []).map(a => FFIType[a] ?? a);
|
|
47
|
+
symbols[name] = lib.func(name, ret, args);
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
symbols,
|
|
51
|
+
close() { try { lib.unload?.(); } catch {} },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Bun's ptr(buf) returns an opaque pointer value. koffi accepts Buffer/Uint8Array
|
|
56
|
+
// directly for `void *` params, so the identity is enough for droid's call sites
|
|
57
|
+
// (write/read pass a Buffer to a void* arg).
|
|
58
|
+
export function ptr(buf) {
|
|
59
|
+
return buf;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default { dlopen, FFIType, ptr };
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Bun.serve polyfill for Node.js.
|
|
2
|
+
// Implements the subset droid's DaemonServer uses:
|
|
3
|
+
// Bun.serve({ unix, fetch, websocket }) // Unix socket
|
|
4
|
+
// Bun.serve({ hostname, port, fetch, websocket }) // TCP
|
|
5
|
+
// fetch(req, server) -> Response | undefined // undefined when upgraded
|
|
6
|
+
// server.upgrade(req, { data }) -> boolean
|
|
7
|
+
// websocket handlers: { open(ws), message(ws,data), close(ws,code,reason), pong(ws), ping(ws), drain(ws) }
|
|
8
|
+
// ws.data // custom data set at upgrade time
|
|
9
|
+
// ws.send(data)
|
|
10
|
+
// ws.close(code?, reason?)
|
|
11
|
+
//
|
|
12
|
+
// Backed by node:http + ws package.
|
|
13
|
+
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import { unlinkSync, existsSync } from 'node:fs';
|
|
16
|
+
import { Readable } from 'node:stream';
|
|
17
|
+
// `ws` is loaded lazily inside bunServe() so droid commands that never call
|
|
18
|
+
// Bun.serve (--version, --help, --resume without daemon) don't require it.
|
|
19
|
+
|
|
20
|
+
// Per-request upgrade context: maps an upgrade-eligible Request → { data, accepted }.
|
|
21
|
+
// Inside the fetch handler we set `accepted = true`; after fetch returns we look up
|
|
22
|
+
// this entry on the raw upgrade event and complete the handshake via ws.handleUpgrade().
|
|
23
|
+
const upgradeRegistry = new WeakMap();
|
|
24
|
+
|
|
25
|
+
function nodeReqToWebRequest(nodeReq, isUpgrade = false) {
|
|
26
|
+
const host = nodeReq.headers.host ?? 'localhost';
|
|
27
|
+
const proto = nodeReq.headers['x-forwarded-proto'] ?? (nodeReq.socket.encrypted ? 'https' : 'http');
|
|
28
|
+
const url = new URL(nodeReq.url, `${proto}://${host}`);
|
|
29
|
+
const headers = new Headers();
|
|
30
|
+
for (const [k, v] of Object.entries(nodeReq.headers)) {
|
|
31
|
+
if (Array.isArray(v)) v.forEach(vv => headers.append(k, vv));
|
|
32
|
+
else if (v !== undefined) headers.set(k, String(v));
|
|
33
|
+
}
|
|
34
|
+
// Methods other than GET/HEAD may have a body — wrap node stream as web ReadableStream
|
|
35
|
+
let body = null;
|
|
36
|
+
if (!isUpgrade && nodeReq.method !== 'GET' && nodeReq.method !== 'HEAD') {
|
|
37
|
+
body = Readable.toWeb ? Readable.toWeb(nodeReq) : nodeReq;
|
|
38
|
+
}
|
|
39
|
+
const req = new Request(url, {
|
|
40
|
+
method: nodeReq.method,
|
|
41
|
+
headers,
|
|
42
|
+
body,
|
|
43
|
+
duplex: body ? 'half' : undefined,
|
|
44
|
+
});
|
|
45
|
+
return req;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function sendWebResponseToNode(webResp, nodeRes) {
|
|
49
|
+
if (!webResp) {
|
|
50
|
+
nodeRes.writeHead(204);
|
|
51
|
+
nodeRes.end();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const headers = {};
|
|
55
|
+
webResp.headers.forEach((v, k) => {
|
|
56
|
+
if (headers[k]) headers[k] = `${headers[k]}, ${v}`;
|
|
57
|
+
else headers[k] = v;
|
|
58
|
+
});
|
|
59
|
+
nodeRes.writeHead(webResp.status, headers);
|
|
60
|
+
if (!webResp.body) { nodeRes.end(); return; }
|
|
61
|
+
// Stream body
|
|
62
|
+
const reader = webResp.body.getReader();
|
|
63
|
+
while (true) {
|
|
64
|
+
const { done, value } = await reader.read();
|
|
65
|
+
if (done) break;
|
|
66
|
+
if (value) nodeRes.write(value);
|
|
67
|
+
}
|
|
68
|
+
nodeRes.end();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function wrapWs(wsConn, data, handlers) {
|
|
72
|
+
// Bun-style ws wrapper around a `ws` package WebSocket. Exposed to handlers.
|
|
73
|
+
const wrapper = {
|
|
74
|
+
data,
|
|
75
|
+
get readyState() { return wsConn.readyState; },
|
|
76
|
+
get remoteAddress() { return wsConn._socket?.remoteAddress; },
|
|
77
|
+
send(message, compress) {
|
|
78
|
+
if (typeof message === 'string') wsConn.send(message);
|
|
79
|
+
else if (message instanceof ArrayBuffer) wsConn.send(Buffer.from(message));
|
|
80
|
+
else wsConn.send(message);
|
|
81
|
+
return message?.length ?? 0;
|
|
82
|
+
},
|
|
83
|
+
close(code, reason) { wsConn.close(code, reason); },
|
|
84
|
+
terminate() { wsConn.terminate(); },
|
|
85
|
+
ping(data) { try { wsConn.ping(data); } catch {} },
|
|
86
|
+
pong(data) { try { wsConn.pong(data); } catch {} },
|
|
87
|
+
subscribe() {}, unsubscribe() {}, publish() {}, // pubsub stub (not used by droid)
|
|
88
|
+
isSubscribed() { return false; },
|
|
89
|
+
};
|
|
90
|
+
wsConn.on('message', (raw, isBinary) => {
|
|
91
|
+
const payload = isBinary ? raw : raw.toString('utf8');
|
|
92
|
+
try { handlers.message?.(wrapper, payload); } catch (e) { console.error('[bun-serve] message handler threw:', e); }
|
|
93
|
+
});
|
|
94
|
+
wsConn.on('close', (code, reason) => {
|
|
95
|
+
try { handlers.close?.(wrapper, code, reason?.toString('utf8') ?? ''); } catch (e) { console.error('[bun-serve] close handler threw:', e); }
|
|
96
|
+
});
|
|
97
|
+
wsConn.on('pong', () => { try { handlers.pong?.(wrapper); } catch (e) { console.error('[bun-serve] pong handler threw:', e); } });
|
|
98
|
+
wsConn.on('ping', () => { try { handlers.ping?.(wrapper); } catch (e) { console.error('[bun-serve] ping handler threw:', e); } });
|
|
99
|
+
wsConn.on('error', (e) => { try { handlers.error?.(wrapper, e); } catch {} });
|
|
100
|
+
// Idle timeout (Bun default 120s, droid passes idleTimeout via spread)
|
|
101
|
+
if (handlers.idleTimeout && handlers.idleTimeout > 0) {
|
|
102
|
+
let timer;
|
|
103
|
+
const reset = () => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
timer = setTimeout(() => wsConn.terminate(), handlers.idleTimeout * 1000);
|
|
106
|
+
timer.unref?.();
|
|
107
|
+
};
|
|
108
|
+
wsConn.on('message', reset);
|
|
109
|
+
wsConn.on('pong', reset);
|
|
110
|
+
reset();
|
|
111
|
+
wsConn.on('close', () => clearTimeout(timer));
|
|
112
|
+
}
|
|
113
|
+
return wrapper;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function bunServe(opts) {
|
|
117
|
+
const wsHandlers = opts.websocket || {};
|
|
118
|
+
const fetchHandler = opts.fetch;
|
|
119
|
+
if (typeof fetchHandler !== 'function') {
|
|
120
|
+
throw new Error('[bun-serve] fetch handler is required');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const httpServer = http.createServer();
|
|
124
|
+
// Lazy: only needed when an actual upgrade request arrives, so daemon-less commands skip ws.
|
|
125
|
+
let wsServer = null;
|
|
126
|
+
const getWsServer = async () => {
|
|
127
|
+
if (!wsServer) {
|
|
128
|
+
const { WebSocketServer } = await import('ws');
|
|
129
|
+
wsServer = new WebSocketServer({ noServer: true });
|
|
130
|
+
}
|
|
131
|
+
return wsServer;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const server = {
|
|
135
|
+
hostname: opts.hostname,
|
|
136
|
+
port: opts.port,
|
|
137
|
+
development: false,
|
|
138
|
+
stop(closeActiveConnections) {
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
if (closeActiveConnections) {
|
|
141
|
+
wsServer?.clients?.forEach(c => c.terminate());
|
|
142
|
+
}
|
|
143
|
+
httpServer.close(() => resolve());
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
/**
|
|
147
|
+
* Bun's server.upgrade(req, {data}) — marks the request for WS upgrade.
|
|
148
|
+
* Returns true on success. Actual handshake happens after fetch handler returns.
|
|
149
|
+
*/
|
|
150
|
+
upgrade(req, info) {
|
|
151
|
+
const entry = upgradeRegistry.get(req);
|
|
152
|
+
if (!entry) return false; // not an upgrade-eligible request
|
|
153
|
+
entry.data = info?.data;
|
|
154
|
+
entry.accepted = true;
|
|
155
|
+
return true;
|
|
156
|
+
},
|
|
157
|
+
publish() { /* pubsub stub */ },
|
|
158
|
+
requestIP(req) {
|
|
159
|
+
const sock = upgradeRegistry.get(req)?.socket ?? req.__nodeSocket;
|
|
160
|
+
return sock ? { address: sock.remoteAddress, family: sock.remoteFamily, port: sock.remotePort } : null;
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Regular HTTP requests
|
|
165
|
+
httpServer.on('request', async (nodeReq, nodeRes) => {
|
|
166
|
+
try {
|
|
167
|
+
const webReq = nodeReqToWebRequest(nodeReq);
|
|
168
|
+
webReq.__nodeSocket = nodeReq.socket;
|
|
169
|
+
const result = await fetchHandler(webReq, server);
|
|
170
|
+
await sendWebResponseToNode(result, nodeRes);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.error('[bun-serve] fetch handler error:', e);
|
|
173
|
+
if (!nodeRes.headersSent) nodeRes.writeHead(500);
|
|
174
|
+
nodeRes.end();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// WebSocket upgrade requests
|
|
179
|
+
httpServer.on('upgrade', async (nodeReq, socket, head) => {
|
|
180
|
+
const webReq = nodeReqToWebRequest(nodeReq, true);
|
|
181
|
+
const entry = { data: undefined, accepted: false, socket };
|
|
182
|
+
upgradeRegistry.set(webReq, entry);
|
|
183
|
+
try {
|
|
184
|
+
const result = await fetchHandler(webReq, server);
|
|
185
|
+
if (!entry.accepted) {
|
|
186
|
+
// fetch returned a Response or rejected upgrade — write it to the raw socket as HTTP
|
|
187
|
+
const status = result?.status ?? 400;
|
|
188
|
+
socket.write(`HTTP/1.1 ${status} ${http.STATUS_CODES[status] ?? 'Bad Request'}\r\n\r\n`);
|
|
189
|
+
socket.destroy();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// Complete handshake; ws package handles it
|
|
193
|
+
(await getWsServer()).handleUpgrade(nodeReq, socket, head, (wsConn) => {
|
|
194
|
+
const wrapper = wrapWs(wsConn, entry.data, wsHandlers);
|
|
195
|
+
try { wsHandlers.open?.(wrapper); } catch (e) { console.error('[bun-serve] open handler threw:', e); }
|
|
196
|
+
});
|
|
197
|
+
} catch (e) {
|
|
198
|
+
console.error('[bun-serve] upgrade fetch error:', e);
|
|
199
|
+
try { socket.destroy(); } catch {}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Listen — async to surface EADDRINUSE the same way as Bun.serve (synchronous-looking throw)
|
|
204
|
+
// Bun.serve throws synchronously; we have to fake that with a sync listen + Promise-trapped error.
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
const onErr = (err) => { reject(err); };
|
|
207
|
+
httpServer.once('error', onErr);
|
|
208
|
+
const onListen = () => {
|
|
209
|
+
httpServer.off('error', onErr);
|
|
210
|
+
httpServer.on('error', (e) => console.error('[bun-serve] post-listen error:', e));
|
|
211
|
+
// Update server.port to actual bound port (Bun does this for port:0)
|
|
212
|
+
const addr = httpServer.address();
|
|
213
|
+
if (addr && typeof addr === 'object') server.port = addr.port;
|
|
214
|
+
resolve(server);
|
|
215
|
+
};
|
|
216
|
+
if (opts.unix) {
|
|
217
|
+
// Bun supports auto-overwriting stale unix sockets; mirror behavior
|
|
218
|
+
try { if (existsSync(opts.unix)) unlinkSync(opts.unix); } catch {}
|
|
219
|
+
httpServer.listen(opts.unix, onListen);
|
|
220
|
+
} else {
|
|
221
|
+
httpServer.listen(opts.port ?? 0, opts.hostname ?? '0.0.0.0', onListen);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Preload: install Node-side polyfills for the Bun globals droid uses.
|
|
2
|
+
// Run via `node --import ./bun-shim.mjs`.
|
|
3
|
+
|
|
4
|
+
import { spawn as cpSpawn, spawnSync as cpSpawnSync } from 'node:child_process';
|
|
5
|
+
import { readFileSync, statSync, createReadStream } from 'node:fs';
|
|
6
|
+
import { fileURLToPath as nodeFileURLToPath } from 'node:url';
|
|
7
|
+
import { createConnection } from 'node:net';
|
|
8
|
+
import { Readable } from 'node:stream';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import bufferModule from 'node:buffer';
|
|
11
|
+
import { bunServe } from './bun-serve.mjs';
|
|
12
|
+
|
|
13
|
+
// ─── execPath / argv0 override ───
|
|
14
|
+
// droid's self-spawn (subagent fanout, restart-after-update) checks
|
|
15
|
+
// `basename(process.execPath).includes("droid")`; without this override
|
|
16
|
+
// it would see "node" and fall back to invoking the literal string "droid"
|
|
17
|
+
// from PATH, which probably points to the buggy standalone we're avoiding.
|
|
18
|
+
const wrapperPath = process.env.DROIDNODE_WRAPPER_PATH;
|
|
19
|
+
if (wrapperPath) {
|
|
20
|
+
try { Object.defineProperty(process, 'execPath', { value: wrapperPath, configurable: true }); } catch { /* sealed */ }
|
|
21
|
+
try { Object.defineProperty(process, 'argv0', { value: 'droid', configurable: true }); } catch { /* sealed */ }
|
|
22
|
+
if (Array.isArray(process.argv) && process.argv.length > 0) process.argv[0] = wrapperPath;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Polyfill `import.meta.require` (Bun-only) — patched into bundle below.
|
|
26
|
+
// Wrapped so we can intercept bare module specs that bundle expected from Bun's
|
|
27
|
+
// runtime (node-fetch, abort-controller) and route them to Node's built-ins.
|
|
28
|
+
// This lets us run with ZERO of those external packages installed.
|
|
29
|
+
const _nodeRequire = createRequire(import.meta.url);
|
|
30
|
+
const _builtinShims = {
|
|
31
|
+
// Both Node 18+ and Bun expose fetch/Request/Response/Headers globally.
|
|
32
|
+
// Gaxios does `WPL(uL("node-fetch"))` at module init then `window.fetch ?? RxB.default`.
|
|
33
|
+
'node-fetch': (() => {
|
|
34
|
+
const f = globalThis.fetch;
|
|
35
|
+
const exp = Object.assign(function(...a) { return f(...a); }, {
|
|
36
|
+
default: f,
|
|
37
|
+
fetch: f,
|
|
38
|
+
Request: globalThis.Request,
|
|
39
|
+
Response: globalThis.Response,
|
|
40
|
+
Headers: globalThis.Headers,
|
|
41
|
+
FormData: globalThis.FormData,
|
|
42
|
+
Blob: globalThis.Blob,
|
|
43
|
+
});
|
|
44
|
+
return exp;
|
|
45
|
+
})(),
|
|
46
|
+
// Node 15+ and Bun have these globals; no need for the legacy polyfill package.
|
|
47
|
+
'abort-controller': {
|
|
48
|
+
AbortController: globalThis.AbortController,
|
|
49
|
+
AbortSignal: globalThis.AbortSignal,
|
|
50
|
+
default: globalThis.AbortController,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
globalThis.__bunRequire = function(spec) {
|
|
54
|
+
if (spec in _builtinShims) return _builtinShims[spec];
|
|
55
|
+
return _nodeRequire(spec);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Node 22+ removed `Buffer.SlowBuffer`; bundle's bundled `buffer-equal-constant-time`
|
|
59
|
+
// does `require('buffer').SlowBuffer`. Aliasing keeps prototype.equal install path alive.
|
|
60
|
+
if (!bufferModule.SlowBuffer) bufferModule.SlowBuffer = bufferModule.Buffer;
|
|
61
|
+
if (!globalThis.SlowBuffer) globalThis.SlowBuffer = bufferModule.Buffer;
|
|
62
|
+
|
|
63
|
+
function adaptSpawnArgs(args) {
|
|
64
|
+
// Bun.spawn signature: spawn(cmd[], opts) OR spawn({cmd, ...opts})
|
|
65
|
+
let cmd, opts = {};
|
|
66
|
+
if (Array.isArray(args[0])) {
|
|
67
|
+
cmd = args[0]; opts = args[1] || {};
|
|
68
|
+
} else {
|
|
69
|
+
cmd = args[0].cmd; opts = args[0];
|
|
70
|
+
}
|
|
71
|
+
return [cmd, opts];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stdioMap = (s) => {
|
|
75
|
+
if (s === undefined || s === 'pipe') return 'pipe';
|
|
76
|
+
if (s === 'ignore' || s === null) return 'ignore';
|
|
77
|
+
if (s === 'inherit') return 'inherit';
|
|
78
|
+
return s;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function bunSpawn(...args) {
|
|
82
|
+
const [cmd, opts] = adaptSpawnArgs(args);
|
|
83
|
+
const stdio = [stdioMap(opts.stdin), stdioMap(opts.stdout), stdioMap(opts.stderr)];
|
|
84
|
+
const child = cpSpawn(cmd[0], cmd.slice(1), { cwd: opts.cwd, env: opts.env, stdio });
|
|
85
|
+
return {
|
|
86
|
+
pid: child.pid,
|
|
87
|
+
stdin: child.stdin,
|
|
88
|
+
stdout: child.stdout && (Readable.toWeb ? Readable.toWeb(child.stdout) : child.stdout),
|
|
89
|
+
stderr: child.stderr && (Readable.toWeb ? Readable.toWeb(child.stderr) : child.stderr),
|
|
90
|
+
exited: new Promise((res) => child.once('exit', (code) => res(code ?? 0))),
|
|
91
|
+
kill: (sig) => child.kill(sig),
|
|
92
|
+
get exitCode() { return child.exitCode; },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function bunSpawnSync(...args) {
|
|
97
|
+
const [cmd, opts] = adaptSpawnArgs(args);
|
|
98
|
+
const stdio = [stdioMap(opts.stdin), stdioMap(opts.stdout), stdioMap(opts.stderr)];
|
|
99
|
+
const r = cpSpawnSync(cmd[0], cmd.slice(1), {
|
|
100
|
+
cwd: opts.cwd, env: opts.env, stdio,
|
|
101
|
+
input: opts.stdin instanceof Buffer ? opts.stdin : undefined,
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
pid: r.pid, stdout: r.stdout, stderr: r.stderr,
|
|
105
|
+
exitCode: r.status, signalCode: r.signal, success: r.status === 0,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function bunFile(path) {
|
|
110
|
+
return {
|
|
111
|
+
text: () => Promise.resolve(readFileSync(path, 'utf8')),
|
|
112
|
+
json: () => Promise.resolve(JSON.parse(readFileSync(path, 'utf8'))),
|
|
113
|
+
arrayBuffer: () => Promise.resolve(readFileSync(path).buffer),
|
|
114
|
+
bytes: () => Promise.resolve(new Uint8Array(readFileSync(path))),
|
|
115
|
+
stream: () => createReadStream(path),
|
|
116
|
+
get size() { try { return statSync(path).size; } catch { return 0; } },
|
|
117
|
+
get exists() { try { statSync(path); return true; } catch { return false; } },
|
|
118
|
+
name: path,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function bunWhich(cmd) {
|
|
123
|
+
const r = cpSpawnSync('which', [cmd], { encoding: 'utf8' });
|
|
124
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function bunGc() {
|
|
128
|
+
if (typeof globalThis.gc === 'function') globalThis.gc();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function bunConnect(opts) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const sock = createConnection({ host: opts.hostname, port: opts.port });
|
|
134
|
+
sock.on('connect', () => { opts.socket?.open?.(sock); resolve(sock); });
|
|
135
|
+
sock.on('data', (d) => opts.socket?.data?.(sock, d));
|
|
136
|
+
sock.on('close', () => opts.socket?.close?.(sock));
|
|
137
|
+
sock.on('error', (e) => { opts.socket?.error?.(sock, e); reject(e); });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function bunServeStub() {
|
|
142
|
+
throw new Error('[bun-shim] Bun.serve unsupported under Node. `droid daemon` needs Bun.');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
globalThis.Bun = globalThis.Bun ?? {
|
|
146
|
+
spawn: bunSpawn,
|
|
147
|
+
spawnSync: bunSpawnSync,
|
|
148
|
+
file: bunFile,
|
|
149
|
+
which: bunWhich,
|
|
150
|
+
gc: bunGc,
|
|
151
|
+
connect: bunConnect,
|
|
152
|
+
serve: bunServe,
|
|
153
|
+
fileURLToPath: nodeFileURLToPath,
|
|
154
|
+
version: '1.3.13-shim',
|
|
155
|
+
revision: 'droidnode',
|
|
156
|
+
env: process.env,
|
|
157
|
+
argv: process.argv,
|
|
158
|
+
main: process.argv[1],
|
|
159
|
+
};
|