mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
Let plugins stream live events and binary data to the app (#8527)
* feat(plugins): stream events and bytes between plugins and the app
Plugins can declare a subscription with defineSubscription, handle it with
server.handle(contract, (input, { push, signal })), and listen from a surface
with useSubscription or client.subscribe. A top-level Uint8Array in an event,
an RPC output, or an RPC input crosses the wire as a binary frame (opcodes
0x20-0x2f) without JSON or base64; a nested one fails validation on the sender.
Each subscriber runs its own handler, owned by the session that subscribed,
so release, disconnect, plugin stop and reload need no plugin code. The
runtime buffers pushes until the subscribe response has gone out, so a client
never sees an event for a subscription it does not know yet.
Gated on server_info.features.pluginSubscriptions; binary outputs reach only
clients that advertise plugin_binary_payloads.
* fix(plugins): close subscription gaps found in review
- Losing daemon.manage now releases the session's plugin subscriptions;
binary events bypass the outbound permission check, so streams would
otherwise outlive the revocation.
- A subscribe that times out sends unsubscribe, since the plugin may
still start the handler after the daemon gave up.
- The byte limit leaves room for the largest frame header and routing,
and lives in assertPluginWireValue so events, outputs and inputs share it.
- A release right after the host accepted keeps `ready` resolved, so a
handler that returns at once reports closed rather than an error.
- Session trace logging tolerates loggers without isLevelEnabled.
* fix(plugins): withhold binary plugin results after plugin access is revoked
A binary RPC output or event skipped the outbound permission check its
JSON form passes, so an RPC in flight when daemon.manage was revoked still
delivered its bytes. Each frame now checks the permission of the JSON
message it stands in for.
* test(plugins): cover subscriptions in a real browser instead of JSDOM
The app subscription tests mounted hooks in JSDOM, which docs/testing.md
rules out. The adapter and contract behavior is now a plain unit test,
and a Playwright spec drives the diff-watch and media-stream examples:
JSON events following file changes, streamed bytes, and an upload.
* test(plugins): add the plain subscription unit test and the browser spec
* fix(plugins): apply subscription schema transforms once
Both ends parsed each subscription value, and the sender sent its parse
output, so the receiver parsed a schema's output as fresh input. An event
schema like z.string().transform((s) => s.length) closed the stream as
invalid. The sender now validates and sends the value as given; the
receiver's parse produces what the handler or callback sees.
* fix(plugins): parse a subscription's input once in the plugin process
server.handle wrapped subscription handlers in a second input parse, so a
transforming input schema rejected its own parsed output before the
handler ran.
This commit is contained in:
@@ -144,6 +144,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
[CLIENT_CAPS.pluginTimelineItems]: true,
|
||||
[CLIENT_CAPS.workspaceSetupBlocked]: true,
|
||||
[CLIENT_CAPS.workspaceSetupObservation]: true,
|
||||
[CLIENT_CAPS.pluginBinaryPayloads]: true,
|
||||
[CLIENT_CAPS.explicitEventSubscriptions]: true,
|
||||
} satisfies Record<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>;
|
||||
|
||||
|
||||
@@ -83,6 +83,26 @@ test("cancellation before the ID waits for bootstrap and releases exactly that I
|
||||
expect(c.released).toEqual(["cancelled"]);
|
||||
});
|
||||
|
||||
test("a release right after the host accepts still reports the subscription as accepted", async () => {
|
||||
const c = connection();
|
||||
const handle = c.observe();
|
||||
c.requests[0]!("closing");
|
||||
const released = handle.release();
|
||||
await expect(handle.ready).resolves.toMatchObject({ subscriptionId: "closing" });
|
||||
await released;
|
||||
expect(c.released).toEqual(["closing"]);
|
||||
});
|
||||
|
||||
test("a release before the host accepts rejects ready", async () => {
|
||||
const c = connection();
|
||||
const handle = c.observe();
|
||||
const released = handle.release();
|
||||
await expect(handle.ready).rejects.toThrow("Subscription released");
|
||||
c.requests[0]!("late");
|
||||
await released;
|
||||
expect(c.released).toEqual(["late"]);
|
||||
});
|
||||
|
||||
test("surviving handles use new remote IDs after reconnect and released handles stay gone", async () => {
|
||||
const c = connection();
|
||||
const a = c.observe();
|
||||
|
||||
@@ -5,6 +5,8 @@ export type SubscriptionSnapshot<T> = T & { subscriptionId: string };
|
||||
export interface SubscriptionObserver<T> {
|
||||
snapshot(snapshot: T): void;
|
||||
update(message: SessionOutboundMessage): void;
|
||||
/** A binary frame the host addressed to this subscription; the payload is a view. */
|
||||
binary?(payload: Uint8Array): void;
|
||||
error?(error: unknown): void;
|
||||
}
|
||||
|
||||
@@ -20,9 +22,12 @@ interface Registration {
|
||||
start(): void;
|
||||
disconnected(): void;
|
||||
receive(message: SessionOutboundMessage): void;
|
||||
receiveBinary(payload: Uint8Array): void;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
|
||||
type Update = { message: SessionOutboundMessage } | { binary: Uint8Array };
|
||||
|
||||
export interface OwnedSubscriptionsHost {
|
||||
release(subscriptionId: string): Promise<void>;
|
||||
failed(error: unknown): void;
|
||||
@@ -50,7 +55,7 @@ export class OwnedSubscriptions {
|
||||
let teardown: Promise<void> | null = null;
|
||||
let disposed = false;
|
||||
let overflow = false;
|
||||
const buffered: SessionOutboundMessage[] = [];
|
||||
const buffered: Update[] = [];
|
||||
const observers = new Set<SubscriptionObserver<SubscriptionSnapshot<T>>>();
|
||||
let resolve!: (snapshot: SubscriptionSnapshot<T>) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
@@ -60,12 +65,10 @@ export class OwnedSubscriptions {
|
||||
});
|
||||
void ready.catch(() => {});
|
||||
|
||||
const deliver = (
|
||||
observer: SubscriptionObserver<SubscriptionSnapshot<T>>,
|
||||
message?: SessionOutboundMessage,
|
||||
) => {
|
||||
const deliver = (observer: SubscriptionObserver<SubscriptionSnapshot<T>>, update?: Update) => {
|
||||
try {
|
||||
if (message) observer.update(message);
|
||||
if (update && "binary" in update) observer.binary?.(update.binary);
|
||||
else if (update) observer.update(update.message);
|
||||
else if (snapshot) observer.snapshot(snapshot);
|
||||
} catch (error) {
|
||||
this.host.failed(error);
|
||||
@@ -78,6 +81,13 @@ export class OwnedSubscriptions {
|
||||
this.routes.delete(released);
|
||||
await this.host.release(released);
|
||||
};
|
||||
const buffer = (update: Update) => {
|
||||
if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(update);
|
||||
else {
|
||||
buffered.length = 0;
|
||||
overflow = true;
|
||||
}
|
||||
};
|
||||
const registration: Registration = {
|
||||
start: () => {
|
||||
if (disposed || pending || id || !this.connected) return;
|
||||
@@ -128,12 +138,14 @@ export class OwnedSubscriptions {
|
||||
},
|
||||
receive: (message) => {
|
||||
if (disposed || ("payload" in message && Object.is(message.payload, snapshot))) return;
|
||||
if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(message);
|
||||
else {
|
||||
buffered.length = 0;
|
||||
overflow = true;
|
||||
}
|
||||
for (const observer of observers) deliver(observer, message);
|
||||
buffer({ message });
|
||||
for (const observer of observers) deliver(observer, { message });
|
||||
},
|
||||
receiveBinary: (payload) => {
|
||||
if (disposed) return;
|
||||
// Bytes wait only for the first observer; retaining them after would hold every frame.
|
||||
if (observers.size === 0) buffer({ binary: payload });
|
||||
for (const observer of observers) deliver(observer, { binary: payload });
|
||||
},
|
||||
release: () => {
|
||||
if (teardown) return teardown;
|
||||
@@ -142,7 +154,9 @@ export class OwnedSubscriptions {
|
||||
observers.clear();
|
||||
buffered.length = 0;
|
||||
if (id) this.routes.delete(id);
|
||||
reject(new Error("Subscription released"));
|
||||
// A host that already accepted keeps `ready` resolved, even if released in the same tick.
|
||||
if (snapshot) resolve(snapshot);
|
||||
else reject(new Error("Subscription released"));
|
||||
teardown = (async () => {
|
||||
await pending;
|
||||
await releaseId();
|
||||
@@ -174,7 +188,7 @@ export class OwnedSubscriptions {
|
||||
.catch(this.host.failed);
|
||||
} else {
|
||||
deliver(observer);
|
||||
for (const message of buffered) deliver(observer, message);
|
||||
for (const update of buffered) deliver(observer, update);
|
||||
}
|
||||
return () => {
|
||||
observers.delete(observer);
|
||||
@@ -205,6 +219,10 @@ export class OwnedSubscriptions {
|
||||
this.routes.get(payload.subscriptionId)?.receive(message);
|
||||
}
|
||||
|
||||
receiveBinary(subscriptionId: string, payload: Uint8Array): void {
|
||||
this.routes.get(subscriptionId)?.receiveBinary(payload);
|
||||
}
|
||||
|
||||
restore(): void {
|
||||
this.connected = true;
|
||||
for (const registration of this.registrations) registration.start();
|
||||
|
||||
@@ -1106,6 +1106,7 @@ test("advertises client capabilities in hello", async () => {
|
||||
terminal_chunked_replay: true,
|
||||
timeline_notifications: true,
|
||||
plugin_timeline_items: true,
|
||||
plugin_binary_payloads: true,
|
||||
workspace_setup_blocked: true,
|
||||
workspace_setup_observation: true,
|
||||
hello_rejection: true,
|
||||
|
||||
@@ -164,6 +164,11 @@ import {
|
||||
FileTransferOpcode,
|
||||
TerminalStreamOpcode,
|
||||
type FileTransferFrame,
|
||||
assertPluginWireValue,
|
||||
decodePluginFrame,
|
||||
encodePluginInputFrame,
|
||||
isPluginFrameOpcode,
|
||||
PluginFrameOpcode,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import {
|
||||
createRelayE2eeTransportFactory,
|
||||
@@ -2260,6 +2265,8 @@ export class DaemonClient {
|
||||
timeout?: number;
|
||||
select: (msg: SessionOutboundMessage) => T | null;
|
||||
options?: { skipQueue?: boolean };
|
||||
/** Sends the request in another form, such as a binary frame; defaults to `message`. */
|
||||
transmit?: () => Promise<void>;
|
||||
}): Promise<T> {
|
||||
const wire = this.owned.prepareRequest(params.message);
|
||||
const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS;
|
||||
@@ -2290,7 +2297,9 @@ export class DaemonClient {
|
||||
// The response deadline still applies while the transport waits for drain.
|
||||
void promise.catch(() => undefined);
|
||||
try {
|
||||
const sent = this.sendSessionMessageOrThrow(wire.message);
|
||||
const sent = params.transmit
|
||||
? params.transmit()
|
||||
: this.sendSessionMessageOrThrow(wire.message);
|
||||
const result = await Promise.race([promise, sent.then(() => promise)]);
|
||||
if (result.kind === "error") throw result.error;
|
||||
return result.value;
|
||||
@@ -2313,12 +2322,14 @@ export class DaemonClient {
|
||||
responseType: TResponseType;
|
||||
options?: { skipQueue?: boolean };
|
||||
selectPayload?: (payload: CorrelatedResponsePayload<TResponseType>) => TResult | null;
|
||||
transmit?: () => Promise<void>;
|
||||
}): Promise<TResult> {
|
||||
return this.sendRequest({
|
||||
requestId: params.requestId,
|
||||
message: params.message,
|
||||
timeout: params.timeout,
|
||||
options: params.options,
|
||||
...(params.transmit ? { transmit: params.transmit } : {}),
|
||||
select: (msg) => {
|
||||
const correlated = msg as CorrelatedResponseMessage;
|
||||
if (correlated.type !== params.responseType) {
|
||||
@@ -2650,6 +2661,7 @@ export class DaemonClient {
|
||||
resetSource = false;
|
||||
if (query.type === "workspace.setup.subscribe.request")
|
||||
this.requireWorkspaceSetupObservation();
|
||||
if (query.type === "plugin.rpc.subscribe.request") this.requirePluginSubscriptions();
|
||||
const requestId = this.createRequestId(options?.requestId);
|
||||
const request = SessionInboundMessageSchema.parse({ ...query, requestId });
|
||||
try {
|
||||
@@ -6197,22 +6209,59 @@ export class DaemonClient {
|
||||
return payload.plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Uint8Array` input or output crosses the wire as a binary frame. Any other value is JSON and
|
||||
* must not contain a `Uint8Array`.
|
||||
*/
|
||||
async invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown> {
|
||||
assertPluginWireValue(input, "input");
|
||||
const requestId = this.createRequestId();
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "plugin.rpc.invoke.request",
|
||||
if (!(input instanceof Uint8Array)) {
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
pluginId,
|
||||
method,
|
||||
input,
|
||||
},
|
||||
message: {
|
||||
type: "plugin.rpc.invoke.request",
|
||||
requestId,
|
||||
pluginId,
|
||||
method,
|
||||
input,
|
||||
},
|
||||
responseType: "plugin.rpc.invoke.response",
|
||||
});
|
||||
return payload.output;
|
||||
}
|
||||
this.requirePluginSubscriptions();
|
||||
const frame = encodePluginInputFrame({ requestId, pluginId, method, payload: input });
|
||||
const payload = await this.sendCorrelatedRequest({
|
||||
requestId,
|
||||
message: { type: "plugin.rpc.invoke.request", requestId, pluginId, method, input: null },
|
||||
responseType: "plugin.rpc.invoke.response",
|
||||
options: { skipQueue: true },
|
||||
transmit: () => this.sendBinaryFrame(frame),
|
||||
});
|
||||
return payload.output;
|
||||
}
|
||||
|
||||
/** One subscription to a plugin subscription handler; events arrive as updates and binary. */
|
||||
observePluginRpc(
|
||||
pluginId: string,
|
||||
method: string,
|
||||
input: unknown,
|
||||
options?: { signal?: AbortSignal },
|
||||
): OwnedSubscription<CorrelatedResponsePayload<"plugin.rpc.subscribe.response">> {
|
||||
assertPluginWireValue(input, "input");
|
||||
return this.observe(
|
||||
"plugin.rpc.subscribe.response",
|
||||
{ type: "plugin.rpc.subscribe.request", pluginId, method, input },
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
private requirePluginSubscriptions(): void {
|
||||
if (this.lastServerInfoMessage?.features?.pluginSubscriptions !== true)
|
||||
throw new Error("Update the host to use plugin subscriptions and binary plugin RPC");
|
||||
}
|
||||
|
||||
async respondToPermissionAndWait(
|
||||
agentId: string,
|
||||
requestId: string,
|
||||
@@ -6975,6 +7024,23 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private handlePluginFrame(rawBytes: Uint8Array): void {
|
||||
const frame = decodePluginFrame(rawBytes);
|
||||
this.consecutiveLivenessFailures = 0;
|
||||
this.runtimeMetrics?.recordBinaryFrame("plugin", rawBytes.byteLength, 0);
|
||||
if (frame?.opcode === PluginFrameOpcode.Event) {
|
||||
this.owned.receiveBinary(frame.subscriptionId, frame.payload);
|
||||
return;
|
||||
}
|
||||
if (frame?.opcode === PluginFrameOpcode.Output) {
|
||||
// The binary form of plugin.rpc.invoke.response.
|
||||
this.resolveWaiters({
|
||||
type: "plugin.rpc.invoke.response",
|
||||
payload: { requestId: frame.requestId, output: frame.payload },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
|
||||
const hostFrame = decodeHostRouteFrame(rawBytes);
|
||||
if (rawBytes[0] === HOST_ROUTE_OPCODE && !hostFrame) {
|
||||
@@ -6986,6 +7052,10 @@ export class DaemonClient {
|
||||
for (const handler of this.hostFrameListeners) handler(hostFrame);
|
||||
return true;
|
||||
}
|
||||
if (isPluginFrameOpcode(rawBytes[0])) {
|
||||
this.handlePluginFrame(rawBytes);
|
||||
return true;
|
||||
}
|
||||
const fileFrame = decodeFileTransferFrame(rawBytes);
|
||||
if (fileFrame) {
|
||||
this.traceInstant("paseo.ws.message.inbound", {
|
||||
|
||||
Reference in New Issue
Block a user