@textjs/opencode 0.0.0-stage → 1.0.2

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/README.md CHANGED
@@ -1,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # ▼ @textjs/opencode
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A local bridge that lets [textjs.org/opencode](https://textjs.org/opencode) talk to the [OpenCode](https://opencode.ai) server on your machine. It has no dependencies and runs on Node 22+ or Bun.
4
+
5
+ ```bash
6
+ bunx @textjs/opencode proxy # or: npx @textjs/opencode proxy
7
+ ```
8
+
9
+ Leave it running, open [textjs.org/opencode](https://textjs.org/opencode), enter `http://127.0.0.1:4096` and press **Connect**. Allow local network access if Chrome asks. Stop the bridge with Ctrl+C.
10
+
11
+ Browsers block a public page from reading `http://127.0.0.1` unless the server answers CORS and Private Network Access preflights, and the OpenCode service also requires a password. The bridge adds the headers and the password, and forwards each request to OpenCode.
12
+
13
+ ## Options
14
+
15
+ | Flag | Description |
16
+ | :-- | :-- |
17
+ | `-p, --port <port>` | Port to listen on (default `4096`) |
18
+ | `--target <url>` | OpenCode server URL. Skips discovery. |
19
+ | `--allow-origin <origin>` | Allow another browser origin, such as `https://example.com` or `http://localhost:*`. Repeatable. |
20
+ | `-v, --version` | Print the version |
21
+ | `-h, --help` | Show help |
22
+
23
+ Without `--target`, the bridge asks `opencode service status` for the managed service and reads its password with `opencode service get password`. It caches the result for 30 seconds and looks again when OpenCode answers `401` or can't be reached, so it follows a service that restarts with a new port or password.
24
+
25
+ For a foreground server, pass its URL. If the server already uses port 4096, run the bridge on another port and connect the page to that port:
26
+
27
+ ```bash
28
+ bunx @textjs/opencode proxy --port 4097 --target http://127.0.0.1:4096
29
+ ```
30
+
31
+ For a password-protected foreground server, export `OPENCODE_PASSWORD` in the bridge's shell. `OPENCODE_SERVER_URL` works in place of `--target`.
32
+
33
+ ## Security
34
+
35
+ - **Loopback only.** The bridge listens on `127.0.0.1` and answers only requests whose `Host` is `localhost`, `127.0.0.1` or `[::1]`, which blocks DNS rebinding.
36
+ - **Origin allowlist.** Browser requests must come from `https://textjs.org`, `https://www.textjs.org`, Text.js preview deployments or `http://localhost`, `127.0.0.1` and `[::1]` on any port. Other origins get a `403`. Add your own with `--allow-origin`.
37
+ - **The password stays on your machine.** The bridge keeps it in memory and adds it to requests it forwards to OpenCode. It isn't logged, written to disk or sent to the browser.
38
+ - **Writes need a custom header.** Requests other than `GET`, `HEAD` and `OPTIONS` must send `x-opencode-client: 1`, a header HTML forms can't set. Request bodies are capped at 4 MB.
39
+ - **Passwords apply to loopback targets only.** `OPENCODE_PASSWORD` is never sent to a non-loopback `--target`, and `--target` rejects URLs that contain credentials, a path, a query or a hash ([TX021](https://textjs.org/errors/TX021)).
40
+
41
+ ## Text.js projects
42
+
43
+ `@textjs/app` runs the same bridge as `text proxy`, with the same flags.
44
+
45
+ ## Errors
46
+
47
+ | Code | Meaning |
48
+ | :-- | :-- |
49
+ | [TX021](https://textjs.org/errors/TX021) | `--target` isn't a bare HTTP(S) origin |
50
+ | [TX022](https://textjs.org/errors/TX022) | `--port` is the OpenCode server's own port |
51
+ | [TX023](https://textjs.org/errors/TX023) | A bridge is already running on that port |
52
+ | [TX024](https://textjs.org/errors/TX024) | Something else is using that port |
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // Bun runs the TypeScript sources in this repo. Installed packages, and Node, run the compiled dist/.
3
+ import { existsSync } from "node:fs";
4
+
5
+ const source = new URL("../src/cli.ts", import.meta.url);
6
+ const compiled = new URL("../dist/cli.js", import.meta.url);
7
+ const entry = typeof process.versions.bun === "string" && existsSync(source) ? source : compiled;
8
+
9
+ if (entry === compiled && !existsSync(compiled)) {
10
+ console.error("▼ textjs-opencode: missing dist/cli.js. Run `bun run build`, or run this file with Bun.");
11
+ process.exitCode = 1;
12
+ } else {
13
+ const { main } = await import(entry.href);
14
+ process.exitCode = await main(process.argv.slice(2));
15
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ export interface ParsedArgs {
2
+ command: string;
3
+ port?: number;
4
+ target?: string;
5
+ allowOrigins: string[];
6
+ help: boolean;
7
+ version: boolean;
8
+ }
9
+ export declare function parseArgs(argv: readonly string[]): ParsedArgs;
10
+ export declare const HELP = "Usage: bunx @textjs/opencode proxy [options]\n\nCommands:\n proxy Bridge textjs.org/opencode to the OpenCode server on this machine\n\nOptions:\n -p, --port <port> Port (default 4096)\n --target <url> OpenCode server URL (default: discover)\n --allow-origin <origin> Allow another browser origin (repeatable)\n -v, --version Print the version\n -h, --help Show this help\n";
11
+ export interface RunProxyOptions {
12
+ port?: number;
13
+ target?: string;
14
+ allowOrigins?: readonly string[];
15
+ }
16
+ /** Start the bridge and keep it running until SIGINT or SIGTERM. */
17
+ export declare function runProxy(options?: RunProxyOptions): Promise<never>;
18
+ export declare function main(argv: readonly string[]): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,81 @@
1
+ import { color } from "./color.js";
2
+ import { DEFAULT_PROXY_PORT, startProxy } from "./proxy.js";
3
+ import { VERSION } from "./version.js";
4
+ export function parseArgs(argv) {
5
+ const out = { command: "", allowOrigins: [], help: false, version: false };
6
+ const positional = [];
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const a = argv[i];
9
+ if (a === "-h" || a === "--help")
10
+ out.help = true;
11
+ else if (a === "-v" || a === "--version")
12
+ out.version = true;
13
+ else if (a === "-p" || a === "--port")
14
+ out.port = Number(argv[++i]);
15
+ else if (a.startsWith("--port="))
16
+ out.port = Number(a.slice(7));
17
+ else if (a === "--target")
18
+ out.target = argv[++i];
19
+ else if (a.startsWith("--target="))
20
+ out.target = a.slice(9);
21
+ else if (a === "--allow-origin") {
22
+ const v = argv[++i];
23
+ if (v)
24
+ out.allowOrigins.push(v);
25
+ }
26
+ else if (a.startsWith("--allow-origin="))
27
+ out.allowOrigins.push(a.slice(15));
28
+ else
29
+ positional.push(a);
30
+ }
31
+ out.command = positional[0] ?? "";
32
+ if (out.port !== undefined && !Number.isFinite(out.port))
33
+ out.port = undefined;
34
+ return out;
35
+ }
36
+ export const HELP = `Usage: bunx @textjs/opencode proxy [options]
37
+
38
+ Commands:
39
+ proxy Bridge textjs.org/opencode to the OpenCode server on this machine
40
+
41
+ Options:
42
+ -p, --port <port> Port (default ${DEFAULT_PROXY_PORT})
43
+ --target <url> OpenCode server URL (default: discover)
44
+ --allow-origin <origin> Allow another browser origin (repeatable)
45
+ -v, --version Print the version
46
+ -h, --help Show this help
47
+ `;
48
+ /** Start the bridge and keep it running until SIGINT or SIGTERM. */
49
+ export async function runProxy(options = {}) {
50
+ const bridge = await startProxy({ port: options.port ?? DEFAULT_PROXY_PORT, target: options.target, allowOrigins: options.allowOrigins });
51
+ const stop = async () => {
52
+ await bridge.close();
53
+ process.exit(0);
54
+ };
55
+ process.once("SIGINT", stop);
56
+ process.once("SIGTERM", stop);
57
+ return new Promise(() => undefined);
58
+ }
59
+ export async function main(argv) {
60
+ const args = parseArgs(argv);
61
+ if (args.version) {
62
+ console.log(`@textjs/opencode v${VERSION}`);
63
+ return 0;
64
+ }
65
+ if (args.help || args.command === "" || args.command === "help") {
66
+ console.log(HELP);
67
+ return args.command === "" && !args.help ? 1 : 0;
68
+ }
69
+ if (args.command !== "proxy") {
70
+ console.error(` ${color.red("⨯")} Unknown command ${color.bold(args.command)}.\n`);
71
+ console.log(HELP);
72
+ return 1;
73
+ }
74
+ try {
75
+ return await runProxy(args);
76
+ }
77
+ catch (error) {
78
+ console.error(` ${color.red("⨯")} ${error instanceof Error ? error.message : String(error)}`);
79
+ return 1;
80
+ }
81
+ }
@@ -0,0 +1,7 @@
1
+ export declare const color: {
2
+ bold: (s: string) => string;
3
+ dim: (s: string) => string;
4
+ red: (s: string) => string;
5
+ yellow: (s: string) => string;
6
+ gray: (s: string) => string;
7
+ };
package/dist/color.js ADDED
@@ -0,0 +1,15 @@
1
+ const enabled = () => {
2
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "")
3
+ return false;
4
+ if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== "0")
5
+ return true;
6
+ return Boolean(process.stdout?.isTTY);
7
+ };
8
+ const wrap = (open, close) => (s) => enabled() ? `\x1b[${open}m${s}\x1b[${close}m` : s;
9
+ export const color = {
10
+ bold: wrap(1, 22),
11
+ dim: wrap(2, 22),
12
+ red: wrap(31, 39),
13
+ yellow: wrap(33, 39),
14
+ gray: wrap(90, 39),
15
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The Text.js error codes the bridge throws, copied from `@textjs/core` so this
3
+ * package has no dependencies. `test/errors.test.ts` keeps them identical.
4
+ */
5
+ export declare const ERRORS_URL = "https://textjs.org/errors";
6
+ export declare const TX_MESSAGES: {
7
+ readonly TX021: "Use an HTTP(S) server origin without credentials for --target or OPENCODE_SERVER_URL.";
8
+ readonly TX022: "The OpenCode server already uses port %s. Pick another with --port.";
9
+ readonly TX023: "Bridge already running at %s. Connect to %s (or pass --port %s).";
10
+ readonly TX024: "Port %s is already in use. Run bunx @textjs/opencode proxy --port %s and connect to %s.";
11
+ };
12
+ export type TxCode = keyof typeof TX_MESSAGES;
13
+ export interface TextJsError extends Error {
14
+ code: TxCode;
15
+ }
16
+ export declare function txMessage(code: TxCode, ...args: unknown[]): string;
17
+ export declare function txError(code: TxCode, ...args: unknown[]): TextJsError;
package/dist/errors.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The Text.js error codes the bridge throws, copied from `@textjs/core` so this
3
+ * package has no dependencies. `test/errors.test.ts` keeps them identical.
4
+ */
5
+ export const ERRORS_URL = "https://textjs.org/errors";
6
+ export const TX_MESSAGES = {
7
+ TX021: "Use an HTTP(S) server origin without credentials for --target or OPENCODE_SERVER_URL.",
8
+ TX022: "The OpenCode server already uses port %s. Pick another with --port.",
9
+ TX023: "Bridge already running at %s. Connect to %s (or pass --port %s).",
10
+ TX024: "Port %s is already in use. Run bunx @textjs/opencode proxy --port %s and connect to %s.",
11
+ };
12
+ function formatTemplate(template, args) {
13
+ let i = 0;
14
+ return template.replace(/%s/g, () => (i < args.length ? String(args[i++]) : "…"));
15
+ }
16
+ export function txMessage(code, ...args) {
17
+ return `Text.js error ${code}: ${formatTemplate(TX_MESSAGES[code], args)} ${ERRORS_URL}/${code}`;
18
+ }
19
+ export function txError(code, ...args) {
20
+ const error = new Error(txMessage(code, ...args));
21
+ error.code = code;
22
+ return error;
23
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./proxy.js";
2
+ export { HELP, main, parseArgs, runProxy, type ParsedArgs, type RunProxyOptions } from "./cli.js";
3
+ export { TX_MESSAGES, txError, txMessage, type TextJsError, type TxCode } from "./errors.js";
4
+ export { VERSION } from "./version.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./proxy.js";
2
+ export { HELP, main, parseArgs, runProxy } from "./cli.js";
3
+ export { TX_MESSAGES, txError, txMessage } from "./errors.js";
4
+ export { VERSION } from "./version.js";
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `bunx @textjs/opencode proxy` (and `text proxy`): a loopback bridge that lets
3
+ * https://textjs.org (and local dev origins) talk to the OpenCode server running
4
+ * on this machine.
5
+ *
6
+ * Browsers block a public page from reading `http://127.0.0.1` unless the
7
+ * server answers CORS and Private Network Access preflights, and the desktop
8
+ * daemon additionally requires Basic auth. The bridge adds both, while
9
+ * rejecting every other origin. The daemon password is read into memory only:
10
+ * it is never logged, never returned to the browser and never written to disk.
11
+ */
12
+ import { type Server } from "node:http";
13
+ export declare const DEFAULT_PROXY_PORT = 4096;
14
+ export declare const BRIDGE_HEADER = "x-textjs-bridge";
15
+ export declare const CLIENT_HEADER = "x-opencode-client";
16
+ export declare const MAX_BODY_BYTES: number;
17
+ export declare const DEFAULT_ALLOWED_ORIGINS: readonly string[];
18
+ export interface BridgeTarget {
19
+ url: string;
20
+ /** How the target was found. */
21
+ source: "service" | "flag" | "env";
22
+ /** `Basic …` header value; memory only. */
23
+ authorization?: string;
24
+ }
25
+ export interface BridgeOptions {
26
+ port?: number;
27
+ host?: string;
28
+ /** Explicit OpenCode server URL (skips daemon discovery). */
29
+ target?: string;
30
+ /** Extra allowed browser origins (`https://example.com`, `http://localhost:*`). */
31
+ allowOrigins?: readonly string[];
32
+ /** Override discovery (tests). */
33
+ discover?: () => Promise<BridgeTarget | null>;
34
+ /** Re-run discovery after this long (default 30s). */
35
+ cacheMs?: number;
36
+ log?: boolean;
37
+ }
38
+ /** Strip the port from a `Host` header: `localhost:4096` → `localhost`, `[::1]:4096` → `[::1]`. */
39
+ export declare function hostnameOf(host: string): string;
40
+ /** Whether a browser `Origin` may use the bridge. */
41
+ export declare function isAllowedOrigin(origin: string, extra?: readonly string[]): boolean;
42
+ /** CORS + Private Network Access headers for an allowed origin. */
43
+ export declare function corsHeaders(origin: string): Record<string, string>;
44
+ /** `http://0.0.0.0:49374` → `http://127.0.0.1:49374`; picks the first URL in CLI output. */
45
+ export declare function normalizeTargetUrl(raw: string): string | null;
46
+ /** Loopback aliases (`localhost`, `127.0.0.1`, `[::1]`) with the same port select the trusted target, while requests still go to `trusted`. */
47
+ export declare function selectsTarget(origin: string, trusted: string): boolean;
48
+ /** An explicit target wins. Never apply a foreground server's password to a browser-selected URL. */
49
+ export declare function discoverOpencode(target?: string): Promise<BridgeTarget | null>;
50
+ /** A Vite/SPA fallback can return 200 HTML for *every* path. Verify the API, not just the status. */
51
+ export declare function probeOpenCode(t: {
52
+ url: string;
53
+ authorization?: string;
54
+ }): Promise<number>;
55
+ export interface BridgeHandler {
56
+ (request: Request): Promise<Response>;
57
+ /** The current (cached) target, discovering it if needed. */
58
+ target(force?: boolean): Promise<BridgeTarget | null>;
59
+ }
60
+ /** The bridge as a Web `Request → Response` handler. */
61
+ export declare function createBridgeHandler(options?: BridgeOptions): BridgeHandler;
62
+ export interface StartedBridge {
63
+ server: Server;
64
+ url: string;
65
+ target: BridgeTarget | null;
66
+ close(): Promise<void>;
67
+ }
68
+ /** Start the bridge on `127.0.0.1:<port>`. */
69
+ export declare function startProxy(options?: BridgeOptions): Promise<StartedBridge>;
package/dist/proxy.js ADDED
@@ -0,0 +1,466 @@
1
+ /**
2
+ * `bunx @textjs/opencode proxy` (and `text proxy`): a loopback bridge that lets
3
+ * https://textjs.org (and local dev origins) talk to the OpenCode server running
4
+ * on this machine.
5
+ *
6
+ * Browsers block a public page from reading `http://127.0.0.1` unless the
7
+ * server answers CORS and Private Network Access preflights, and the desktop
8
+ * daemon additionally requires Basic auth. The bridge adds both, while
9
+ * rejecting every other origin. The daemon password is read into memory only:
10
+ * it is never logged, never returned to the browser and never written to disk.
11
+ */
12
+ import { execFile } from "node:child_process";
13
+ import { existsSync } from "node:fs";
14
+ import { createServer } from "node:http";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { color } from "./color.js";
18
+ import { txError } from "./errors.js";
19
+ export const DEFAULT_PROXY_PORT = 4096;
20
+ export const BRIDGE_HEADER = "x-textjs-bridge";
21
+ export const CLIENT_HEADER = "x-opencode-client";
22
+ export const MAX_BODY_BYTES = 4 * 1024 * 1024;
23
+ export const DEFAULT_ALLOWED_ORIGINS = [
24
+ "https://textjs.org",
25
+ "https://www.textjs.org",
26
+ "https://textjs-avg.pages.dev",
27
+ "https://*.textjs-avg.pages.dev",
28
+ "http://localhost:*",
29
+ "http://127.0.0.1:*",
30
+ "http://[::1]:*",
31
+ ];
32
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
33
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
34
+ const FORWARD_REQUEST_HEADERS = ["accept", "content-type", "last-event-id", "x-opencode-directory"];
35
+ const FORWARD_RESPONSE_HEADERS = ["content-type", "location", "x-request-id"];
36
+ /** Strip the port from a `Host` header: `localhost:4096` → `localhost`, `[::1]:4096` → `[::1]`. */
37
+ export function hostnameOf(host) {
38
+ const h = host.trim().toLowerCase();
39
+ if (h.startsWith("[")) {
40
+ const end = h.indexOf("]");
41
+ return end > 0 ? h.slice(0, end + 1) : h;
42
+ }
43
+ return h.replace(/:\d*$/, "");
44
+ }
45
+ function matchOrigin(origin, pattern) {
46
+ if (pattern === origin)
47
+ return true;
48
+ let o;
49
+ try {
50
+ o = new URL(origin);
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ const m = /^(https?):\/\/(\*\.)?(\[[^\]]+\]|[^:/]+)(?::(\*|\d+))?$/.exec(pattern);
56
+ if (!m)
57
+ return false;
58
+ const [, scheme, wildcard, host, port] = m;
59
+ if (o.protocol !== `${scheme}:`)
60
+ return false;
61
+ if (wildcard ? !o.hostname.endsWith(`.${host}`) : o.hostname !== host)
62
+ return false;
63
+ if (port === "*")
64
+ return true;
65
+ return o.port === (port ?? "");
66
+ }
67
+ /** Whether a browser `Origin` may use the bridge. */
68
+ export function isAllowedOrigin(origin, extra = []) {
69
+ if (origin === "null")
70
+ return false;
71
+ for (const p of DEFAULT_ALLOWED_ORIGINS)
72
+ if (matchOrigin(origin, p))
73
+ return true;
74
+ for (const p of extra)
75
+ if (matchOrigin(origin, p))
76
+ return true;
77
+ return false;
78
+ }
79
+ /** CORS + Private Network Access headers for an allowed origin. */
80
+ export function corsHeaders(origin) {
81
+ return {
82
+ "access-control-allow-origin": origin,
83
+ "access-control-allow-private-network": "true",
84
+ "access-control-allow-methods": "GET, HEAD, POST, PATCH, PUT, DELETE, OPTIONS",
85
+ "access-control-allow-headers": `Content-Type, Accept, Last-Event-ID, x-opencode-directory, ${CLIENT_HEADER}`,
86
+ "access-control-expose-headers": BRIDGE_HEADER,
87
+ "access-control-max-age": "86400",
88
+ vary: "Origin",
89
+ };
90
+ }
91
+ /** `http://0.0.0.0:49374` → `http://127.0.0.1:49374`; picks the first URL in CLI output. */
92
+ export function normalizeTargetUrl(raw) {
93
+ const word = raw.split(/\s+/).find((s) => /^https?:\/\//i.test(s));
94
+ if (!word)
95
+ return null;
96
+ try {
97
+ const u = new URL(word);
98
+ if (u.hostname === "0.0.0.0" || u.hostname === "[::]")
99
+ u.hostname = "127.0.0.1";
100
+ return u.origin;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ function opencodeFallbacks() {
107
+ try {
108
+ const home = homedir();
109
+ return [join(home, ".bun", "bin", "opencode"), join(home, ".opencode", "bin", "opencode"), "/opt/homebrew/bin/opencode", "/usr/local/bin/opencode"];
110
+ }
111
+ catch {
112
+ return ["/opt/homebrew/bin/opencode", "/usr/local/bin/opencode"];
113
+ }
114
+ }
115
+ function execOpencode(bin, args) {
116
+ return new Promise((resolve) => {
117
+ execFile(bin, args, { timeout: 4000, windowsHide: true }, (error, stdout) => {
118
+ const enoent = Boolean(error && error.code === "ENOENT");
119
+ resolve({ ok: !error, enoent, stdout: error ? "" : String(stdout).trim() });
120
+ });
121
+ });
122
+ }
123
+ async function run(args) {
124
+ const first = await execOpencode("opencode", args);
125
+ if (!first.enoent)
126
+ return first.stdout;
127
+ for (const candidate of opencodeFallbacks()) {
128
+ if (!existsSync(candidate))
129
+ continue;
130
+ const res = await execOpencode(candidate, args);
131
+ if (!res.enoent)
132
+ return res.stdout;
133
+ }
134
+ return "";
135
+ }
136
+ /** Loopback aliases (`localhost`, `127.0.0.1`, `[::1]`) with the same port select the trusted target, while requests still go to `trusted`. */
137
+ export function selectsTarget(origin, trusted) {
138
+ if (origin === trusted)
139
+ return true;
140
+ const a = new URL(origin);
141
+ const b = new URL(trusted);
142
+ return LOOPBACK_HOSTNAMES.has(a.hostname) && LOOPBACK_HOSTNAMES.has(b.hostname) && a.port === b.port && a.protocol === b.protocol;
143
+ }
144
+ /** An explicit target wins. Never apply a foreground server's password to a browser-selected URL. */
145
+ export async function discoverOpencode(target) {
146
+ const configured = (target ?? process.env.OPENCODE_SERVER_URL ?? "").trim();
147
+ let explicit;
148
+ if (configured) {
149
+ const u = new URL(configured.includes("://") ? configured : `http://${configured}`);
150
+ if (!/^https?:$/.test(u.protocol) || u.username || u.password || u.pathname !== "/" || u.search || u.hash) {
151
+ throw txError("TX021");
152
+ }
153
+ explicit = normalizeTargetUrl(u.origin) ?? undefined;
154
+ }
155
+ const service = normalizeTargetUrl(await run(["service", "status"]));
156
+ if (service && (!explicit || selectsTarget(explicit, service))) {
157
+ const password = await run(["service", "get", "password"]);
158
+ return { url: service, source: "service", authorization: password ? `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` : undefined };
159
+ }
160
+ if (!explicit)
161
+ return null;
162
+ const password = LOOPBACK_HOSTNAMES.has(new URL(explicit).hostname) ? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD : undefined;
163
+ const username = process.env.OPENCODE_PASSWORD ? "opencode" : process.env.OPENCODE_SERVER_USERNAME || "opencode";
164
+ return { url: explicit, source: target ? "flag" : "env", authorization: password ? `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` : undefined };
165
+ }
166
+ /** A Vite/SPA fallback can return 200 HTML for *every* path. Verify the API, not just the status. */
167
+ export async function probeOpenCode(t) {
168
+ const headers = new Headers({ accept: "application/json" });
169
+ if (t.authorization)
170
+ headers.set("authorization", t.authorization);
171
+ try {
172
+ for (const path of ["/api/info", "/session?limit=1"]) {
173
+ const res = await fetch(`${t.url}${path}`, { headers, signal: AbortSignal.timeout(5000), redirect: "manual" });
174
+ if (res.ok && res.headers.get("content-type")?.includes("application/json")) {
175
+ const data = await res.json();
176
+ if (path === "/api/info" ? typeof data?.version === "string" : Array.isArray(data))
177
+ return res.status;
178
+ }
179
+ else {
180
+ await res.body?.cancel();
181
+ if (!res.ok && res.status !== 404 && res.status !== 405)
182
+ return res.status;
183
+ }
184
+ }
185
+ return 415;
186
+ }
187
+ catch {
188
+ return 0;
189
+ }
190
+ }
191
+ function json(status, body, headers = {}) {
192
+ return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store", ...headers } });
193
+ }
194
+ async function readBody(request, limit = MAX_BODY_BYTES) {
195
+ if (!request.body)
196
+ return new ArrayBuffer(0);
197
+ const reader = request.body.getReader();
198
+ const chunks = [];
199
+ let size = 0;
200
+ for (;;) {
201
+ const { done, value } = await reader.read();
202
+ if (done)
203
+ break;
204
+ size += value.byteLength;
205
+ if (size > limit) {
206
+ await reader.cancel().catch(() => undefined);
207
+ return null;
208
+ }
209
+ chunks.push(value);
210
+ }
211
+ const out = new Uint8Array(size);
212
+ let offset = 0;
213
+ for (const c of chunks) {
214
+ out.set(c, offset);
215
+ offset += c.byteLength;
216
+ }
217
+ return out.buffer;
218
+ }
219
+ /** The bridge as a Web `Request → Response` handler. */
220
+ export function createBridgeHandler(options = {}) {
221
+ const discover = options.discover ?? (() => discoverOpencode(options.target));
222
+ const cacheMs = options.cacheMs ?? 30_000;
223
+ const extra = options.allowOrigins ?? [];
224
+ let cached = null;
225
+ const target = async (force = false) => {
226
+ if (!force && cached && Date.now() - cached.at < cacheMs)
227
+ return cached.target;
228
+ cached = { target: await discover(), at: Date.now() };
229
+ return cached.target;
230
+ };
231
+ const handler = (async (request) => {
232
+ const url = new URL(request.url);
233
+ const host = request.headers.get("host") ?? url.host;
234
+ if (!LOOPBACK_HOSTNAMES.has(hostnameOf(host)))
235
+ return json(403, { error: "Forbidden host" });
236
+ const origin = request.headers.get("origin");
237
+ if (origin !== null && !isAllowedOrigin(origin, extra))
238
+ return json(403, { error: "Origin not allowed" });
239
+ const cors = { [BRIDGE_HEADER]: "1", ...(origin !== null ? corsHeaders(origin) : {}) };
240
+ const method = request.method.toUpperCase();
241
+ if (method === "OPTIONS")
242
+ return new Response(null, { status: 204, headers: cors });
243
+ if (url.pathname === "/__bridge") {
244
+ const wasCached = cached !== null && Date.now() - cached.at < cacheMs;
245
+ let t = await target();
246
+ let code = t ? await probeOpenCode(t) : 0;
247
+ if (wasCached && t?.source === "service" && (code === 0 || code === 401)) {
248
+ t = await target(true);
249
+ code = t ? await probeOpenCode(t) : 0;
250
+ }
251
+ return json(200, { bridge: "textjs", target: t?.url ?? null, connected: code >= 200 && code < 300 }, cors);
252
+ }
253
+ if (!SAFE_METHODS.has(method) && request.headers.get(CLIENT_HEADER) !== "1")
254
+ return json(403, { error: `Missing ${CLIENT_HEADER} header` }, cors);
255
+ const wasCached = cached !== null && Date.now() - cached.at < cacheMs;
256
+ let t = await target();
257
+ if (t === null)
258
+ t = await target(true);
259
+ if (t === null)
260
+ return json(502, { error: "No OpenCode server found. Start `opencode` or pass --target." }, cors);
261
+ const buildHeaders = (auth) => {
262
+ const h = new Headers();
263
+ for (const k of FORWARD_REQUEST_HEADERS) {
264
+ const v = request.headers.get(k);
265
+ if (v)
266
+ h.set(k, v);
267
+ }
268
+ if (auth)
269
+ h.set("authorization", auth);
270
+ return h;
271
+ };
272
+ let body;
273
+ if (method !== "GET" && method !== "HEAD") {
274
+ if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY_BYTES)
275
+ return json(413, { error: "Payload Too Large" }, cors);
276
+ const read = await readBody(request);
277
+ if (read === null)
278
+ return json(413, { error: "Payload Too Large" }, cors);
279
+ body = read;
280
+ }
281
+ const canRetry = wasCached && t.source === "service" && SAFE_METHODS.has(method);
282
+ let upstream;
283
+ try {
284
+ upstream = await fetch(`${t.url}${url.pathname}${url.search}`, { method, headers: buildHeaders(t.authorization), body, signal: request.signal, redirect: "manual" });
285
+ }
286
+ catch (error) {
287
+ if (request.signal.aborted)
288
+ return new Response(null, { status: 499, headers: cors });
289
+ cached = null;
290
+ if (canRetry) {
291
+ const refreshed = await target(true);
292
+ if (refreshed) {
293
+ try {
294
+ upstream = await fetch(`${refreshed.url}${url.pathname}${url.search}`, { method, headers: buildHeaders(refreshed.authorization), body, signal: request.signal, redirect: "manual" });
295
+ }
296
+ catch (retryError) {
297
+ if (request.signal.aborted)
298
+ return new Response(null, { status: 499, headers: cors });
299
+ cached = null;
300
+ return json(502, { error: "OpenCode server unreachable", detail: retryError instanceof Error ? retryError.message : String(retryError) }, cors);
301
+ }
302
+ }
303
+ else {
304
+ return json(502, { error: "OpenCode server unreachable", detail: error instanceof Error ? error.message : String(error) }, cors);
305
+ }
306
+ }
307
+ else {
308
+ return json(502, { error: "OpenCode server unreachable", detail: error instanceof Error ? error.message : String(error) }, cors);
309
+ }
310
+ }
311
+ // The daemon may have restarted with a new port or password.
312
+ if (upstream.status === 401) {
313
+ cached = null;
314
+ if (canRetry) {
315
+ const refreshed = await target(true);
316
+ if (refreshed && (refreshed.url !== t.url || refreshed.authorization !== t.authorization)) {
317
+ await upstream.body?.cancel().catch(() => undefined);
318
+ try {
319
+ upstream = await fetch(`${refreshed.url}${url.pathname}${url.search}`, { method, headers: buildHeaders(refreshed.authorization), body, signal: request.signal, redirect: "manual" });
320
+ }
321
+ catch (retryError) {
322
+ if (request.signal.aborted)
323
+ return new Response(null, { status: 499, headers: cors });
324
+ cached = null;
325
+ return json(502, { error: "OpenCode server unreachable", detail: retryError instanceof Error ? retryError.message : String(retryError) }, cors);
326
+ }
327
+ }
328
+ }
329
+ }
330
+ const out = new Headers(cors);
331
+ for (const h of FORWARD_RESPONSE_HEADERS) {
332
+ const v = upstream.headers.get(h);
333
+ if (v)
334
+ out.set(h, v);
335
+ }
336
+ out.set("cache-control", "no-cache");
337
+ if ((upstream.headers.get("content-type") ?? "").includes("text/event-stream")) {
338
+ out.set("connection", "keep-alive");
339
+ out.set("x-accel-buffering", "no");
340
+ }
341
+ return new Response(method === "HEAD" ? null : upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: out });
342
+ });
343
+ handler.target = target;
344
+ return handler;
345
+ }
346
+ async function serveNode(handler, req, res) {
347
+ const abort = new AbortController();
348
+ res.on("close", () => abort.abort());
349
+ const headers = new Headers();
350
+ for (const [k, v] of Object.entries(req.headers)) {
351
+ if (typeof v === "string")
352
+ headers.set(k, v);
353
+ else if (Array.isArray(v))
354
+ headers.set(k, v.join(", "));
355
+ }
356
+ const method = (req.method ?? "GET").toUpperCase();
357
+ const hasBody = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
358
+ const body = hasBody
359
+ ? new ReadableStream({
360
+ start(controller) {
361
+ req.on("data", (c) => controller.enqueue(new Uint8Array(c)));
362
+ req.on("end", () => controller.close());
363
+ req.on("error", (e) => controller.error(e));
364
+ },
365
+ cancel() {
366
+ req.destroy();
367
+ },
368
+ })
369
+ : undefined;
370
+ const request = new Request(`http://${req.headers.host ?? "127.0.0.1"}${req.url ?? "/"}`, {
371
+ method,
372
+ headers,
373
+ body,
374
+ signal: abort.signal,
375
+ ...(body ? { duplex: "half" } : {}),
376
+ });
377
+ const response = await handler(request);
378
+ res.statusCode = response.status;
379
+ response.headers.forEach((value, key) => res.setHeader(key, value));
380
+ if (!response.body) {
381
+ res.end();
382
+ return;
383
+ }
384
+ res.flushHeaders();
385
+ const reader = response.body.getReader();
386
+ const cancel = () => {
387
+ reader.cancel().catch(() => undefined);
388
+ };
389
+ res.on("close", cancel);
390
+ try {
391
+ for (;;) {
392
+ const { done, value } = await reader.read();
393
+ if (done || res.writableEnded || res.destroyed)
394
+ break;
395
+ res.write(value);
396
+ }
397
+ }
398
+ catch {
399
+ // client disconnected mid-stream
400
+ }
401
+ finally {
402
+ res.off("close", cancel);
403
+ }
404
+ res.end();
405
+ }
406
+ /** Start the bridge on `127.0.0.1:<port>`. */
407
+ export async function startProxy(options = {}) {
408
+ const handler = createBridgeHandler(options);
409
+ const host = options.host ?? "127.0.0.1";
410
+ const port = options.port ?? DEFAULT_PROXY_PORT;
411
+ const target = await handler.target();
412
+ if (target) {
413
+ const t = new URL(target.url);
414
+ if (LOOPBACK_HOSTNAMES.has(t.hostname) && Number(t.port) === port) {
415
+ throw txError("TX022", port);
416
+ }
417
+ }
418
+ const server = createServer((req, res) => {
419
+ serveNode(handler, req, res).catch((error) => {
420
+ if (!res.headersSent) {
421
+ res.statusCode = 500;
422
+ res.setHeader("content-type", "application/json");
423
+ }
424
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
425
+ });
426
+ });
427
+ try {
428
+ await new Promise((resolve, reject) => {
429
+ server.once("error", reject);
430
+ server.listen(port, host, () => {
431
+ server.off("error", reject);
432
+ resolve();
433
+ });
434
+ });
435
+ }
436
+ catch (err) {
437
+ const msg = err instanceof Error ? err.message : String(err);
438
+ if (port > 0 && (err?.code === "EADDRINUSE" || /in use/i.test(msg))) {
439
+ const probeHost = host === "::1" ? "[::1]" : host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
440
+ const existing = await fetch(`http://${probeHost}:${port}/__bridge`, { signal: AbortSignal.timeout(1000) })
441
+ .then((r) => (r.ok ? r.json() : null))
442
+ .catch(() => null);
443
+ if (existing && typeof existing === "object" && existing.bridge === "textjs") {
444
+ const dest = typeof existing.target === "string" ? ` → ${existing.target}` : "";
445
+ throw txError("TX023", `http://${probeHost}:${port}${dest}`, `http://${probeHost}:${port}`, port + 1);
446
+ }
447
+ throw txError("TX024", port, port + 1, `http://${probeHost}:${port + 1}`);
448
+ }
449
+ throw err;
450
+ }
451
+ const addr = server.address();
452
+ const url = `http://${host === "::1" ? "[::1]" : host}:${addr.port}`;
453
+ if (options.log !== false) {
454
+ const dest = target ? `${target.url}${target.source === "service" ? color.dim(" (opencode service)") : ""}` : color.yellow("no OpenCode server yet. Start opencode.");
455
+ console.log(`\n ${color.bold("▼ Text.js OpenCode Bridge")}\n\n ${url} ${color.dim("→")} ${dest}\n ${color.dim("Open https://textjs.org/opencode to connect.")}\n`);
456
+ }
457
+ return {
458
+ server,
459
+ url,
460
+ target,
461
+ close: () => new Promise((resolve) => {
462
+ server.closeAllConnections?.();
463
+ server.close(() => resolve());
464
+ }),
465
+ };
466
+ }
@@ -0,0 +1 @@
1
+ export declare const VERSION = "1.0.2";
@@ -0,0 +1 @@
1
+ export const VERSION = "1.0.2";
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@textjs/opencode",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.2",
4
+ "description": "A loopback bridge that connects textjs.org/opencode to the OpenCode server on your machine.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "homepage": "https://textjs.org/docs/opencode",
9
+ "keywords": [
10
+ "opencode",
11
+ "textjs",
12
+ "cors",
13
+ "proxy",
14
+ "bridge"
15
+ ],
16
+ "bin": {
17
+ "textjs-opencode": "bin/textjs-opencode.js"
18
+ },
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./cli": {
27
+ "types": "./dist/cli.d.ts",
28
+ "default": "./dist/cli.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "bin/textjs-opencode.js",
34
+ "dist",
35
+ "!dist/.tsbuildinfo"
36
+ ],
37
+ "engines": {
38
+ "node": ">=22"
39
+ },
40
+ "scripts": {
41
+ "build": "tsc -b tsconfig.json && bun ../../scripts/esm-specifiers.ts dist",
42
+ "typecheck": "tsc -p tsconfig.typecheck.json",
43
+ "test": "bun test"
44
+ }
45
+ }