mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-21 19:04:05 +02:00
Run plugins in a QuickJS sandbox in the browser
Adds packages/plugin-sandbox: QuickJS-ng compiled to wasm, running in a dedicated worker, with a runtime shell inside it that loads a plugin bundle and answers the same InternalEventPayload events the Node runtime answers. Plugins are unmodified. Wires the browser host's template function, authentication, cURL import and template render commands to it, and relaxes TemplateCallback's Send bound on wasm32 so the engine's renderer can call back out to a plugin.
This commit is contained in:
@@ -17,15 +17,22 @@
|
||||
* up here as a type error rather than as a runtime surprise.
|
||||
*/
|
||||
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import type { JsonPrimitive } from "@yaakapp-internal/plugins";
|
||||
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
||||
import type { CapabilityName, RpcPayload } from "../types";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
import { unsupported } from "./errors";
|
||||
import type { WebPlugins } from "./plugins";
|
||||
import { sendHttpRequest } from "./send";
|
||||
|
||||
export type AppCmd = keyof RpcSchema;
|
||||
|
||||
type Handler = (payload: RpcPayload, db: WorkerConnection) => Promise<unknown>;
|
||||
type Handler = (
|
||||
payload: RpcPayload,
|
||||
db: WorkerConnection,
|
||||
plugins: WebPlugins,
|
||||
) => Promise<unknown>;
|
||||
|
||||
/** Placeholder shown wherever the desktop would show a real filesystem path. */
|
||||
const NO_PATH = "";
|
||||
@@ -41,6 +48,31 @@ function text(payload: RpcPayload, key: string): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/** Form values as the plugin protocol carries them. */
|
||||
function values(payload: RpcPayload, key = "values"): Record<string, JsonPrimitive> {
|
||||
const value = payload[key];
|
||||
return value != null && typeof value === "object"
|
||||
? (value as Record<string, JsonPrimitive>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The id a plugin keys its stored state on.
|
||||
*
|
||||
* The desktop hashes the id of whichever model the configuration was read from,
|
||||
* so two requests inheriting one folder's authentication share a token cache.
|
||||
* The preview paths here have no such model in hand and pass what they were
|
||||
* given, which is enough to be stable per form.
|
||||
*/
|
||||
function contextId(payload: RpcPayload): string {
|
||||
const model = payload.model;
|
||||
if (model != null && typeof model === "object" && "id" in model) {
|
||||
const id = (model as { id?: unknown }).id;
|
||||
return typeof id === "string" ? id : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands this host answers itself.
|
||||
*
|
||||
@@ -73,10 +105,16 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
|
||||
// The tab renders and stores; a stateless server puts the bytes on the wire.
|
||||
// See send.ts for the whole shape of it.
|
||||
cmd_send_http_request: (payload, db) => {
|
||||
cmd_send_http_request: (payload, db, plugins) => {
|
||||
const requestId = str(payload, "requestId");
|
||||
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
|
||||
return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
|
||||
return sendHttpRequest(
|
||||
db,
|
||||
plugins,
|
||||
requestId,
|
||||
str(payload, "environmentId"),
|
||||
str(payload, "cookieJarId"),
|
||||
);
|
||||
},
|
||||
|
||||
/* -------------------------------- app ---------------------------------- */
|
||||
@@ -146,22 +184,67 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
* Both of these are polled once a second until they answer with something, so
|
||||
* an empty list is not a quiet no — it is a poll that never stops.
|
||||
*
|
||||
* The auth list names what Yaak actually offers, so the picker tells the
|
||||
* truth about the product even though the form behind each entry stays empty
|
||||
* until plugins run here. Template functions get the opposite treatment: one
|
||||
* provider contributing no functions. That settles the poll while putting
|
||||
* nothing in the autocomplete, which is the honest answer — a function the
|
||||
* user could insert but nothing could evaluate would be worse than none.
|
||||
* Both now answer from the plugins actually loaded in the sandbox, which is
|
||||
* the only answer that stays true: an authentication method in the picker
|
||||
* that no loaded plugin can apply would be a promise this host cannot keep,
|
||||
* and a template function offered in the autocomplete that nothing can
|
||||
* evaluate would be worse than none.
|
||||
*/
|
||||
async cmd_get_http_authentication_summaries() {
|
||||
return HTTP_AUTHENTICATION_SUMMARIES;
|
||||
async cmd_get_http_authentication_summaries(_payload, _db, plugins) {
|
||||
return plugins.httpAuthenticationSummaries();
|
||||
},
|
||||
async cmd_template_function_summaries() {
|
||||
return [{ pluginRefId: "web", functions: [] }];
|
||||
async cmd_template_function_summaries(_payload, _db, plugins) {
|
||||
return plugins.templateFunctionSummaries();
|
||||
},
|
||||
|
||||
async cmd_get_http_authentication_config() {
|
||||
return { args: [], pluginRefId: "web" };
|
||||
async cmd_get_http_authentication_config(payload, _db, plugins) {
|
||||
const authName = str(payload, "authName");
|
||||
const config =
|
||||
authName == null
|
||||
? null
|
||||
: await plugins.httpAuthenticationConfig(authName, values(payload), contextId(payload));
|
||||
return config ?? { args: [], actions: [], pluginRefId: "web" };
|
||||
},
|
||||
|
||||
async cmd_template_function_config(payload, _db, plugins) {
|
||||
const name = str(payload, "functionName") ?? str(payload, "name");
|
||||
if (name == null) return null;
|
||||
return plugins.templateFunctionConfig(name, values(payload), contextId(payload));
|
||||
},
|
||||
|
||||
async cmd_call_http_authentication_action(payload, _db, plugins) {
|
||||
const authName = str(payload, "authName");
|
||||
if (authName == null) return null;
|
||||
const index = payload.actionIndex;
|
||||
await plugins.callHttpAuthenticationAction(
|
||||
authName,
|
||||
typeof index === "number" ? index : 0,
|
||||
values(payload),
|
||||
contextId(payload),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Turn a pasted cURL command into a request.
|
||||
*
|
||||
* Routed through the same importer the desktop uses, in the sandbox, which
|
||||
* is why this is a handler and no longer a refusal. The reshaping afterwards
|
||||
* matches `cmd_curl_to_request` in crates/yaak-commands: the importer names a
|
||||
* workspace of its own invention and mints an id, and both belong to the
|
||||
* caller instead.
|
||||
*/
|
||||
async cmd_curl_to_request(payload, _db, plugins) {
|
||||
const resources = await plugins.import(text(payload, "command"));
|
||||
const imported = resources?.httpRequests?.[0];
|
||||
if (imported == null) {
|
||||
throw new Error("Failed to import cURL command");
|
||||
}
|
||||
return {
|
||||
...imported,
|
||||
id: "",
|
||||
workspaceId: str(payload, "workspaceId") ?? imported.workspaceId,
|
||||
} as HttpRequest;
|
||||
},
|
||||
|
||||
async cmd_format_json(payload) {
|
||||
@@ -176,13 +259,19 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Rendering resolves variables and calls template functions, and the
|
||||
* functions live in plugins. Handing the template back unrendered is what the
|
||||
* preview then shows — the raw `${[...]}`, which is at least the thing the
|
||||
* user typed rather than a wrong value.
|
||||
* Resolve variables and call template functions, in the engine, exactly as
|
||||
* `cmd_render_template` does on the desktop. The functions come back out to
|
||||
* the sandbox as the render reaches them — see `templateBridge` in worker.ts.
|
||||
*/
|
||||
async cmd_render_template(payload) {
|
||||
return text(payload, "template");
|
||||
async cmd_render_template(payload, db) {
|
||||
const workspaceId = str(payload, "workspaceId");
|
||||
if (workspaceId == null) return text(payload, "template");
|
||||
return db.renderTemplate({
|
||||
template: text(payload, "template"),
|
||||
workspaceId,
|
||||
environmentId: str(payload, "environmentId"),
|
||||
ignoreError: payload.ignoreError === true,
|
||||
});
|
||||
},
|
||||
|
||||
/* ------------------------------- bodies -------------------------------- */
|
||||
@@ -224,21 +313,6 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The auth methods Yaak ships as plugins today (plugins/auth-*). Listed so the
|
||||
* picker is truthful about the product; choosing one currently yields an empty
|
||||
* config form, because the plugin that defines the form isn't running.
|
||||
*/
|
||||
const HTTP_AUTHENTICATION_SUMMARIES = [
|
||||
{ name: "apikey", label: "API Key", shortLabel: "API Key" },
|
||||
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
|
||||
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
|
||||
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
|
||||
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
|
||||
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
|
||||
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
|
||||
{ name: "oauth2", label: "OAuth 2.0", shortLabel: "OAuth 2" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Commands this host declines, each with the reason a user would need.
|
||||
@@ -253,7 +327,6 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
// ones nothing stores, used for GraphQL introspection — take the same road but
|
||||
// return the body inline; not wired yet.
|
||||
cmd_send_ephemeral_request: ["Sending unsaved requests isn't available in the browser yet", null],
|
||||
cmd_curl_to_request: ["Importing from cURL needs a plugin, which this host doesn't run", null],
|
||||
|
||||
// Protocols that need a real socket.
|
||||
cmd_grpc_reflect: ["gRPC isn't available in the browser", "grpc"],
|
||||
@@ -295,14 +368,12 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_workspace_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_folder_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_http_authentication_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
|
||||
cmd_send_feedback: ["Feedback goes through the desktop app for now", null],
|
||||
};
|
||||
@@ -329,9 +400,10 @@ export async function runCommand(
|
||||
cmd: string,
|
||||
payload: RpcPayload,
|
||||
db: WorkerConnection,
|
||||
plugins: WebPlugins,
|
||||
): Promise<unknown> {
|
||||
const handler = HANDLERS[cmd as AppCmd];
|
||||
if (handler != null) return handler(payload, db);
|
||||
if (handler != null) return handler(payload, db, plugins);
|
||||
|
||||
const declined = DECLINED[cmd as AppCmd];
|
||||
if (declined != null) throw unsupported(cmd, declined[0], declined[1]);
|
||||
|
||||
@@ -50,6 +50,16 @@ export class WorkerConnection {
|
||||
/** True once the worker has said anything at all. */
|
||||
private heard = false;
|
||||
|
||||
/**
|
||||
* Who answers a template function, once something can.
|
||||
*
|
||||
* The engine renders in the worker but the functions come from plugins in
|
||||
* this tab's sandbox, so the worker asks back through this. Unset until the
|
||||
* sandbox is up, and a render that arrives before then gets the same refusal
|
||||
* a host with no plugins gives — which is the truth at that moment.
|
||||
*/
|
||||
private templateFunctions: ((name: string, args: string) => Promise<string>) | null = null;
|
||||
|
||||
constructor() {
|
||||
// Both are required and neither is faked. Without a shared worker every
|
||||
// tab would need its own SQLite over the same pages; without Web Locks
|
||||
@@ -147,6 +157,9 @@ export class WorkerConnection {
|
||||
case "event":
|
||||
this.deliver(message.event, message.payload);
|
||||
return;
|
||||
case "template_function":
|
||||
void this.runTemplateFunction(message.id, message.name, message.args);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +172,35 @@ export class WorkerConnection {
|
||||
});
|
||||
}
|
||||
|
||||
/** Hand the worker somewhere to send template functions. */
|
||||
setTemplateFunctionHandler(handler: (name: string, args: string) => Promise<string>): void {
|
||||
this.templateFunctions = handler;
|
||||
}
|
||||
|
||||
private async runTemplateFunction(id: number, name: string, args: string): Promise<void> {
|
||||
if (this.templateFunctions == null) {
|
||||
this.post({
|
||||
type: "template_function_result",
|
||||
id,
|
||||
error: `The template function \`${name}\` needs a plugin, and none are loaded yet`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.post({
|
||||
type: "template_function_result",
|
||||
id,
|
||||
value: await this.templateFunctions(name, args),
|
||||
});
|
||||
} catch (err) {
|
||||
this.post({
|
||||
type: "template_function_result",
|
||||
id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
rpc<T>(cmd: string, payload: unknown): Promise<T> {
|
||||
return this.request<T>((id) => ({ type: "rpc", id, cmd, payload, label: this.label }));
|
||||
}
|
||||
@@ -168,6 +210,11 @@ export class WorkerConnection {
|
||||
return this.request<T>((id) => ({ type: "prepare_http_send", id, payload }));
|
||||
}
|
||||
|
||||
/** See `render_template` in crates/yaak-web. */
|
||||
renderTemplate(payload: unknown): Promise<string> {
|
||||
return this.request<string>((id) => ({ type: "render_template", id, payload }));
|
||||
}
|
||||
|
||||
async blobGet(blobId: string): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
const buf = await this.request<ArrayBuffer | null>((id) => ({ type: "blob_get", id, blobId }));
|
||||
return buf == null ? null : new Uint8Array(buf);
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
import { commandSupport, runCommand } from "./commands";
|
||||
import { WorkerConnection } from "./connection";
|
||||
import { unsupported } from "./errors";
|
||||
import { WebPlugins } from "./plugins";
|
||||
import { requestPersistence } from "./storage";
|
||||
|
||||
/** What this host can do, reported honestly. */
|
||||
@@ -60,7 +61,9 @@ function capabilitiesFor(): PlatformCapabilities {
|
||||
// The browser already zooms the page, on the same keys, and remembers it
|
||||
// per site. The app stays out of the way.
|
||||
interfaceZoom: false,
|
||||
plugins: false,
|
||||
// Plugins run in a QuickJS sandbox, but only the bundled set: there is no
|
||||
// installing them, so the plugin manager stays unavailable and says so.
|
||||
plugins: true,
|
||||
encryption: false,
|
||||
updater: false,
|
||||
// Reading needs a permission prompt at first paint, which is a bad ask for
|
||||
@@ -160,8 +163,14 @@ function createWindow(db: WorkerConnection): PlatformWindow {
|
||||
|
||||
export function createWebPlatform(): Platform {
|
||||
const db = new WorkerConnection();
|
||||
const plugins = new WebPlugins(db);
|
||||
const capabilities = capabilitiesFor();
|
||||
|
||||
// Rendering happens in the worker and template functions live in the sandbox,
|
||||
// so the worker needs a way back here to call one. Registered before anything
|
||||
// can render, which is why it is here rather than inside the first send.
|
||||
db.setTemplateFunctionHandler((name, args) => plugins.callTemplateFunction(name, args));
|
||||
|
||||
// Without this, IndexedDB is best-effort storage and a browser reclaiming
|
||||
// space may drop someone's workspaces. Asking is all we can do, and there is
|
||||
// nothing useful to do about a refusal.
|
||||
@@ -231,7 +240,7 @@ export function createWebPlatform(): Platform {
|
||||
// `plugin:` commands are Tauri host plugins, not engine commands, and
|
||||
// never reached the router even on the desktop.
|
||||
if (cmd.startsWith("plugin:")) return hostPluginCommand<T>(cmd, payload);
|
||||
return runCommand(cmd, payload ?? {}, db) as Promise<T>;
|
||||
return runCommand(cmd, payload ?? {}, db, plugins) as Promise<T>;
|
||||
},
|
||||
|
||||
async rpcStream<T, M>(
|
||||
@@ -244,7 +253,7 @@ export function createWebPlatform(): Platform {
|
||||
const streamId = crypto.randomUUID();
|
||||
const unlisten = db.listen(`stream_${streamId}`, (p) => onMessage(p as M));
|
||||
try {
|
||||
const result = (await runCommand(cmd, { ...payload, streamId }, db)) as T;
|
||||
const result = (await runCommand(cmd, { ...payload, streamId }, db, plugins)) as T;
|
||||
return { result, unlisten };
|
||||
} catch (err) {
|
||||
unlisten();
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* The plugins this host runs, and everything they are allowed to reach.
|
||||
*
|
||||
* Two jobs. Outward: keep a sandbox, load the bundled plugins into it, and know
|
||||
* which of them answers what — the app asks for "the bearer auth config" and
|
||||
* this decides that means `auth-bearer`. Inward: answer the `ctx` calls those
|
||||
* plugins make, which is where the sandbox stops being a sealed box and starts
|
||||
* being a host. Everything a plugin can do to the world is in `hostRequest`
|
||||
* below, by name, with a refusal for anything not listed.
|
||||
*
|
||||
* The plugins are bundled into the app rather than installed, for now — see
|
||||
* `scripts/bundle-sandbox-plugins.mjs`. Which three, and why only three, is a
|
||||
* decision that belongs to this slice and not to the sandbox: the runtime does
|
||||
* not know how many plugins exist.
|
||||
*/
|
||||
|
||||
import { PluginSandbox, type PluginSummary } from "@yaakapp-internal/plugin-sandbox";
|
||||
import type {
|
||||
GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse,
|
||||
GetTemplateFunctionConfigResponse,
|
||||
GetTemplateFunctionSummaryResponse,
|
||||
ImportResources,
|
||||
InternalEventPayload,
|
||||
JsonPrimitive,
|
||||
PluginContext,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
import { SANDBOX_PLUGINS } from "./sandboxPlugins.generated";
|
||||
|
||||
/** What a plugin's own storage is keyed under, matching the desktop's namespacing. */
|
||||
type KeyValueRequest = { key: string };
|
||||
|
||||
export interface AppliedAuthentication {
|
||||
setHeaders?: { name: string; value: string }[] | null;
|
||||
setQueryParameters?: { name: string; value: string }[] | null;
|
||||
}
|
||||
|
||||
export class WebPlugins {
|
||||
private readonly db: WorkerConnection;
|
||||
private sandbox: PluginSandbox | null = null;
|
||||
private loading: Promise<void> | null = null;
|
||||
|
||||
/** Loaded plugin ids, by what they contribute. */
|
||||
private readonly byTemplateFunction = new Map<string, string>();
|
||||
private readonly byAuthName = new Map<string, string>();
|
||||
private readonly importers: string[] = [];
|
||||
private readonly summaries = new Map<string, PluginSummary>();
|
||||
|
||||
constructor(db: WorkerConnection) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the sandbox up and load every bundled plugin, once.
|
||||
*
|
||||
* Called from every entry point rather than at construction, so a session
|
||||
* that never touches a plugin never pays for QuickJS — and so a tab that
|
||||
* cannot start the worker still boots the app, with plugin-shaped features
|
||||
* failing individually instead of the page failing entirely.
|
||||
*/
|
||||
ready(): Promise<void> {
|
||||
this.loading ??= this.start();
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
const sandbox = new PluginSandbox({
|
||||
onHostRequest: (envelope) => this.hostRequest(envelope),
|
||||
onLog: ({ pluginRefId, level, message }) => {
|
||||
// Prefixed, because otherwise a plugin's console output is
|
||||
// indistinguishable from the app's own and blames the wrong code.
|
||||
const write = level === "error" ? console.error : console.log;
|
||||
write(`[plugin ${pluginRefId}] ${message}`);
|
||||
},
|
||||
});
|
||||
this.sandbox = sandbox;
|
||||
|
||||
// In parallel: each is an independent QuickJS context and none of them
|
||||
// observes the others.
|
||||
await Promise.all(
|
||||
SANDBOX_PLUGINS.map(async ({ name, source }) => {
|
||||
try {
|
||||
const summary = await sandbox.load(name, source);
|
||||
this.summaries.set(name, summary);
|
||||
for (const fn of summary.templateFunctions) this.byTemplateFunction.set(fn, name);
|
||||
if (summary.authentication != null) this.byAuthName.set(summary.authentication, name);
|
||||
if (summary.importer) this.importers.push(name);
|
||||
} catch (err) {
|
||||
// One bad bundle should cost its own features and nothing else.
|
||||
console.error(`Failed to load plugin \`${name}\``, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------ what exists ------------------------------ */
|
||||
|
||||
async templateFunctionSummaries(): Promise<GetTemplateFunctionSummaryResponse[]> {
|
||||
await this.ready();
|
||||
return this.gather("get_template_function_summary_request", this.summaries.keys());
|
||||
}
|
||||
|
||||
async httpAuthenticationSummaries(): Promise<GetHttpAuthenticationSummaryResponse[]> {
|
||||
await this.ready();
|
||||
return this.gather("get_http_authentication_summary_request", this.byAuthName.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask several plugins the same question and keep the answers that came back.
|
||||
*
|
||||
* A plugin that has nothing to say answers `empty_response`, which is not an
|
||||
* answer and is dropped; one that throws is logged and dropped too, so a
|
||||
* single broken plugin cannot empty the picker for all the others.
|
||||
*/
|
||||
private async gather<T>(type: string, ids: Iterable<string>): Promise<T[]> {
|
||||
const replies = await Promise.all(
|
||||
Array.from(ids).map(async (id): Promise<{ type: string } | null> => {
|
||||
try {
|
||||
return await this.dispatch(id, { type } as InternalEventPayload);
|
||||
} catch (err) {
|
||||
console.error(`Plugin \`${id}\` failed to answer \`${type}\``, err);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return replies.filter((r) => r != null && r.type !== "empty_response") as T[];
|
||||
}
|
||||
|
||||
/* -------------------------------- calling -------------------------------- */
|
||||
|
||||
async templateFunctionConfig(
|
||||
name: string,
|
||||
values: Record<string, JsonPrimitive>,
|
||||
contextId: string,
|
||||
): Promise<GetTemplateFunctionConfigResponse | null> {
|
||||
await this.ready();
|
||||
const id = this.byTemplateFunction.get(name);
|
||||
if (id == null) return null;
|
||||
return this.dispatch(id, {
|
||||
type: "get_template_function_config_request",
|
||||
contextId,
|
||||
name,
|
||||
values,
|
||||
} as InternalEventPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one template function.
|
||||
*
|
||||
* This is what the engine's render calls back into, so its contract is the
|
||||
* engine's: a string, or a throw whose message says what went wrong. A
|
||||
* function nothing provides is a throw naming it rather than an empty
|
||||
* string, because a request sent with a silently blank token is worse than
|
||||
* one that refuses to be sent.
|
||||
*/
|
||||
async callTemplateFunction(name: string, argsJson: string): Promise<string> {
|
||||
await this.ready();
|
||||
const id = this.byTemplateFunction.get(name);
|
||||
if (id == null) {
|
||||
throw new Error(`No plugin provides the template function \`${name}\``);
|
||||
}
|
||||
|
||||
const values = JSON.parse(argsJson) as Record<string, JsonPrimitive>;
|
||||
const reply = await this.dispatch<{ value: string | null; error?: string | null }>(id, {
|
||||
type: "call_template_function_request",
|
||||
name,
|
||||
args: { purpose: "send", values },
|
||||
} as InternalEventPayload);
|
||||
|
||||
if (reply.error) throw new Error(reply.error);
|
||||
return reply.value ?? "";
|
||||
}
|
||||
|
||||
async httpAuthenticationConfig(
|
||||
authName: string,
|
||||
values: Record<string, JsonPrimitive>,
|
||||
contextId: string,
|
||||
): Promise<GetHttpAuthenticationConfigResponse | null> {
|
||||
await this.ready();
|
||||
const id = this.byAuthName.get(authName);
|
||||
if (id == null) return null;
|
||||
return this.dispatch(id, {
|
||||
type: "get_http_authentication_config_request",
|
||||
contextId,
|
||||
values,
|
||||
} as InternalEventPayload);
|
||||
}
|
||||
|
||||
async callHttpAuthenticationAction(
|
||||
authName: string,
|
||||
index: number,
|
||||
values: Record<string, JsonPrimitive>,
|
||||
contextId: string,
|
||||
): Promise<void> {
|
||||
await this.ready();
|
||||
const id = this.byAuthName.get(authName);
|
||||
if (id == null) throw new Error(`No plugin provides \`${authName}\` authentication`);
|
||||
await this.dispatch(id, {
|
||||
type: "call_http_authentication_action_request",
|
||||
index,
|
||||
pluginRefId: id,
|
||||
args: { contextId, values },
|
||||
} as InternalEventPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an authentication method to a request that is about to be sent.
|
||||
*
|
||||
* The plugin is shown the request as it stands and hands back headers and
|
||||
* query parameters to add — the same exchange the desktop has, at the same
|
||||
* point in the send.
|
||||
*/
|
||||
async applyHttpAuthentication(
|
||||
authName: string,
|
||||
request: {
|
||||
contextId: string;
|
||||
values: Record<string, JsonPrimitive>;
|
||||
method: string;
|
||||
url: string;
|
||||
headers: { name: string; value: string }[];
|
||||
body: string | null;
|
||||
},
|
||||
): Promise<AppliedAuthentication> {
|
||||
await this.ready();
|
||||
const id = this.byAuthName.get(authName);
|
||||
if (id == null) {
|
||||
throw new Error(
|
||||
`This request uses ${authName} authentication, which no plugin in the browser provides`,
|
||||
);
|
||||
}
|
||||
return this.dispatch<AppliedAuthentication>(id, {
|
||||
type: "call_http_authentication_request",
|
||||
...request,
|
||||
} as InternalEventPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import whatever this text turns out to be.
|
||||
*
|
||||
* Every importer is asked and the first one that recognizes it wins, which
|
||||
* is how the desktop's `import_data` decides too — an importer that does not
|
||||
* recognize its input returns nothing rather than guessing.
|
||||
*/
|
||||
async import(content: string): Promise<ImportResources | null> {
|
||||
await this.ready();
|
||||
for (const id of this.importers) {
|
||||
try {
|
||||
const reply = await this.dispatch<{ resources?: ImportResources }>(id, {
|
||||
type: "import_request",
|
||||
content,
|
||||
} as InternalEventPayload);
|
||||
if (reply.type === "import_response" && reply.resources != null) return reply.resources;
|
||||
} catch (err) {
|
||||
console.error(`Importer \`${id}\` failed`, err);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------- internals ------------------------------- */
|
||||
|
||||
private async dispatch<T>(
|
||||
pluginRefId: string,
|
||||
payload: InternalEventPayload,
|
||||
): Promise<T & { type: string }> {
|
||||
if (this.sandbox == null) throw new Error("The plugin sandbox is not running");
|
||||
return this.sandbox.dispatch<T>(pluginRefId, this.context(), payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* The context a plugin sees.
|
||||
*
|
||||
* `label` is null and stays null: it names a desktop window, and the calls
|
||||
* that need one — `ctx.window.requestId()` and its neighbours — are refused
|
||||
* rather than answered with a guess about which request the user is looking
|
||||
* at. `workspaceId` is genuinely unknown here for the same reason; the
|
||||
* commands that know it pass it themselves.
|
||||
*/
|
||||
private context(): PluginContext {
|
||||
return { id: "web", label: null, workspaceId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer one `ctx` call.
|
||||
*
|
||||
* The list is short on purpose. What is here is what a plugin can do in a
|
||||
* browser tab today; what is missing refuses by name, so a plugin that needs
|
||||
* it fails with a sentence someone can act on rather than a hang or an
|
||||
* undefined. Every addition to this list is a capability decision, which is
|
||||
* why they are written out one at a time instead of forwarded wholesale.
|
||||
*/
|
||||
private async hostRequest(envelope: string): Promise<string> {
|
||||
const { pluginRefId, payload } = JSON.parse(envelope) as {
|
||||
pluginRefId: string;
|
||||
context: PluginContext;
|
||||
payload: InternalEventPayload;
|
||||
};
|
||||
|
||||
const reply = async (): Promise<InternalEventPayload> => {
|
||||
switch (payload.type) {
|
||||
/* A plugin's own storage, namespaced by plugin in the database. */
|
||||
case "get_key_value_request": {
|
||||
const value = await this.db.rpc<string | null>("web_plugin_kv_get", {
|
||||
pluginName: pluginRefId,
|
||||
key: (payload as unknown as KeyValueRequest).key,
|
||||
});
|
||||
return { type: "get_key_value_response", value } as InternalEventPayload;
|
||||
}
|
||||
case "set_key_value_request": {
|
||||
const { key, value } = payload as unknown as { key: string; value: string };
|
||||
await this.db.rpc("web_plugin_kv_set", {
|
||||
pluginName: pluginRefId,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
return { type: "set_key_value_response" } as InternalEventPayload;
|
||||
}
|
||||
case "delete_key_value_request": {
|
||||
const deleted = await this.db.rpc<boolean>("web_plugin_kv_delete", {
|
||||
pluginName: pluginRefId,
|
||||
key: (payload as unknown as KeyValueRequest).key,
|
||||
});
|
||||
return { type: "delete_key_value_response", deleted } as InternalEventPayload;
|
||||
}
|
||||
|
||||
/* A message for the user, delivered where every other one is. */
|
||||
case "show_toast_request": {
|
||||
const { type: _type, ...toast } = payload;
|
||||
this.db.deliver("show_toast", toast);
|
||||
return { type: "empty_response" };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`\`${payload.type}\` isn't something a plugin can do when Yaak runs in a browser yet`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
return JSON.stringify(await reply());
|
||||
} catch (err) {
|
||||
return JSON.stringify({
|
||||
type: "error_response",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,6 +16,18 @@ export type ToWorker =
|
||||
* async in the engine (rendering is), where every `rpc` command is not.
|
||||
*/
|
||||
| { type: "prepare_http_send"; id: number; payload: unknown }
|
||||
/**
|
||||
* Render one template string. Async for the same reason `prepare_http_send`
|
||||
* is: a template function is a call out to a plugin, and plugins are not here.
|
||||
*/
|
||||
| { type: "render_template"; id: number; payload: unknown }
|
||||
/** The tab's answer to a `template_function` call. */
|
||||
| {
|
||||
type: "template_function_result";
|
||||
id: number;
|
||||
value?: string;
|
||||
error?: string;
|
||||
}
|
||||
| { type: "blob_get"; id: number; blobId: string }
|
||||
| { type: "blob_put"; id: number; blobId: string; bytes: ArrayBuffer }
|
||||
| { type: "blob_delete"; id: number; blobId: string }
|
||||
@@ -38,7 +50,16 @@ export type FromWorker =
|
||||
| { type: "result"; id: number; result: unknown }
|
||||
| { type: "error"; id: number; message: string }
|
||||
/** A backend event for the app — today only `model_writes`. Sent to every port. */
|
||||
| { type: "event"; event: string; payload: unknown };
|
||||
| { type: "event"; event: string; payload: unknown }
|
||||
/**
|
||||
* Render a template function, please.
|
||||
*
|
||||
* The one message that runs the other way. Rendering happens in the engine,
|
||||
* here, but the functions it calls live in a plugin sandbox the tab owns —
|
||||
* so the engine asks, and it asks the port that started the render rather
|
||||
* than broadcasting, because only that tab is waiting.
|
||||
*/
|
||||
| { type: "template_function"; id: number; name: string; args: string };
|
||||
|
||||
/** What the worker registers itself under. Tabs on one origin share it. */
|
||||
export const WORKER_NAME = "yaak-db";
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -33,7 +33,8 @@ import type {
|
||||
} from "@yaakapp-internal/models";
|
||||
import type { Frame, SendRequest } from "@yaakapp-internal/web";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
import { serverIdentity, serverSendUrl, readFrames } from "./server";
|
||||
import type { WebPlugins } from "./plugins";
|
||||
import { readFrames, serverIdentity, serverSendUrl } from "./server";
|
||||
|
||||
/* -------------------------------- shapes --------------------------------- */
|
||||
|
||||
@@ -51,11 +52,69 @@ type ResponsePatch = Partial<HttpResponse>;
|
||||
/** What `prepare_http_send` (crates/yaak-wasm) hands back. */
|
||||
interface PreparedHttpSend {
|
||||
request: HttpRequest;
|
||||
/** Hashed id of the model the authentication came from; a plugin keys its state on it. */
|
||||
authContextId: string;
|
||||
settings: HttpSendSettings;
|
||||
settingEvents: HttpResponseEventData[];
|
||||
cookieJar: CookieJar | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the request's authentication method, if it has one.
|
||||
*
|
||||
* The desktop does this to the request it is about to put on the wire, after
|
||||
* rendering and after building the sendable form of it. Here the sendable form
|
||||
* is built by the proxy, so the plugin's answer is applied to the model instead
|
||||
* — headers onto `headers`, query parameters onto `urlParameters` — and the
|
||||
* proxy folds both in exactly as it would any others. The result on the wire is
|
||||
* the same for a method that sets a header, which is every method whose plugin
|
||||
* runs in the browser today.
|
||||
*
|
||||
* It is not the same for a method that *signs* the request, because the plugin
|
||||
* is shown the request before the proxy assembles it: AWS SigV4 and OAuth 1.0
|
||||
* would sign a URL and a header set slightly different from the ones sent. Both
|
||||
* are refused rather than silently mis-signed — see the sandbox README.
|
||||
*/
|
||||
async function applyAuthentication(
|
||||
plugins: WebPlugins,
|
||||
prepared: PreparedHttpSend,
|
||||
): Promise<HttpRequest> {
|
||||
const { request } = prepared;
|
||||
const authType = request.authenticationType;
|
||||
const disabled = request.authentication?.disabled === true;
|
||||
if (authType == null || authType === "none" || disabled) return request;
|
||||
|
||||
const applied = await plugins.applyHttpAuthentication(authType, {
|
||||
contextId: prepared.authContextId,
|
||||
values: request.authentication as Record<string, never>,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers.filter((h) => h.enabled !== false),
|
||||
// The desktop passes the body so signing schemes can hash it. This host
|
||||
// does not have it in bytes at this point, and the schemes that would use
|
||||
// it are the ones already refused.
|
||||
body: null,
|
||||
});
|
||||
|
||||
const headers = [...request.headers];
|
||||
for (const header of applied.setHeaders ?? []) {
|
||||
// Replace-or-append, case-insensitively, matching `insert_header` in
|
||||
// crates/yaak-http: a plugin setting Authorization must not end up with the
|
||||
// request's own Authorization also on the wire.
|
||||
const at = headers.findIndex((h) => h.name.toLowerCase() === header.name.toLowerCase());
|
||||
const entry = { name: header.name, value: header.value, enabled: true };
|
||||
if (at >= 0) headers[at] = { ...headers[at], ...entry };
|
||||
else headers.push(entry);
|
||||
}
|
||||
|
||||
const urlParameters = [...request.urlParameters];
|
||||
for (const param of applied.setQueryParameters ?? []) {
|
||||
urlParameters.push({ name: param.name, value: param.value, enabled: true });
|
||||
}
|
||||
|
||||
return { ...request, headers, urlParameters };
|
||||
}
|
||||
|
||||
/** The desktop writes progress at most this often while a body streams in. */
|
||||
const PROGRESS_INTERVAL_MS = 100;
|
||||
|
||||
@@ -63,6 +122,7 @@ const PROGRESS_INTERVAL_MS = 100;
|
||||
|
||||
export async function sendHttpRequest(
|
||||
db: WorkerConnection,
|
||||
plugins: WebPlugins,
|
||||
requestId: string,
|
||||
environmentId: string | null,
|
||||
cookieJarId: string | null,
|
||||
@@ -78,7 +138,7 @@ export async function sendHttpRequest(
|
||||
const unlistenCancel = db.listen(`cancel_http_response_${response.id}`, () => cancel.abort());
|
||||
|
||||
try {
|
||||
await runSend(db, response, requestId, environmentId, cookieJarId, cancel.signal);
|
||||
await runSend(db, plugins, response, requestId, environmentId, cookieJarId, cancel.signal);
|
||||
} catch (err) {
|
||||
const message = cancel.signal.aborted ? "Request canceled" : errorMessage(err);
|
||||
await response.finish({ error: message });
|
||||
@@ -90,6 +150,7 @@ export async function sendHttpRequest(
|
||||
|
||||
async function runSend(
|
||||
db: WorkerConnection,
|
||||
plugins: WebPlugins,
|
||||
response: ResponseWriter,
|
||||
requestId: string,
|
||||
environmentId: string | null,
|
||||
@@ -101,7 +162,8 @@ async function runSend(
|
||||
environmentId,
|
||||
cookieJarId,
|
||||
});
|
||||
await response.patch({ url: prepared.request.url });
|
||||
const request = await applyAuthentication(plugins, prepared);
|
||||
await response.patch({ url: request.url });
|
||||
|
||||
// The first line of the timeline says what did the sending and where. A
|
||||
// request through a proxy shows a different origin to the server than the
|
||||
@@ -111,7 +173,7 @@ async function runSend(
|
||||
timeline.push(prepared.settingEvents);
|
||||
|
||||
const body: SendRequest = {
|
||||
request: prepared.request,
|
||||
request,
|
||||
settings: prepared.settings,
|
||||
cookies: prepared.cookieJar?.cookies ?? null,
|
||||
};
|
||||
|
||||
@@ -108,12 +108,44 @@ function bootOnce(): Promise<void> {
|
||||
return booted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Template functions, which live somewhere this worker cannot reach.
|
||||
*
|
||||
* Rendering is the engine's, and the engine is here. The functions it calls
|
||||
* come from plugins, which run in a sandbox the tab owns — so the engine is
|
||||
* handed a function that asks the tab. It asks the port that started the
|
||||
* render, not every port, because only that tab is waiting on the answer and
|
||||
* only its sandbox has the plugins the render was started against.
|
||||
*
|
||||
* A failure comes back as a rejection, which the engine turns into a render
|
||||
* error naming the function. That matters: rendering `${[ uuid.v4() ]}` to an
|
||||
* empty string and sending it would be worse than not sending at all.
|
||||
*/
|
||||
const pendingTemplateFunctions = new Map<number, (result: string | Error) => void>();
|
||||
let nextTemplateFunctionId = 1;
|
||||
|
||||
function templateBridge(port: MessagePort): (name: string, args: string) => Promise<string> {
|
||||
return (name, args) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const id = nextTemplateFunctionId++;
|
||||
pendingTemplateFunctions.set(id, (r) => (r instanceof Error ? reject(r) : resolve(r)));
|
||||
send(port, { type: "template_function", id, name, args });
|
||||
});
|
||||
}
|
||||
|
||||
async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
||||
if (message.type === "goodbye") {
|
||||
ports.delete(port);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "template_function_result") {
|
||||
const settle = pendingTemplateFunctions.get(message.id);
|
||||
pendingTemplateFunctions.delete(message.id);
|
||||
settle?.(message.error != null ? new Error(message.error) : (message.value ?? ""));
|
||||
return;
|
||||
}
|
||||
|
||||
// Every command waits for boot rather than the tab having to. Tabs post
|
||||
// the moment they load; the port queues; this drains once the DB is open.
|
||||
try {
|
||||
@@ -122,7 +154,8 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
||||
send(port, { type: "error", id: message.id, message: bootError ?? "Database failed to open" });
|
||||
return;
|
||||
}
|
||||
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send } = engine!;
|
||||
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send, render_template } =
|
||||
engine!;
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
@@ -143,10 +176,15 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
||||
return;
|
||||
}
|
||||
case "prepare_http_send": {
|
||||
const prepared = await prepare_http_send(message.payload);
|
||||
const prepared = await prepare_http_send(message.payload, templateBridge(port));
|
||||
send(port, { type: "result", id: message.id, result: prepared });
|
||||
return;
|
||||
}
|
||||
case "render_template": {
|
||||
const rendered = await render_template(message.payload, templateBridge(port));
|
||||
send(port, { type: "result", id: message.id, result: rendered });
|
||||
return;
|
||||
}
|
||||
case "blob_get": {
|
||||
const bytes = blob_get(message.blobId);
|
||||
if (bytes == null) {
|
||||
|
||||
Reference in New Issue
Block a user