@ccmsg/cli 0.1.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 +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ANONYMOUS,
|
|
3
|
+
type ConnIdentity,
|
|
4
|
+
type Requester,
|
|
5
|
+
type SettledIdentity,
|
|
6
|
+
} from "../dispatch/index.ts";
|
|
7
|
+
|
|
8
|
+
/** One accepted connection, as the layers above transport see it.
|
|
9
|
+
*
|
|
10
|
+
* UDS and WS produce this same type, so nothing above transport can tell them
|
|
11
|
+
* apart (daemon-v2 §3.1). Everything that differs between the two — how a line
|
|
12
|
+
* reaches the peer, how a blocked write is retried — is settled behind `send`. */
|
|
13
|
+
export interface Conn extends Requester {
|
|
14
|
+
/** Distinguishes connections within one process run. It is not an identity:
|
|
15
|
+
* a connection is anonymous until `hello` settles one. */
|
|
16
|
+
readonly id: number;
|
|
17
|
+
/** Anonymous until `hello` binds a role and, for a session, its sid. */
|
|
18
|
+
readonly identity: ConnIdentity;
|
|
19
|
+
/** Bind the identity `hello` established. Called once, by the transport
|
|
20
|
+
* driver, at the moment the `hello` reply goes out. */
|
|
21
|
+
settle(identity: SettledIdentity): void;
|
|
22
|
+
/** Queue one frame as a line. Ordering is preserved; delivery is
|
|
23
|
+
* best-effort, as it is for any socket that may go away mid-write. */
|
|
24
|
+
send(frame: object): void;
|
|
25
|
+
/** Queue one frame to go out once the reply to the request in flight has.
|
|
26
|
+
* `flushDeferred` is what releases it, and only the driver calls that. */
|
|
27
|
+
deferSend(frame: object): void;
|
|
28
|
+
/** Send everything `deferSend` queued, in the order it was queued. */
|
|
29
|
+
flushDeferred(): void;
|
|
30
|
+
/** Close the underlying socket. Idempotent.
|
|
31
|
+
*
|
|
32
|
+
* A code says why, for the one closure a peer must not read as a fault: the
|
|
33
|
+
* loser of a glare is closed deliberately and is normal at the other end
|
|
34
|
+
* (mesh-peer-auth §8.1). Transports that carry no such code ignore it. */
|
|
35
|
+
close(code?: number, reason?: string): void;
|
|
36
|
+
/** Run when the connection is gone. Anything held per connection — the
|
|
37
|
+
* subscriptions of §6.3 once they exist — is released here, because a closed
|
|
38
|
+
* connection is the only end a subscription has. */
|
|
39
|
+
onClose(listener: () => void): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What a transport implementation supplies for one accepted socket: the two
|
|
43
|
+
* operations that differ between UDS and WS. */
|
|
44
|
+
export interface ConnSocket {
|
|
45
|
+
send(line: string): void;
|
|
46
|
+
close(code?: number, reason?: string): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The `Conn` half that is the same for every transport. */
|
|
50
|
+
export class BaseConn implements Conn {
|
|
51
|
+
#identity: ConnIdentity = ANONYMOUS;
|
|
52
|
+
#closed = false;
|
|
53
|
+
readonly #listeners: (() => void)[] = [];
|
|
54
|
+
readonly #deferred: object[] = [];
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
readonly id: number,
|
|
58
|
+
private readonly socket: ConnSocket,
|
|
59
|
+
) {}
|
|
60
|
+
|
|
61
|
+
get identity(): ConnIdentity {
|
|
62
|
+
return this.#identity;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
settle(identity: SettledIdentity): void {
|
|
66
|
+
this.#identity = identity;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
send(frame: object): void {
|
|
70
|
+
if (this.#closed) return;
|
|
71
|
+
this.socket.send(`${JSON.stringify(frame)}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
deferSend(frame: object): void {
|
|
75
|
+
this.#deferred.push(frame);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
flushDeferred(): void {
|
|
79
|
+
for (const frame of this.#deferred.splice(0)) this.send(frame);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
close(code?: number, reason?: string): void {
|
|
83
|
+
this.socket.close(code, reason);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onClose(listener: () => void): void {
|
|
87
|
+
if (this.#closed) {
|
|
88
|
+
listener();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.#listeners.push(listener);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Called by the transport when the socket is gone, once. */
|
|
95
|
+
closed(): void {
|
|
96
|
+
if (this.#closed) return;
|
|
97
|
+
this.#closed = true;
|
|
98
|
+
for (const listener of this.#listeners.splice(0)) listener();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The connections one instance currently holds.
|
|
103
|
+
*
|
|
104
|
+
* It exists so shutdown (§8.5 step 3) can reach every connection before any
|
|
105
|
+
* listener is closed, and so tests can see that a closed connection is gone. */
|
|
106
|
+
export class ConnRegistry {
|
|
107
|
+
readonly #conns = new Set<Conn>();
|
|
108
|
+
#nextId = 1;
|
|
109
|
+
|
|
110
|
+
get size(): number {
|
|
111
|
+
return this.#conns.size;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
[Symbol.iterator](): Iterator<Conn> {
|
|
115
|
+
return this.#conns[Symbol.iterator]();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
nextId(): number {
|
|
119
|
+
return this.#nextId++;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
add(conn: Conn): void {
|
|
123
|
+
this.#conns.add(conn);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
remove(conn: Conn): void {
|
|
127
|
+
this.#conns.delete(conn);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { BaseConn, type Conn, type ConnRegistry } from "./conn.ts";
|
|
2
|
+
|
|
3
|
+
export interface DialOptions {
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly protocols?: readonly string[];
|
|
6
|
+
readonly conns: ConnRegistry;
|
|
7
|
+
/** One frame the far end wrote. A dialled connection is one this instance
|
|
8
|
+
* speaks on rather than one it serves, so what arrives is answered by whoever
|
|
9
|
+
* dialled rather than by dispatch. */
|
|
10
|
+
readonly onFrame: (frame: unknown, conn: Conn) => void;
|
|
11
|
+
/** Why the connection ended, as the far end stated it. The code is what
|
|
12
|
+
* separates a closure that is a fault from one that is the other end's
|
|
13
|
+
* normal course (mesh-peer-auth §8.1). */
|
|
14
|
+
readonly onClose?: (code: number) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A connection this instance opened, as the same `Conn` an accepted one is.
|
|
18
|
+
*
|
|
19
|
+
* daemon-v2 §3.1 has one connection type above transport, and a mesh link is a
|
|
20
|
+
* connection of that layer whichever end dialled it: the two differ in who
|
|
21
|
+
* opened the socket and in nothing the layers above can see. */
|
|
22
|
+
export async function dialWs(options: DialOptions): Promise<Conn> {
|
|
23
|
+
const ws =
|
|
24
|
+
options.protocols === undefined
|
|
25
|
+
? new WebSocket(options.url)
|
|
26
|
+
: new WebSocket(options.url, [...options.protocols]);
|
|
27
|
+
const conn = new BaseConn(options.conns.nextId(), {
|
|
28
|
+
send: (line) => {
|
|
29
|
+
ws.send(line);
|
|
30
|
+
},
|
|
31
|
+
close: (code, reason) => {
|
|
32
|
+
if (code === undefined) ws.close();
|
|
33
|
+
else ws.close(code, reason);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
await new Promise<void>((resolve, reject) => {
|
|
37
|
+
ws.addEventListener("open", () => {
|
|
38
|
+
resolve();
|
|
39
|
+
});
|
|
40
|
+
ws.addEventListener("error", () => {
|
|
41
|
+
reject(new Error(`${options.url} did not accept a connection`));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
options.conns.add(conn);
|
|
45
|
+
ws.addEventListener("message", (event: MessageEvent) => {
|
|
46
|
+
// One message is one frame here, as it is on the accepting side, and the
|
|
47
|
+
// far end writes whole lines.
|
|
48
|
+
for (const line of String(event.data).split("\n")) {
|
|
49
|
+
if (line.trim() === "") continue;
|
|
50
|
+
let frame: unknown;
|
|
51
|
+
try {
|
|
52
|
+
frame = JSON.parse(line);
|
|
53
|
+
} catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
options.onFrame(frame, conn);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
ws.addEventListener("close", (event: CloseEvent) => {
|
|
60
|
+
options.conns.remove(conn);
|
|
61
|
+
conn.closed();
|
|
62
|
+
options.onClose?.(event.code);
|
|
63
|
+
});
|
|
64
|
+
return conn;
|
|
65
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { MAX_FRAME_BYTES, type OpName, type Role, type Sid } from "@ccmsg/protocol";
|
|
2
|
+
import { type DispatchResult, failure, type Requester } from "../dispatch/index.ts";
|
|
3
|
+
import type { Conn } from "./conn.ts";
|
|
4
|
+
|
|
5
|
+
/** What transport calls once a line is a frame. In the instance this is
|
|
6
|
+
* `dispatch` bound to its deps; in tests it is whatever the test needs. */
|
|
7
|
+
export interface FrameHandler {
|
|
8
|
+
(frame: unknown, conn: Requester): Promise<DispatchResult>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The op whose reply settles the connection's identity. Transport knows this
|
|
12
|
+
* one op name because binding the identity is its job (daemon-v2 §3.1); every
|
|
13
|
+
* other op is opaque to it. */
|
|
14
|
+
const HELLO = "hello" satisfies OpName;
|
|
15
|
+
|
|
16
|
+
/** Drive one connection: a line in, a frame answered on the same connection.
|
|
17
|
+
*
|
|
18
|
+
* The same driver runs for UDS and WS. It holds no framing (that is
|
|
19
|
+
* `LineReader`) and no authorization (that is dispatch) — only the two things
|
|
20
|
+
* that belong to a connection: turning text into a frame, and binding the
|
|
21
|
+
* identity that `hello` establishes. */
|
|
22
|
+
export function createDriver(conn: Conn, handle: FrameHandler) {
|
|
23
|
+
return {
|
|
24
|
+
line(text: string): void {
|
|
25
|
+
let frame: unknown;
|
|
26
|
+
try {
|
|
27
|
+
frame = JSON.parse(text);
|
|
28
|
+
} catch {
|
|
29
|
+
conn.send(
|
|
30
|
+
failure(undefined, "bad_request", "a request must be one JSON object per line").response,
|
|
31
|
+
);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
void handle(frame, conn).then(
|
|
35
|
+
(result) => {
|
|
36
|
+
if (result.kind === "none") return;
|
|
37
|
+
settleIfHello(conn, frame, result);
|
|
38
|
+
conn.send(responseOf(result));
|
|
39
|
+
// Whatever the implementation queued for after its reply — the
|
|
40
|
+
// snapshot of a fresh subscription (§6.1) — goes out here.
|
|
41
|
+
conn.flushDeferred();
|
|
42
|
+
},
|
|
43
|
+
(cause: unknown) => {
|
|
44
|
+
// The handler settles every refusal of its own into an answer, so a
|
|
45
|
+
// rejection reaching here is this instance failing rather than the
|
|
46
|
+
// caller asking for something wrong.
|
|
47
|
+
conn.send(
|
|
48
|
+
failure(
|
|
49
|
+
requestIdOf(frame),
|
|
50
|
+
"internal_error",
|
|
51
|
+
`the request could not be answered: ${String(cause)}`,
|
|
52
|
+
).response,
|
|
53
|
+
);
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
overflow(bytes: number): void {
|
|
59
|
+
conn.send(
|
|
60
|
+
failure(
|
|
61
|
+
undefined,
|
|
62
|
+
"bad_request",
|
|
63
|
+
`a request line may not exceed ${MAX_FRAME_BYTES} bytes (got at least ${bytes})`,
|
|
64
|
+
).response,
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Bind role and sid at the moment the `hello` reply goes out.
|
|
71
|
+
*
|
|
72
|
+
* The frame is safe to read because dispatch only answers `reply` after the
|
|
73
|
+
* op's own schema accepted it, so `role` is a role and `sid` — required of a
|
|
74
|
+
* session and absent otherwise — is a sid. */
|
|
75
|
+
function settleIfHello(conn: Conn, frame: unknown, result: DispatchResult): void {
|
|
76
|
+
if (result.kind !== "reply") return;
|
|
77
|
+
const fields = frame as Record<string, unknown>;
|
|
78
|
+
if (fields["op"] !== HELLO) return;
|
|
79
|
+
const sid = fields["sid"];
|
|
80
|
+
conn.settle({
|
|
81
|
+
state: "settled",
|
|
82
|
+
role: fields["role"] as Role,
|
|
83
|
+
...(typeof sid === "string" ? { sid: sid as Sid } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function responseOf(result: Exclude<DispatchResult, { kind: "none" }>): object {
|
|
88
|
+
if (result.kind === "forward") {
|
|
89
|
+
// Mesh is what carries a forwarded op to its instance, and there is none
|
|
90
|
+
// yet: the destination exists but nothing can reach it, which is the code
|
|
91
|
+
// the contract gives that outcome.
|
|
92
|
+
const requestId = requestIdOf(result.frame);
|
|
93
|
+
return failure(requestId, "instance_unreachable", `${result.to} cannot be reached`).response;
|
|
94
|
+
}
|
|
95
|
+
return result.response;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function requestIdOf(frame: unknown): string | undefined {
|
|
99
|
+
if (typeof frame !== "object" || frame === null) return undefined;
|
|
100
|
+
const id = (frame as Record<string, unknown>)["request_id"];
|
|
101
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
102
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Who is allowed to reach this instance at all (daemon-v2 §3.1, "入口の許可").
|
|
2
|
+
*
|
|
3
|
+
* Two questions, because they are asked of different things. `allowRequest`
|
|
4
|
+
* runs for every HTTP request the listener takes, the routed ones included, and
|
|
5
|
+
* answers "may this address, presenting this `Origin`, speak to us at all".
|
|
6
|
+
* `allowUpgrade` runs only for the WebSocket handshake and answers what the
|
|
7
|
+
* connection is let in as, which is what decides the subprotocol the reply
|
|
8
|
+
* selects.
|
|
9
|
+
*
|
|
10
|
+
* The checks are config-driven and the instance supplies them; transport only
|
|
11
|
+
* asks, so no policy is written into the listener. */
|
|
12
|
+
export interface EntryPolicy {
|
|
13
|
+
/** Decide one incoming request before anything is done with it.
|
|
14
|
+
*
|
|
15
|
+
* `source` is the peer address the server observed. It is passed rather than
|
|
16
|
+
* read off the request because a forwarding header is written by whoever is
|
|
17
|
+
* in front of us, and this allowlist is about who actually connected. */
|
|
18
|
+
allowRequest?(request: Request, source: string | undefined): boolean;
|
|
19
|
+
/** Decide the WebSocket handshake itself. */
|
|
20
|
+
allowUpgrade?(request: Request): UpgradeDecision;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The handshake's answer. `protocol` is the subprotocol to select, which the
|
|
24
|
+
* handshake must echo when the client offered any: a browser fails a connection
|
|
25
|
+
* whose reply names none of what it asked for. */
|
|
26
|
+
export type UpgradeDecision =
|
|
27
|
+
| {
|
|
28
|
+
readonly ok: true;
|
|
29
|
+
readonly protocol?: string;
|
|
30
|
+
/** Let in as a peer. Such a connection has shown nothing yet: what it is
|
|
31
|
+
* gets decided by the mesh handshake, so until that finishes it may do
|
|
32
|
+
* only the one thing that can decide it. */
|
|
33
|
+
readonly mesh?: boolean;
|
|
34
|
+
}
|
|
35
|
+
| { readonly ok: false; readonly reason: string };
|
|
36
|
+
|
|
37
|
+
/** Accepts everything. A listener given no policy is open to whatever can reach
|
|
38
|
+
* the address it is bound to. */
|
|
39
|
+
export const OPEN: EntryPolicy = {};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** Newline-delimited JSON, in one place for both transports (daemon-v2 §3.1).
|
|
2
|
+
*
|
|
3
|
+
* The limit and the backpressure handling live here rather than in the UDS and
|
|
4
|
+
* WS listeners, so the two cannot drift into two framings. */
|
|
5
|
+
|
|
6
|
+
import { MAX_FRAME_BYTES } from "@ccmsg/protocol";
|
|
7
|
+
|
|
8
|
+
const NEWLINE = 0x0a;
|
|
9
|
+
|
|
10
|
+
export interface LineReaderSink {
|
|
11
|
+
/** One complete, non-empty line, decoded as UTF-8. */
|
|
12
|
+
line(text: string): void;
|
|
13
|
+
/** A line that reached `MAX_FRAME_BYTES` before its newline. The bytes are
|
|
14
|
+
* dropped and reading resumes at the next newline, so one oversized line
|
|
15
|
+
* costs that line and not the connection. */
|
|
16
|
+
overflow(bytes: number): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Accumulates bytes and hands out whole lines.
|
|
20
|
+
*
|
|
21
|
+
* Bytes are split before they are decoded, so a multi-byte character straddling
|
|
22
|
+
* two chunks is decoded once, whole, at the line boundary. */
|
|
23
|
+
export class LineReader {
|
|
24
|
+
#buffer = new Uint8Array(0);
|
|
25
|
+
/** Set after an overflow: bytes are discarded up to the next newline. */
|
|
26
|
+
#discarding = false;
|
|
27
|
+
#discarded = 0;
|
|
28
|
+
readonly #decoder = new TextDecoder();
|
|
29
|
+
|
|
30
|
+
constructor(private readonly sink: LineReaderSink) {}
|
|
31
|
+
|
|
32
|
+
push(chunk: Uint8Array): void {
|
|
33
|
+
let rest = chunk;
|
|
34
|
+
while (rest.length > 0) {
|
|
35
|
+
if (this.#discarding) {
|
|
36
|
+
const at = rest.indexOf(NEWLINE);
|
|
37
|
+
if (at < 0) {
|
|
38
|
+
this.#discarded += rest.length;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
this.#discarding = false;
|
|
42
|
+
this.sink.overflow(this.#discarded + at);
|
|
43
|
+
this.#discarded = 0;
|
|
44
|
+
rest = rest.subarray(at + 1);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const at = rest.indexOf(NEWLINE);
|
|
48
|
+
if (at < 0) {
|
|
49
|
+
this.#append(rest);
|
|
50
|
+
if (this.#buffer.length > MAX_FRAME_BYTES) this.#startDiscarding();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
this.#append(rest.subarray(0, at));
|
|
54
|
+
rest = rest.subarray(at + 1);
|
|
55
|
+
if (this.#buffer.length > MAX_FRAME_BYTES) {
|
|
56
|
+
this.sink.overflow(this.#buffer.length);
|
|
57
|
+
this.#buffer = new Uint8Array(0);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const text = this.#decoder.decode(this.#buffer);
|
|
61
|
+
this.#buffer = new Uint8Array(0);
|
|
62
|
+
if (text.trim() !== "") this.sink.line(text);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#startDiscarding(): void {
|
|
67
|
+
this.#discarding = true;
|
|
68
|
+
this.#discarded = this.#buffer.length;
|
|
69
|
+
this.#buffer = new Uint8Array(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#append(bytes: Uint8Array): void {
|
|
73
|
+
if (bytes.length === 0) return;
|
|
74
|
+
const grown = new Uint8Array(this.#buffer.length + bytes.length);
|
|
75
|
+
grown.set(this.#buffer);
|
|
76
|
+
grown.set(bytes, this.#buffer.length);
|
|
77
|
+
this.#buffer = grown;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** How one transport hands a line to its socket.
|
|
82
|
+
*
|
|
83
|
+
* `write` returns what the socket would not take: `undefined` when the whole
|
|
84
|
+
* chunk went. This is the one shape both backpressure behaviours fit — a UDS
|
|
85
|
+
* `write` returns a short count and leaves the tail to us, a WS `send` either
|
|
86
|
+
* buffers the whole message itself or drops it whole. */
|
|
87
|
+
export interface ChunkSink<T> {
|
|
88
|
+
encode(line: string): T;
|
|
89
|
+
write(chunk: T): T | undefined;
|
|
90
|
+
/** Nudge the socket to push what it accepted, where that is a separate step. */
|
|
91
|
+
flush?(): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Lines waiting for a socket that is not taking them yet.
|
|
95
|
+
*
|
|
96
|
+
* Every line goes through the queue, including the ones that are written
|
|
97
|
+
* immediately, so a line queued behind a blocked one can never overtake it. */
|
|
98
|
+
export class WriteQueue<T> {
|
|
99
|
+
readonly #pending: T[] = [];
|
|
100
|
+
|
|
101
|
+
constructor(private readonly sink: ChunkSink<T>) {}
|
|
102
|
+
|
|
103
|
+
push(line: string): void {
|
|
104
|
+
this.#pending.push(this.sink.encode(line));
|
|
105
|
+
this.drain();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Write what the socket will take. Called on every push and again whenever
|
|
109
|
+
* the socket reports it has room. */
|
|
110
|
+
drain(): void {
|
|
111
|
+
while (this.#pending.length > 0) {
|
|
112
|
+
const chunk = this.#pending[0] as T;
|
|
113
|
+
let remainder: T | undefined;
|
|
114
|
+
try {
|
|
115
|
+
remainder = this.sink.write(chunk);
|
|
116
|
+
} catch {
|
|
117
|
+
// The socket is going away mid-write; delivery is best-effort.
|
|
118
|
+
this.#pending.length = 0;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (remainder !== undefined) {
|
|
122
|
+
// Still blocked: keep the unsent part at the front and wait to be
|
|
123
|
+
// drained again rather than spinning on a full socket buffer.
|
|
124
|
+
this.#pending[0] = remainder;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
this.#pending.shift();
|
|
128
|
+
}
|
|
129
|
+
this.sink.flush?.();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** One bound address, and the way to give it up.
|
|
2
|
+
*
|
|
3
|
+
* `kind` exists for the stop order of daemon-v2 §8.5: the UDS is released after
|
|
4
|
+
* everything else, because a client reads "the unix socket refuses" as the
|
|
5
|
+
* instance having finished leaving, and a successor may take the resources it
|
|
6
|
+
* sees freed before that. */
|
|
7
|
+
export interface Listener {
|
|
8
|
+
readonly kind: "uds" | "ws";
|
|
9
|
+
/** The socket path, or the bound `host:port` — resolved, so an ephemeral
|
|
10
|
+
* port is readable here. */
|
|
11
|
+
readonly address: string;
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The listeners one instance holds, closed in the order §8.5 requires. */
|
|
16
|
+
export class Transport {
|
|
17
|
+
readonly #listeners: Listener[] = [];
|
|
18
|
+
|
|
19
|
+
add<T extends Listener>(listener: T): T {
|
|
20
|
+
this.#listeners.push(listener);
|
|
21
|
+
return listener;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get listeners(): readonly Listener[] {
|
|
25
|
+
return this.#listeners;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Release every address, the unix socket last. Callers do the steps that
|
|
29
|
+
* come before this one (§8.5 1-4: refuse new work, stop upstream watches,
|
|
30
|
+
* tell the connections, settle what is persisted). */
|
|
31
|
+
async close(): Promise<void> {
|
|
32
|
+
const ordered = [
|
|
33
|
+
...this.#listeners.filter((l) => l.kind !== "uds"),
|
|
34
|
+
...this.#listeners.filter((l) => l.kind === "uds"),
|
|
35
|
+
];
|
|
36
|
+
this.#listeners.length = 0;
|
|
37
|
+
for (const listener of ordered) await listener.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { chmodSync } from "node:fs";
|
|
2
|
+
import { BaseConn, type Conn, type ConnRegistry } from "./conn.ts";
|
|
3
|
+
import { createDriver, type FrameHandler } from "./driver.ts";
|
|
4
|
+
import { LineReader, WriteQueue } from "./framing.ts";
|
|
5
|
+
import type { Listener } from "./listener.ts";
|
|
6
|
+
|
|
7
|
+
interface UdsState {
|
|
8
|
+
conn: BaseConn;
|
|
9
|
+
reader: LineReader;
|
|
10
|
+
queue: WriteQueue<Uint8Array>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UdsOptions {
|
|
14
|
+
/** The socket path.
|
|
15
|
+
*
|
|
16
|
+
* §8.5 asks that closing leave the path alone, because a successor's socket
|
|
17
|
+
* may already stand there. Bun's listener unlinks it in `stop()` regardless
|
|
18
|
+
* (measured against Bun 1.3.13: the path is gone the moment `stop` returns),
|
|
19
|
+
* so this layer cannot honour that on its own — what it can do is not add a
|
|
20
|
+
* second removal of its own. */
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly conns: ConnRegistry;
|
|
23
|
+
readonly handle: FrameHandler;
|
|
24
|
+
/** Runs for each accepted connection, before any frame is read. */
|
|
25
|
+
readonly onConn?: (conn: Conn) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Accept sessions and the CLI on the instance's unix socket.
|
|
29
|
+
*
|
|
30
|
+
* `socket.write` hands the bytes to sendto(2) and returns a short count when
|
|
31
|
+
* the socket buffer is full, so the unsent tail is kept by the queue and
|
|
32
|
+
* written again on `drain` — the difference from WS that §3.1 puts in this
|
|
33
|
+
* layer. */
|
|
34
|
+
export function listenUds(options: UdsOptions): Listener {
|
|
35
|
+
const server = Bun.listen<UdsState>({
|
|
36
|
+
unix: options.path,
|
|
37
|
+
socket: {
|
|
38
|
+
open(socket) {
|
|
39
|
+
const queue = new WriteQueue<Uint8Array>({
|
|
40
|
+
encode: (line) => new TextEncoder().encode(line),
|
|
41
|
+
write(chunk) {
|
|
42
|
+
const written = socket.write(chunk);
|
|
43
|
+
if (written < 0) return undefined; // closing: nothing more will go
|
|
44
|
+
return written === chunk.length ? undefined : chunk.subarray(written);
|
|
45
|
+
},
|
|
46
|
+
flush: () => {
|
|
47
|
+
socket.flush();
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const conn = new BaseConn(options.conns.nextId(), {
|
|
51
|
+
send: (line) => {
|
|
52
|
+
queue.push(line);
|
|
53
|
+
},
|
|
54
|
+
close: () => {
|
|
55
|
+
socket.end();
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const driver = createDriver(conn, options.handle);
|
|
59
|
+
socket.data = { conn, queue, reader: new LineReader(driver) };
|
|
60
|
+
options.conns.add(conn);
|
|
61
|
+
options.onConn?.(conn);
|
|
62
|
+
},
|
|
63
|
+
data(socket, chunk) {
|
|
64
|
+
socket.data.reader.push(chunk);
|
|
65
|
+
},
|
|
66
|
+
drain(socket) {
|
|
67
|
+
socket.data.queue.drain();
|
|
68
|
+
},
|
|
69
|
+
close(socket) {
|
|
70
|
+
const state = socket.data as UdsState | undefined;
|
|
71
|
+
if (state === undefined) return;
|
|
72
|
+
options.conns.remove(state.conn);
|
|
73
|
+
state.conn.closed();
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
// Only this user's sessions may speak to the instance; the socket's own mode
|
|
78
|
+
// is the whole of the entry check on this transport.
|
|
79
|
+
chmodSync(options.path, 0o600);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
kind: "uds",
|
|
83
|
+
address: options.path,
|
|
84
|
+
async close() {
|
|
85
|
+
server.stop(true);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|