mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
* 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.
234 lines
8.4 KiB
TypeScript
234 lines
8.4 KiB
TypeScript
import { filePanelTitle } from "@/file-panels/choice/presentation";
|
|
import { i18n } from "@/i18n/i18next";
|
|
import { createPluginHosts } from "./hosts";
|
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
import type { PaseoApi } from "@getpaseo/client";
|
|
import { expect, test } from "vitest";
|
|
import { PluginRegistry } from "./registry";
|
|
|
|
const audio = { play: async () => 0 };
|
|
|
|
/** A host client that records open event observations by their first event name. */
|
|
function observingClient() {
|
|
const open = new Set<string>();
|
|
const client = {
|
|
observeEvents(events: string[]) {
|
|
const [name] = events;
|
|
open.add(name);
|
|
return {
|
|
ready: Promise.resolve({ subscriptionId: name }),
|
|
subscribe: () => () => undefined,
|
|
release: async () => {
|
|
open.delete(name);
|
|
},
|
|
};
|
|
},
|
|
invokePluginRpc: async () => null,
|
|
} as unknown as DaemonClient;
|
|
return { client, open };
|
|
}
|
|
|
|
function registry(
|
|
localeSource: import("@getpaseo/plugin/client/host").PluginLocaleSource = {
|
|
getLocale: () => "en",
|
|
subscribe: () => () => {},
|
|
},
|
|
) {
|
|
const setupClients = new Map<string, PaseoApi>();
|
|
const plugins = new PluginRegistry({
|
|
localeSource,
|
|
version: "0.8.0",
|
|
createRuntime: (installation) => {
|
|
setupClients.set(installation.id, installation.paseo);
|
|
return {
|
|
hosts: createPluginHosts(
|
|
{
|
|
getHosts: () => [],
|
|
getSnapshot: () => null,
|
|
subscribeAll: () => () => {},
|
|
subscribeHostList: () => () => {},
|
|
},
|
|
installation.lifetime.signal,
|
|
),
|
|
paseo: installation.paseo,
|
|
i18n: installation.i18n,
|
|
rpc: async () => {
|
|
throw new Error("Unexpected plugin RPC");
|
|
},
|
|
openScreen: () => {},
|
|
openSurface: () => {},
|
|
openSettings: () => {},
|
|
playAudio: async () => {},
|
|
openPanel: () => {},
|
|
addComposerPill: () => ({ update() {}, remove() {} }),
|
|
addHeaderButton: () => ({ update() {}, remove() {} }),
|
|
subscribe: () => {
|
|
throw new Error("Subscriptions are not used here");
|
|
},
|
|
};
|
|
},
|
|
});
|
|
return { ...observingClient(), plugins, setupClients };
|
|
}
|
|
|
|
/** A plugin whose setup opens an observation named after it and never releases it. */
|
|
function catalog(id: string, body = "return function() {};") {
|
|
return {
|
|
id,
|
|
requirements: { paseo: ">=0.8.0" },
|
|
clientBundle: `(function() { return { default: function(plugin) { plugin.paseo.observeEvents(["${id}"]); ${body} } }; })`,
|
|
};
|
|
}
|
|
|
|
test("setup and every surface share the installation's one Paseo client", () => {
|
|
const h = registry();
|
|
h.plugins.installCatalog("host", [catalog("deploys")], { client: h.client, audio });
|
|
|
|
const [installation] = h.plugins.getSnapshot();
|
|
expect(h.setupClients.get("deploys")).toBe(installation.paseo);
|
|
expect(h.open).toEqual(new Set(["deploys"]));
|
|
});
|
|
|
|
test("teardown ends every subscription the plugin still holds", async () => {
|
|
const h = registry();
|
|
h.plugins.installCatalog("host", [catalog("disabled"), catalog("kept")], {
|
|
client: h.client,
|
|
audio,
|
|
});
|
|
expect(h.open).toEqual(new Set(["disabled", "kept"]));
|
|
|
|
// Disabling one plugin removes it from the catalog.
|
|
h.plugins.installCatalog("host", [catalog("kept")], { client: h.client, audio });
|
|
await expect.poll(() => [...h.open]).toEqual(["kept"]);
|
|
|
|
h.plugins.removeHost("host");
|
|
await expect.poll(() => h.open.size).toBe(0);
|
|
});
|
|
|
|
test("reloading a plugin ends the old installation's subscriptions", async () => {
|
|
const h = registry();
|
|
h.plugins.installCatalog("host", [catalog("reloaded")], { client: h.client, audio });
|
|
const [first] = h.plugins.getSnapshot();
|
|
|
|
h.plugins.installCatalog("host", [catalog("reloaded")], {
|
|
client: h.client,
|
|
audio,
|
|
replacePluginId: "reloaded",
|
|
});
|
|
|
|
const [second] = h.plugins.getSnapshot();
|
|
expect(second).not.toBe(first);
|
|
expect(second.paseo).not.toBe(first.paseo);
|
|
await expect(first.paseo.dispose()).resolves.toBeUndefined();
|
|
expect(() => first.paseo.observeEvents(["project.update"])).toThrow("Paseo API is disposed");
|
|
expect(h.open).toEqual(new Set(["reloaded"]));
|
|
});
|
|
|
|
test("failed plugin initialization ends the subscriptions it opened", async () => {
|
|
const h = registry();
|
|
h.plugins.installCatalog("host", [catalog("failed", 'throw new Error("setup failed");')], {
|
|
client: h.client,
|
|
audio,
|
|
});
|
|
await expect.poll(() => h.open.size).toBe(0);
|
|
expect(h.plugins.getSnapshot()).toEqual([]);
|
|
});
|
|
|
|
test("unloading ends the subscriptions even when plugin cleanup throws, preserving another plugin", async () => {
|
|
const h = registry();
|
|
const failing = catalog("failing", 'return function() { throw new Error("cleanup failed"); };');
|
|
const surviving = catalog("surviving", "return function() {};");
|
|
h.plugins.installCatalog("host", [failing, surviving], { client: h.client, audio });
|
|
h.plugins.installCatalog("host", [surviving], { client: h.client, audio });
|
|
await expect.poll(() => [...h.open]).toEqual(["surviving"]);
|
|
expect(h.plugins.getSnapshot().map((plugin) => plugin.id)).toEqual(["surviving"]);
|
|
h.plugins.removeHost("host");
|
|
await expect.poll(() => h.open.size).toBe(0);
|
|
});
|
|
|
|
test("an invalid async client entry is disposed and its rejected continuation is observed", async () => {
|
|
const h = registry();
|
|
h.plugins.installCatalog(
|
|
"host",
|
|
[catalog("async-entry", 'return Promise.reject(new Error("asynchronous setup failed"));')],
|
|
{ client: h.client, audio },
|
|
);
|
|
await expect.poll(() => h.open.size).toBe(0);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(h.plugins.getSnapshot()).toEqual([]);
|
|
});
|
|
|
|
test("locale snapshots preserve installation state and observations, then release locale subscriptions", async () => {
|
|
let locale = "en";
|
|
const listeners = new Set<() => void>();
|
|
const h = registry({
|
|
getLocale: () => locale,
|
|
subscribe(listener) {
|
|
listeners.add(listener);
|
|
return () => {
|
|
listeners.delete(listener);
|
|
};
|
|
},
|
|
});
|
|
const translated = {
|
|
...catalog("notes"),
|
|
clientBundle: `(function(require) {
|
|
const {defineTranslations} = require("@getpaseo/plugin/client");
|
|
const messages = defineTranslations({resources:{en:{title:"Notes"},"zh-CN":{title:"笔记"}}});
|
|
return {default(plugin) {
|
|
plugin.paseo.observeEvents(["notes"]);
|
|
plugin.addScreen({id:"notes",title:messages.message("title"),Component:function Notes(){return null;}});
|
|
plugin.addFilePanel({id:"notes",title:messages.message("title"),icon:"FileText",files:{extensions:["notes"]},Component:function NotesFile(){return null;}});
|
|
return function(){};
|
|
}};
|
|
})`,
|
|
};
|
|
h.plugins.installCatalog("host", [translated], { client: h.client, audio });
|
|
expect(h.plugins.getEvaluationError("host", "notes")).toBeUndefined();
|
|
const [before] = h.plugins.getSnapshot();
|
|
function fileTitle(plugin: typeof before) {
|
|
const contribution = plugin.filePanels[0];
|
|
return filePanelTitle(i18n.t, {
|
|
id: "plugin:notes/notes",
|
|
builtin: false,
|
|
files: contribution.files,
|
|
plugin,
|
|
contribution,
|
|
});
|
|
}
|
|
expect(fileTitle(before)).toBe("Notes");
|
|
expect(before.locale).toBe("en");
|
|
expect(
|
|
before.i18n.resolve(
|
|
before.surfaces[0].title as import("@getpaseo/plugin/client").PluginText,
|
|
before.locale,
|
|
),
|
|
).toBe("Notes");
|
|
locale = "zh-CN";
|
|
for (const listener of listeners) listener();
|
|
const [after] = h.plugins.getSnapshot();
|
|
expect(after.locale).toBe("zh-CN");
|
|
expect(fileTitle(after)).toBe("笔记");
|
|
expect(fileTitle(before)).toBe("Notes");
|
|
expect(after.filePanels[0].Component).toBe(before.filePanels[0].Component);
|
|
expect(
|
|
after.i18n.resolve(
|
|
after.surfaces[0].title as import("@getpaseo/plugin/client").PluginText,
|
|
after.locale,
|
|
),
|
|
).toBe("笔记");
|
|
expect(after.lifetime).toBe(before.lifetime);
|
|
expect(after.queryClient).toBe(before.queryClient);
|
|
expect(after.paseo).toBe(before.paseo);
|
|
expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component);
|
|
expect(h.open).toEqual(new Set(["notes"]));
|
|
const translatedSnapshot = h.plugins.getSnapshot();
|
|
h.plugins.installCatalog("host", [translated], { client: h.client, audio });
|
|
expect(h.plugins.getSnapshot()).toBe(translatedSnapshot);
|
|
expect(h.plugins.getSnapshot()[0].locale).toBe("zh-CN");
|
|
expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n);
|
|
h.plugins.removeHost("host");
|
|
expect(listeners.size).toBe(0);
|
|
await expect.poll(() => h.open.size).toBe(0);
|
|
});
|