Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
386 lines
14 KiB
JavaScript
386 lines
14 KiB
JavaScript
import {
|
|
PLUGIN_STREAM_MAX_CHUNK_BYTES,
|
|
PLUGIN_STREAM_MAX_ID_LENGTH,
|
|
PLUGIN_STREAM_MAX_WINDOW_BYTES,
|
|
PLUGIN_STREAM_MIN_WINDOW_BYTES,
|
|
createMessagePortStreamEnvelope,
|
|
materializeStreamChunk,
|
|
} from "@netcatty/plugin-contract";
|
|
import { PluginError } from "@netcatty/plugin-sdk";
|
|
|
|
function assertStreamId(streamId) {
|
|
if (typeof streamId !== "string"
|
|
|| streamId.length < 1
|
|
|| streamId.length > PLUGIN_STREAM_MAX_ID_LENGTH
|
|
|| streamId.includes("\0")) {
|
|
throw new PluginError("invalid_argument", "Plugin stream ID is invalid");
|
|
}
|
|
return streamId;
|
|
}
|
|
|
|
function assertWindowBytes(windowBytes) {
|
|
if (!Number.isSafeInteger(windowBytes)
|
|
|| windowBytes < PLUGIN_STREAM_MIN_WINDOW_BYTES
|
|
|| windowBytes > PLUGIN_STREAM_MAX_WINDOW_BYTES) {
|
|
throw new PluginError("invalid_argument", "Plugin stream window is invalid");
|
|
}
|
|
return windowBytes;
|
|
}
|
|
|
|
function copyBytes(value) {
|
|
let source;
|
|
if (value instanceof Uint8Array) source = value;
|
|
else if (value instanceof ArrayBuffer) source = new Uint8Array(value);
|
|
else throw new PluginError("invalid_argument", "Plugin stream writes require Uint8Array or ArrayBuffer");
|
|
const copy = new Uint8Array(source.byteLength);
|
|
copy.set(source);
|
|
return copy;
|
|
}
|
|
|
|
function closedError(streamId) {
|
|
return new PluginError("unavailable", `Plugin stream is closed: ${streamId}`);
|
|
}
|
|
|
|
export function createPluginStreamEndpoint(transport, options = {}) {
|
|
const maxStreams = options.maxStreams ?? 128;
|
|
const incoming = new Map();
|
|
const outgoing = new Map();
|
|
const pendingIncoming = new Map();
|
|
let closed = false;
|
|
|
|
const sendEnvelope = (frame, transfer) => {
|
|
const envelope = createMessagePortStreamEnvelope(frame, transfer);
|
|
transport.post(envelope, transfer ? [transfer] : []);
|
|
};
|
|
|
|
const activeStreams = () => incoming.size + outgoing.size;
|
|
const reservedStreams = () => activeStreams() + pendingIncoming.size;
|
|
|
|
function releaseCurrent(state) {
|
|
if (!state.currentCreditBytes || closed) return;
|
|
const creditBytes = state.currentCreditBytes;
|
|
state.currentCreditBytes = 0;
|
|
state.availableBytes += creditBytes;
|
|
state.updateSequence += 1;
|
|
sendEnvelope({
|
|
streamId: state.streamId,
|
|
sequence: state.updateSequence,
|
|
kind: "windowUpdate",
|
|
creditBytes,
|
|
});
|
|
}
|
|
|
|
function settleIncomingWaiters(state) {
|
|
while (state.readers.length > 0) {
|
|
if (state.queue.length > 0) {
|
|
const reader = state.readers.shift();
|
|
const chunk = state.queue.shift();
|
|
state.currentCreditBytes = chunk.creditBytes;
|
|
reader.resolve(chunk.data);
|
|
continue;
|
|
}
|
|
if (!state.closed) break;
|
|
const reader = state.readers.shift();
|
|
if (state.error) reader.reject(state.error);
|
|
else reader.resolve(null);
|
|
}
|
|
}
|
|
|
|
function closeIncoming(state, error, notify = false) {
|
|
if (state.closed) return;
|
|
state.closed = true;
|
|
state.error = error ?? null;
|
|
incoming.delete(state.streamId);
|
|
if (notify) {
|
|
try {
|
|
sendEnvelope({
|
|
streamId: state.streamId,
|
|
sequence: state.nextSequence,
|
|
kind: "cancel",
|
|
});
|
|
} catch {}
|
|
}
|
|
settleIncomingWaiters(state);
|
|
}
|
|
|
|
function readableHandle(state) {
|
|
return Object.freeze({
|
|
id: state.streamId,
|
|
async read() {
|
|
if (state.readers.length > 0) {
|
|
throw new PluginError("failed_precondition", "Plugin stream does not allow concurrent reads");
|
|
}
|
|
if (state.currentCreditBytes) releaseCurrent(state);
|
|
if (state.queue.length > 0) {
|
|
const chunk = state.queue.shift();
|
|
state.currentCreditBytes = chunk.creditBytes;
|
|
return chunk.data;
|
|
}
|
|
if (state.closed) {
|
|
if (state.error) throw state.error;
|
|
return null;
|
|
}
|
|
return new Promise((resolve, reject) => state.readers.push({ resolve, reject }));
|
|
},
|
|
cancel() { closeIncoming(state, new PluginError("cancelled", "Plugin stream was cancelled"), true); },
|
|
dispose() { closeIncoming(state, new PluginError("cancelled", "Plugin stream was disposed"), true); },
|
|
});
|
|
}
|
|
|
|
function flushOutgoing(state) {
|
|
while (!state.closed && state.queue.length > 0) {
|
|
const pending = state.queue[0];
|
|
if (pending.bytes.byteLength > state.availableBytes) break;
|
|
state.queue.shift();
|
|
state.queuedBytes -= pending.bytes.byteLength;
|
|
state.availableBytes -= pending.bytes.byteLength;
|
|
state.nextSequence += 1;
|
|
try {
|
|
sendEnvelope({
|
|
streamId: state.streamId,
|
|
sequence: state.nextSequence,
|
|
kind: "chunk",
|
|
data: { encoding: "transfer", byteLength: pending.bytes.byteLength },
|
|
}, pending.bytes.buffer);
|
|
pending.resolve();
|
|
} catch (error) {
|
|
state.closed = true;
|
|
outgoing.delete(state.streamId);
|
|
pending.reject(error);
|
|
for (const queued of state.queue.splice(0)) queued.reject(error);
|
|
state.queuedBytes = 0;
|
|
state.endReject?.(error);
|
|
}
|
|
}
|
|
if (!state.closed && state.terminal === "end" && state.queue.length === 0 && !state.terminalSent) {
|
|
state.terminalSent = true;
|
|
state.nextSequence += 1;
|
|
try {
|
|
sendEnvelope({ streamId: state.streamId, sequence: state.nextSequence, kind: "end" });
|
|
state.endResolve?.();
|
|
if (state.availableBytes === state.windowBytes) {
|
|
state.closed = true;
|
|
outgoing.delete(state.streamId);
|
|
}
|
|
} catch (error) {
|
|
state.closed = true;
|
|
outgoing.delete(state.streamId);
|
|
state.endReject?.(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
function writableHandle(state) {
|
|
return Object.freeze({
|
|
id: state.streamId,
|
|
write(value) {
|
|
if (state.closed || state.terminal) return Promise.reject(closedError(state.streamId));
|
|
const bytes = copyBytes(value);
|
|
if (bytes.byteLength < 1 || bytes.byteLength > PLUGIN_STREAM_MAX_CHUNK_BYTES) {
|
|
return Promise.reject(new PluginError("out_of_range", "Plugin stream chunk size is invalid"));
|
|
}
|
|
if (state.queuedBytes + bytes.byteLength > state.windowBytes) {
|
|
return Promise.reject(new PluginError("resource_exhausted", "Plugin stream pending writes exceed its window"));
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
state.queue.push({ bytes, resolve, reject });
|
|
state.queuedBytes += bytes.byteLength;
|
|
flushOutgoing(state);
|
|
});
|
|
},
|
|
end() {
|
|
if (state.terminal === "end") return state.endPromise;
|
|
if (state.closed || state.terminal) return Promise.reject(closedError(state.streamId));
|
|
state.terminal = "end";
|
|
state.endPromise = new Promise((resolve, reject) => {
|
|
state.endResolve = resolve;
|
|
state.endReject = reject;
|
|
});
|
|
flushOutgoing(state);
|
|
return state.endPromise;
|
|
},
|
|
fail(error) {
|
|
if (state.closed || state.terminal) return;
|
|
state.terminal = "error";
|
|
state.closed = true;
|
|
outgoing.delete(state.streamId);
|
|
const failure = new PluginError("data_loss", String(error?.message ?? "Plugin stream failed"));
|
|
for (const pending of state.queue.splice(0)) pending.reject(failure);
|
|
state.queuedBytes = 0;
|
|
state.nextSequence += 1;
|
|
sendEnvelope({
|
|
streamId: state.streamId,
|
|
sequence: state.nextSequence,
|
|
kind: "error",
|
|
error: {
|
|
code: -32013,
|
|
message: String(error?.message ?? "Plugin stream failed").slice(0, 2048),
|
|
},
|
|
});
|
|
},
|
|
cancel() {
|
|
if (state.closed) return;
|
|
state.closed = true;
|
|
outgoing.delete(state.streamId);
|
|
const error = new PluginError("cancelled", "Plugin stream was cancelled");
|
|
for (const pending of state.queue.splice(0)) pending.reject(error);
|
|
state.queuedBytes = 0;
|
|
state.endReject?.(error);
|
|
sendEnvelope({
|
|
streamId: state.streamId,
|
|
sequence: state.nextSequence + 1,
|
|
kind: "cancel",
|
|
});
|
|
},
|
|
dispose() { this.cancel(); },
|
|
});
|
|
}
|
|
|
|
function accept(message) {
|
|
if (!message || typeof message !== "object" || !Object.hasOwn(message, "frame")) return false;
|
|
const envelope = createMessagePortStreamEnvelope(message.frame, message.transfer);
|
|
const frame = envelope.frame;
|
|
if (frame.kind === "open") {
|
|
if (!pendingIncoming.has(frame.streamId)) return false;
|
|
if (closed || activeStreams() >= maxStreams || incoming.has(frame.streamId) || outgoing.has(frame.streamId)) {
|
|
throw new PluginError("resource_exhausted", `Plugin stream cannot be opened: ${frame.streamId}`);
|
|
}
|
|
const state = {
|
|
streamId: frame.streamId,
|
|
availableBytes: frame.windowBytes,
|
|
nextSequence: 1,
|
|
updateSequence: -1,
|
|
currentCreditBytes: 0,
|
|
queue: [],
|
|
readers: [],
|
|
closed: false,
|
|
error: null,
|
|
};
|
|
incoming.set(frame.streamId, state);
|
|
const waiter = pendingIncoming.get(frame.streamId);
|
|
pendingIncoming.delete(frame.streamId);
|
|
waiter.resolve(readableHandle(state));
|
|
return true;
|
|
}
|
|
const output = outgoing.get(frame.streamId);
|
|
if (output && frame.kind === "windowUpdate") {
|
|
if (frame.sequence !== output.lastUpdateSequence + 1) {
|
|
throw new PluginError("data_loss", `Plugin stream credit is out of order: ${frame.streamId}`);
|
|
}
|
|
output.lastUpdateSequence = frame.sequence;
|
|
output.availableBytes += frame.creditBytes;
|
|
if (output.availableBytes > output.windowBytes) {
|
|
throw new PluginError("data_loss", `Plugin stream credit exceeds its window: ${frame.streamId}`);
|
|
}
|
|
if (output.terminalSent && output.availableBytes === output.windowBytes) {
|
|
output.closed = true;
|
|
outgoing.delete(frame.streamId);
|
|
} else flushOutgoing(output);
|
|
return true;
|
|
}
|
|
if (output && frame.kind === "cancel") {
|
|
output.closed = true;
|
|
outgoing.delete(frame.streamId);
|
|
const error = new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`);
|
|
for (const pending of output.queue.splice(0)) pending.reject(error);
|
|
output.queuedBytes = 0;
|
|
output.endReject?.(error);
|
|
return true;
|
|
}
|
|
const input = incoming.get(frame.streamId);
|
|
if (!input || input.closed) throw new PluginError("data_loss", `Unknown Plugin stream: ${frame.streamId}`);
|
|
if (frame.sequence !== input.nextSequence) {
|
|
throw new PluginError("data_loss", `Plugin stream frame is out of order: ${frame.streamId}`);
|
|
}
|
|
input.nextSequence += 1;
|
|
if (frame.kind === "chunk") {
|
|
const materialized = materializeStreamChunk(frame.data, envelope.transfer);
|
|
if (materialized.encoding !== "binary" || !(materialized.bytes instanceof Uint8Array)) {
|
|
throw new PluginError("data_loss", "Plugin byte stream received a non-binary chunk");
|
|
}
|
|
const data = materialized.bytes;
|
|
if (data.byteLength > input.availableBytes) {
|
|
throw new PluginError("resource_exhausted", `Plugin stream exceeded receive credit: ${frame.streamId}`);
|
|
}
|
|
input.availableBytes -= data.byteLength;
|
|
input.queue.push({ data, creditBytes: data.byteLength });
|
|
settleIncomingWaiters(input);
|
|
return true;
|
|
}
|
|
closeIncoming(
|
|
input,
|
|
frame.kind === "error"
|
|
? new PluginError("data_loss", frame.error.message)
|
|
: frame.kind === "cancel"
|
|
? new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`)
|
|
: null,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
return Object.freeze({
|
|
accept,
|
|
async acceptReadable(streamId) {
|
|
const id = assertStreamId(streamId);
|
|
const existing = incoming.get(id);
|
|
if (existing) return readableHandle(existing);
|
|
if (closed || pendingIncoming.has(id) || outgoing.has(id)) {
|
|
throw new PluginError("failed_precondition", `Plugin stream cannot be accepted: ${id}`);
|
|
}
|
|
if (reservedStreams() >= maxStreams) {
|
|
throw new PluginError("resource_exhausted", "Plugin stream limit is exhausted");
|
|
}
|
|
return new Promise((resolve, reject) => pendingIncoming.set(id, { resolve, reject }));
|
|
},
|
|
rejectReadable(streamId, error = new PluginError("cancelled", "Plugin stream acceptance was cancelled")) {
|
|
const id = assertStreamId(streamId);
|
|
const waiter = pendingIncoming.get(id);
|
|
if (!waiter) return false;
|
|
pendingIncoming.delete(id);
|
|
waiter.reject(error);
|
|
return true;
|
|
},
|
|
async openWritable(streamId, windowBytes = 256 * 1024) {
|
|
const id = assertStreamId(streamId);
|
|
const window = assertWindowBytes(windowBytes);
|
|
if (closed || reservedStreams() >= maxStreams || incoming.has(id) || outgoing.has(id) || pendingIncoming.has(id)) {
|
|
throw new PluginError("resource_exhausted", `Plugin stream cannot be opened: ${id}`);
|
|
}
|
|
const state = {
|
|
streamId: id,
|
|
windowBytes: window,
|
|
availableBytes: window,
|
|
nextSequence: 0,
|
|
lastUpdateSequence: -1,
|
|
queue: [],
|
|
queuedBytes: 0,
|
|
terminal: null,
|
|
terminalSent: false,
|
|
endPromise: null,
|
|
endResolve: null,
|
|
endReject: null,
|
|
closed: false,
|
|
};
|
|
outgoing.set(id, state);
|
|
sendEnvelope({ streamId: id, sequence: 0, kind: "open", windowBytes: window });
|
|
return writableHandle(state);
|
|
},
|
|
close(error = new PluginError("unavailable", "Plugin stream transport closed")) {
|
|
if (closed) return;
|
|
closed = true;
|
|
for (const state of incoming.values()) closeIncoming(state, error);
|
|
for (const state of outgoing.values()) {
|
|
state.closed = true;
|
|
for (const pending of state.queue.splice(0)) pending.reject(error);
|
|
state.queuedBytes = 0;
|
|
state.endReject?.(error);
|
|
}
|
|
outgoing.clear();
|
|
for (const waiter of pendingIncoming.values()) waiter.reject(error);
|
|
pendingIncoming.clear();
|
|
},
|
|
});
|
|
}
|
|
|
|
export { assertStreamId, assertWindowBytes };
|