mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-25 21:04:04 +02:00
Build ctx once and share it between both plugin runtimes
The sandbox's context builder was a near-copy of the Node runtime's. Both now come from createPluginContext in @yaakapp-internal/lib, with each runtime supplying only a transport. The two places hosts genuinely differ are optional transport methods: `stream` (a window reporting navigation until it closes) and `form` (a prompt that re-renders as values change). The sandbox has neither, so openUrl refuses and a form is drawn once from its defaults.
This commit is contained in:
+112
-50
@@ -1,12 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* `ctx`, as a plugin sees it, built entirely out of one call to the host.
|
* `ctx`, as a plugin sees it, built once for every runtime that has one.
|
||||||
*
|
*
|
||||||
* Every method here serializes a request payload, hands it out of the sandbox,
|
* Two runtimes host plugins today — the Node sidecar over a WebSocket, and the
|
||||||
* and awaits a reply payload. That is the whole capability surface: the sandbox
|
* QuickJS sandbox over a message port — and a third will when the sandbox is
|
||||||
* has no socket, no clock it owns, no storage and no DOM, so anything a plugin
|
* embedded in Rust. What `ctx.httpRequest.send(...)` *means* is the same in all
|
||||||
* does to the world is a message the host chose to answer. The payload shapes
|
* of them, so it is built here, and the only thing a runtime supplies is how a
|
||||||
* are the ones in `crates/yaak-plugins/src/events.rs`, unchanged, so a plugin
|
* payload gets to its host and back.
|
||||||
* written for the Node runtime runs here without knowing which host it has.
|
*
|
||||||
|
* `stream` and `form` are optional because they are the two places where a host
|
||||||
|
* genuinely differs: both need a conversation rather than one reply, and a
|
||||||
|
* runtime that cannot hold one degrades honestly instead of pretending.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -14,12 +17,6 @@ import type {
|
|||||||
Context,
|
Context,
|
||||||
DynamicPromptFormArg,
|
DynamicPromptFormArg,
|
||||||
} from "@yaakapp/api";
|
} from "@yaakapp/api";
|
||||||
import {
|
|
||||||
applyDynamicFormInput,
|
|
||||||
stripDynamicCallbacks,
|
|
||||||
} from "@yaakapp-internal/lib/pluginForms";
|
|
||||||
import { createResponseBody, decodeBase64Chunk } from "@yaakapp-internal/lib/responseBody";
|
|
||||||
import { applyFormInputDefaults } from "@yaakapp-internal/lib/templateFunction";
|
|
||||||
import type {
|
import type {
|
||||||
DeleteKeyValueResponse,
|
DeleteKeyValueResponse,
|
||||||
DeleteModelResponse,
|
DeleteModelResponse,
|
||||||
@@ -51,19 +48,53 @@ import type {
|
|||||||
UpsertModelResponse,
|
UpsertModelResponse,
|
||||||
WindowInfoResponse,
|
WindowInfoResponse,
|
||||||
} from "@yaakapp-internal/plugins";
|
} from "@yaakapp-internal/plugins";
|
||||||
|
import { applyDynamicFormInput, stripDynamicCallbacks } from "./pluginForms";
|
||||||
|
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
|
||||||
|
import { applyFormInputDefaults } from "./templateFunction";
|
||||||
|
|
||||||
/** What the host installs: one request out, one reply back. */
|
export interface PluginTransport {
|
||||||
export type HostCall = (
|
/** One request out, one reply back. Rejects if the host couldn't answer. */
|
||||||
context: PluginContext,
|
request(
|
||||||
payload: InternalEventPayload,
|
context: PluginContext,
|
||||||
) => Promise<Record<string, unknown>>;
|
payload: InternalEventPayload,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
|
/** Send with no reply expected. */
|
||||||
|
notify(context: PluginContext, payload: InternalEventPayload): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send once and keep receiving. Used by windows, which report navigation
|
||||||
|
* until they close. Absent where a host has no windows to open.
|
||||||
|
*/
|
||||||
|
stream?(
|
||||||
|
context: PluginContext,
|
||||||
|
payload: InternalEventPayload,
|
||||||
|
onReply: (payload: InternalEventPayload) => void,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a form that may re-render before it settles.
|
||||||
|
*
|
||||||
|
* `onChange` is called with the values entered so far and answers with the
|
||||||
|
* form to show next, so inputs that compute themselves from other inputs stay
|
||||||
|
* live. A host without it gets a form drawn once from its defaults.
|
||||||
|
*/
|
||||||
|
form?(
|
||||||
|
context: PluginContext,
|
||||||
|
payload: InternalEventPayload,
|
||||||
|
onChange: (
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
) => Promise<InternalEventPayload | null>,
|
||||||
|
): Promise<PromptFormResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A response as a plugin should see it.
|
* A response as a plugin should see it.
|
||||||
*
|
*
|
||||||
* `bodyPath` names a file on a host's disk. There is no disk here and there is
|
* `bodyPath` names a file on a host's disk: meaningless to a plugin, absent
|
||||||
* none in a browser, and plugins address bodies by response id, so it is
|
* once bodies move off the filesystem, impossible in a browser. Plugins address
|
||||||
* dropped rather than left for one to grow a dependency on.
|
* bodies by response id, so it is dropped rather than left for one to grow a
|
||||||
|
* dependency on.
|
||||||
*/
|
*/
|
||||||
function forPlugin(httpResponse: HttpResponse): HttpResponse {
|
function forPlugin(httpResponse: HttpResponse): HttpResponse {
|
||||||
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
|
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
|
||||||
@@ -72,9 +103,12 @@ function forPlugin(httpResponse: HttpResponse): HttpResponse {
|
|||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newContext(call: HostCall, context: PluginContext): Context {
|
export function createPluginContext(
|
||||||
|
transport: PluginTransport,
|
||||||
|
context: PluginContext,
|
||||||
|
): Context {
|
||||||
const send = <T>(payload: InternalEventPayload): Promise<T> =>
|
const send = <T>(payload: InternalEventPayload): Promise<T> =>
|
||||||
call(context, payload) as Promise<T>;
|
transport.request(context, payload) as Promise<T>;
|
||||||
|
|
||||||
/** Read a body the host has stored, a chunk at a time, following it if it is still arriving. */
|
/** Read a body the host has stored, a chunk at a time, following it if it is still arriving. */
|
||||||
const storedBody = async (responseId: string) => {
|
const storedBody = async (responseId: string) => {
|
||||||
@@ -132,11 +166,20 @@ export function newContext(call: HostCall, context: PluginContext): Context {
|
|||||||
requestId: async () => (await windowInfo()).requestId,
|
requestId: async () => (await windowInfo()).requestId,
|
||||||
workspaceId: async () => (await windowInfo()).workspaceId,
|
workspaceId: async () => (await windowInfo()).workspaceId,
|
||||||
environmentId: async () => (await windowInfo()).environmentId,
|
environmentId: async () => (await windowInfo()).environmentId,
|
||||||
openUrl: async () => {
|
openUrl: async ({ onNavigate, onClose, ...args }) => {
|
||||||
// A window is the host's to open, and the browser host has one tab. A
|
if (transport.stream == null) {
|
||||||
// plugin asking is told so rather than handed a handle that does
|
throw new Error("ctx.window.openUrl is not available in this runtime");
|
||||||
// nothing when it calls `close()`.
|
}
|
||||||
throw new Error("ctx.window.openUrl is not available in the sandbox runtime");
|
args.label = args.label || `${Math.random()}`;
|
||||||
|
transport.stream(context, { type: "open_window_request", ...args }, (event) => {
|
||||||
|
if (event.type === "window_navigate_event") onNavigate?.(event);
|
||||||
|
else if (event.type === "window_close_event") onClose?.();
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
close: () => {
|
||||||
|
transport.notify(context, { type: "close_window_request", label: args.label });
|
||||||
|
},
|
||||||
|
};
|
||||||
},
|
},
|
||||||
openExternalUrl: async (url) => {
|
openExternalUrl: async (url) => {
|
||||||
await send({ type: "open_external_url_request", url });
|
await send({ type: "open_external_url_request", url });
|
||||||
@@ -148,22 +191,36 @@ export function newContext(call: HostCall, context: PluginContext): Context {
|
|||||||
return reply.value;
|
return reply.value;
|
||||||
},
|
},
|
||||||
form: async (args) => {
|
form: async (args) => {
|
||||||
// The inputs a plugin declares may compute themselves from the values
|
// Inputs may compute themselves from the values entered so far, and a
|
||||||
// entered so far. The host draws a static form, so they are resolved
|
// function cannot cross to a host — so they are resolved against the
|
||||||
// against the defaults before it is drawn and the callbacks stripped
|
// defaults before the form is drawn, then stripped.
|
||||||
// — a function cannot cross the boundary, and one left in would
|
const resolve = async (values: Record<string, unknown>) => {
|
||||||
// serialize to nothing and take its input's shape with it.
|
const callArgs: CallPromptFormDynamicArgs = { values } as CallPromptFormDynamicArgs;
|
||||||
const defaults = applyFormInputDefaults(args.inputs, {});
|
const resolved = await applyDynamicFormInput(
|
||||||
const callArgs: CallPromptFormDynamicArgs = { values: defaults };
|
ctx,
|
||||||
const resolved = await applyDynamicFormInput(
|
args.inputs as DynamicPromptFormArg[],
|
||||||
ctx,
|
callArgs,
|
||||||
args.inputs as DynamicPromptFormArg[],
|
);
|
||||||
callArgs,
|
return stripDynamicCallbacks(resolved) as FormInput[];
|
||||||
);
|
};
|
||||||
const reply = await send<PromptFormResponse>({
|
|
||||||
|
const initial = await resolve(applyFormInputDefaults(args.inputs, {}));
|
||||||
|
const payload: InternalEventPayload = {
|
||||||
type: "prompt_form_request",
|
type: "prompt_form_request",
|
||||||
...args,
|
...args,
|
||||||
inputs: stripDynamicCallbacks(resolved) as FormInput[],
|
inputs: initial,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (transport.form == null) {
|
||||||
|
const reply = await send<PromptFormResponse>(payload);
|
||||||
|
return reply.values;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = await transport.form(context, payload, async (values) => {
|
||||||
|
// Fired on mount before any interaction, when there is nothing to
|
||||||
|
// recompute from.
|
||||||
|
if (values == null || Object.keys(values).length === 0) return null;
|
||||||
|
return { type: "prompt_form_request", ...args, inputs: await resolve(values) };
|
||||||
});
|
});
|
||||||
return reply.values;
|
return reply.values;
|
||||||
},
|
},
|
||||||
@@ -202,13 +259,10 @@ export function newContext(call: HostCall, context: PluginContext): Context {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// A send with no request behind it saves nothing, so the reply carries
|
// A send with no request behind it saves nothing, so the reply carries
|
||||||
// the only copy of its body. A saved one is read back from the host
|
// the only copy of its body. A saved one is read back from the host like
|
||||||
// like any other. Callers get the same thing either way.
|
// any other. Callers get the same thing either way.
|
||||||
if (body == null) {
|
if (body == null) {
|
||||||
return {
|
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
|
||||||
httpResponse: forPlugin(httpResponse),
|
|
||||||
body: await storedBody(httpResponse.id),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = decodeBase64Chunk(body);
|
const bytes = decodeBase64Chunk(body);
|
||||||
@@ -312,6 +366,10 @@ export function newContext(call: HostCall, context: PluginContext): Context {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
templates: {
|
templates: {
|
||||||
|
/**
|
||||||
|
* Invoke Yaak's template engine to render a value. If the value is a nested
|
||||||
|
* type (eg. object), it will be recursively rendered.
|
||||||
|
*/
|
||||||
render: async (args: TemplateRenderRequest) => {
|
render: async (args: TemplateRenderRequest) => {
|
||||||
const result = await send<TemplateRenderResponse>({
|
const result = await send<TemplateRenderResponse>({
|
||||||
type: "template_render_request",
|
type: "template_render_request",
|
||||||
@@ -343,7 +401,7 @@ export function newContext(call: HostCall, context: PluginContext): Context {
|
|||||||
},
|
},
|
||||||
plugin: {
|
plugin: {
|
||||||
reload: () => {
|
reload: () => {
|
||||||
void send({ type: "reload_response", silent: true });
|
transport.notify(context, { type: "reload_response", silent: true });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
@@ -362,7 +420,11 @@ export function newContext(call: HostCall, context: PluginContext): Context {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
withContext: (handle: { id: string; name: string; _label?: string }) =>
|
withContext: (handle: { id: string; name: string; _label?: string }) =>
|
||||||
newContext(call, { ...context, label: handle._label || null, workspaceId: handle.id }),
|
createPluginContext(transport, {
|
||||||
|
...context,
|
||||||
|
label: handle._label || null,
|
||||||
|
workspaceId: handle.id,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2,75 +2,37 @@ import console from "node:console";
|
|||||||
import { type Stats, statSync, watch } from "node:fs";
|
import { type Stats, statSync, watch } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type {
|
import type {
|
||||||
CallPromptFormDynamicArgs,
|
|
||||||
Context,
|
Context,
|
||||||
DynamicPromptFormArg,
|
DynamicPromptFormArg,
|
||||||
PluginDefinition,
|
PluginDefinition,
|
||||||
} from "@yaakapp/api";
|
} from "@yaakapp/api";
|
||||||
|
import {
|
||||||
|
createPluginContext,
|
||||||
|
type PluginTransport,
|
||||||
|
} from "@yaakapp-internal/lib/pluginContext";
|
||||||
import {
|
import {
|
||||||
applyDynamicFormInput,
|
applyDynamicFormInput,
|
||||||
migrateTemplateFunctionSelectOptions,
|
migrateTemplateFunctionSelectOptions,
|
||||||
stripDynamicCallbacks,
|
stripDynamicCallbacks,
|
||||||
} from "@yaakapp-internal/lib/pluginForms";
|
} from "@yaakapp-internal/lib/pluginForms";
|
||||||
import { createResponseBody, decodeBase64Chunk } from "@yaakapp-internal/lib/responseBody";
|
|
||||||
import {
|
import {
|
||||||
applyFormInputDefaults,
|
applyFormInputDefaults,
|
||||||
validateTemplateFunctionArgs,
|
validateTemplateFunctionArgs,
|
||||||
} from "@yaakapp-internal/lib/templateFunction";
|
} from "@yaakapp-internal/lib/templateFunction";
|
||||||
import type {
|
import type {
|
||||||
BootRequest,
|
BootRequest,
|
||||||
DeleteKeyValueResponse,
|
|
||||||
DeleteModelResponse,
|
|
||||||
FindHttpResponsesResponse,
|
|
||||||
Folder,
|
|
||||||
GetCookieValueRequest,
|
|
||||||
GetCookieValueResponse,
|
|
||||||
GetHttpRequestByIdResponse,
|
|
||||||
GetHttpResponseBodyInfoResponse,
|
|
||||||
GetKeyValueResponse,
|
|
||||||
GrpcRequestAction,
|
GrpcRequestAction,
|
||||||
HttpAuthenticationAction,
|
HttpAuthenticationAction,
|
||||||
HttpRequest,
|
|
||||||
HttpRequestAction,
|
HttpRequestAction,
|
||||||
HttpResponse,
|
|
||||||
ImportResources,
|
ImportResources,
|
||||||
InternalEvent,
|
InternalEvent,
|
||||||
InternalEventPayload,
|
InternalEventPayload,
|
||||||
ListCookieNamesResponse,
|
|
||||||
ListFoldersResponse,
|
|
||||||
ListHttpRequestsRequest,
|
|
||||||
ListHttpRequestsResponse,
|
|
||||||
ListOpenWorkspacesResponse,
|
|
||||||
PluginContext,
|
PluginContext,
|
||||||
PromptFormResponse,
|
PromptFormResponse,
|
||||||
PromptTextResponse,
|
|
||||||
ReadHttpResponseBodyChunkResponse,
|
|
||||||
RenderGrpcRequestResponse,
|
|
||||||
RenderHttpRequestResponse,
|
|
||||||
SendHttpRequestResponse,
|
|
||||||
TemplateFunction,
|
TemplateFunction,
|
||||||
TemplateRenderRequest,
|
|
||||||
TemplateRenderResponse,
|
|
||||||
UpsertModelResponse,
|
|
||||||
WindowInfoResponse,
|
|
||||||
} from "@yaakapp-internal/plugins";
|
} from "@yaakapp-internal/plugins";
|
||||||
import { EventChannel } from "./EventChannel";
|
import { EventChannel } from "./EventChannel";
|
||||||
|
|
||||||
/**
|
|
||||||
* A response as a plugin should see it.
|
|
||||||
*
|
|
||||||
* The host still puts `bodyPath` on the wire for its own callers, but it names
|
|
||||||
* a file on the host's disk — meaningless to a plugin, absent once bodies move
|
|
||||||
* off the filesystem, and impossible in a browser. Plugins address bodies by
|
|
||||||
* response id, so drop it here rather than let one grow a dependency on it.
|
|
||||||
*/
|
|
||||||
function forPlugin(httpResponse: HttpResponse): HttpResponse {
|
|
||||||
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
|
|
||||||
bodyPath?: string | null;
|
|
||||||
};
|
|
||||||
return rest;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginWorkerData {
|
export interface PluginWorkerData {
|
||||||
bootRequest: BootRequest;
|
bootRequest: BootRequest;
|
||||||
pluginRefId: string;
|
pluginRefId: string;
|
||||||
@@ -631,431 +593,61 @@ export class PluginInstance {
|
|||||||
this.#sendEvent(eventToSend);
|
this.#sendEvent(eventToSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
#newCtx(context: PluginContext): Context {
|
/**
|
||||||
/** Read a body the host has stored, a chunk at a time, following it if it is still arriving. */
|
* How a plugin reaches the app from this runtime.
|
||||||
const storedBody = async (responseId: string) => {
|
*
|
||||||
const bodyInfo = () =>
|
* Every request is an event whose reply is matched by id. This runtime can
|
||||||
this.#sendForReply<GetHttpResponseBodyInfoResponse>(context, {
|
* hold a conversation open, so it supplies `stream` and `form`: a window
|
||||||
type: "get_http_response_body_info_request",
|
* reports navigation until it closes, and a prompt form re-renders as values
|
||||||
responseId,
|
* change. `ctx` itself is built from these in @yaakapp-internal/lib, the same
|
||||||
});
|
* way the sandbox runtime builds it.
|
||||||
const info = await bodyInfo();
|
*/
|
||||||
|
#transport: PluginTransport = {
|
||||||
|
request: (context, payload) => this.#sendForReply(context, payload),
|
||||||
|
|
||||||
return createResponseBody(
|
notify: (context, payload) => {
|
||||||
{
|
this.#sendPayload(context, payload, null);
|
||||||
responseId,
|
},
|
||||||
contentLength: info.contentLength,
|
|
||||||
contentType: info.contentType ?? null,
|
|
||||||
complete: info.complete,
|
|
||||||
},
|
|
||||||
async (offset, length) => {
|
|
||||||
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(
|
|
||||||
context,
|
|
||||||
{ type: "read_http_response_body_chunk_request", responseId, offset, length },
|
|
||||||
);
|
|
||||||
return decodeBase64Chunk(chunk.data);
|
|
||||||
},
|
|
||||||
{ refresh: bodyInfo },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const _windowInfo = async () => {
|
stream: (context, payload, onReply) => {
|
||||||
if (context.label == null) {
|
this.#sendAndListenForEvents(context, payload, onReply);
|
||||||
throw new Error("Can't get window context without an active window");
|
},
|
||||||
}
|
|
||||||
const payload: InternalEventPayload = {
|
|
||||||
type: "window_info_request",
|
|
||||||
label: context.label,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.#sendForReply<WindowInfoResponse>(context, payload);
|
form: (context, payload, onChange) => {
|
||||||
};
|
// Built by hand so the event id is available: intermediate re-renders
|
||||||
|
// reply to the original request rather than starting a new one.
|
||||||
|
const eventToSend = this.#buildEventToSend(context, payload, null);
|
||||||
|
|
||||||
return {
|
return new Promise<PromptFormResponse>((resolve) => {
|
||||||
clipboard: {
|
const cb = (event: InternalEvent) => {
|
||||||
copyText: async (text) => {
|
if (event.replyId !== eventToSend.id) return;
|
||||||
await this.#sendForReply(context, {
|
if (event.payload.type !== "prompt_form_response") return;
|
||||||
type: "copy_text_request",
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toast: {
|
|
||||||
show: async (args) => {
|
|
||||||
await this.#sendForReply(context, {
|
|
||||||
type: "show_toast_request",
|
|
||||||
// Handle default here because null/undefined both convert to None in Rust translation
|
|
||||||
timeout: args.timeout === undefined ? 5000 : args.timeout,
|
|
||||||
...args,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
window: {
|
|
||||||
requestId: async () => {
|
|
||||||
return (await _windowInfo()).requestId;
|
|
||||||
},
|
|
||||||
async workspaceId(): Promise<string | null> {
|
|
||||||
return (await _windowInfo()).workspaceId;
|
|
||||||
},
|
|
||||||
async environmentId(): Promise<string | null> {
|
|
||||||
return (await _windowInfo()).environmentId;
|
|
||||||
},
|
|
||||||
openUrl: async ({ onNavigate, onClose, ...args }) => {
|
|
||||||
args.label = args.label || `${Math.random()}`;
|
|
||||||
const payload: InternalEventPayload = { type: "open_window_request", ...args };
|
|
||||||
const onEvent = (event: InternalEventPayload) => {
|
|
||||||
if (event.type === "window_navigate_event") {
|
|
||||||
onNavigate?.(event);
|
|
||||||
} else if (event.type === "window_close_event") {
|
|
||||||
onClose?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.#sendAndListenForEvents(context, payload, onEvent);
|
|
||||||
return {
|
|
||||||
close: () => {
|
|
||||||
const closePayload: InternalEventPayload = {
|
|
||||||
type: "close_window_request",
|
|
||||||
label: args.label,
|
|
||||||
};
|
|
||||||
this.#sendPayload(context, closePayload, null);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
openExternalUrl: async (url) => {
|
|
||||||
await this.#sendForReply(context, {
|
|
||||||
type: "open_external_url_request",
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
prompt: {
|
|
||||||
text: async (args) => {
|
|
||||||
const reply: PromptTextResponse = await this.#sendForReply(context, {
|
|
||||||
type: "prompt_text_request",
|
|
||||||
...args,
|
|
||||||
});
|
|
||||||
return reply.value;
|
|
||||||
},
|
|
||||||
form: async (args) => {
|
|
||||||
// Resolve dynamic callbacks on initial inputs using default values
|
|
||||||
const defaults = applyFormInputDefaults(args.inputs, {});
|
|
||||||
const callArgs: CallPromptFormDynamicArgs = { values: defaults };
|
|
||||||
const resolvedInputs = await applyDynamicFormInput(
|
|
||||||
this.#newCtx(context),
|
|
||||||
args.inputs,
|
|
||||||
callArgs,
|
|
||||||
);
|
|
||||||
const strippedInputs = stripDynamicCallbacks(resolvedInputs);
|
|
||||||
|
|
||||||
// Build the event manually so we can get the event ID for keying
|
const { done, values } = event.payload as PromptFormResponse;
|
||||||
const eventToSend = this.#buildEventToSend(
|
if (done) {
|
||||||
context,
|
this.#appToPluginEvents.unlisten(cb);
|
||||||
{ type: "prompt_form_request", ...args, inputs: strippedInputs },
|
resolve({ values } as PromptFormResponse);
|
||||||
null,
|
return;
|
||||||
);
|
|
||||||
|
|
||||||
// Store original inputs (with dynamic callbacks) for later resolution
|
|
||||||
this.#pendingDynamicForms.set(eventToSend.id, args.inputs);
|
|
||||||
|
|
||||||
const reply = await new Promise<PromptFormResponse>((resolve) => {
|
|
||||||
const cb = (event: InternalEvent) => {
|
|
||||||
if (event.replyId !== eventToSend.id) return;
|
|
||||||
|
|
||||||
if (event.payload.type === "prompt_form_response") {
|
|
||||||
const { done, values } = event.payload as PromptFormResponse;
|
|
||||||
if (done) {
|
|
||||||
// Final response — resolve the promise and clean up
|
|
||||||
this.#appToPluginEvents.unlisten(cb);
|
|
||||||
this.#pendingDynamicForms.delete(eventToSend.id);
|
|
||||||
resolve({ values } as PromptFormResponse);
|
|
||||||
} else {
|
|
||||||
// Intermediate value change — resolve dynamic inputs and send back
|
|
||||||
// Skip empty values (fired on initial mount before user interaction)
|
|
||||||
const storedInputs = this.#pendingDynamicForms.get(eventToSend.id);
|
|
||||||
if (storedInputs && values && Object.keys(values).length > 0) {
|
|
||||||
const ctx = this.#newCtx(context);
|
|
||||||
const callArgs: CallPromptFormDynamicArgs = { values };
|
|
||||||
applyDynamicFormInput(ctx, storedInputs, callArgs)
|
|
||||||
.then((resolvedInputs) => {
|
|
||||||
const stripped = stripDynamicCallbacks(resolvedInputs);
|
|
||||||
this.#sendPayload(
|
|
||||||
context,
|
|
||||||
{ type: "prompt_form_request", ...args, inputs: stripped },
|
|
||||||
eventToSend.id,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Failed to resolve dynamic form inputs", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.#appToPluginEvents.listen(cb);
|
|
||||||
|
|
||||||
// Send the initial event after we start listening (to prevent race)
|
|
||||||
this.#sendEvent(eventToSend);
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.values;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
httpResponse: {
|
|
||||||
find: async (args) => {
|
|
||||||
const payload = {
|
|
||||||
type: "find_http_responses_request",
|
|
||||||
...args,
|
|
||||||
} as const;
|
|
||||||
const { httpResponses } = await this.#sendForReply<FindHttpResponsesResponse>(
|
|
||||||
context,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return httpResponses.map(forPlugin);
|
|
||||||
},
|
|
||||||
body: ({ responseId }) => storedBody(responseId),
|
|
||||||
},
|
|
||||||
grpcRequest: {
|
|
||||||
render: async (args) => {
|
|
||||||
const payload = {
|
|
||||||
type: "render_grpc_request_request",
|
|
||||||
...args,
|
|
||||||
} as const;
|
|
||||||
const { grpcRequest } = await this.#sendForReply<RenderGrpcRequestResponse>(
|
|
||||||
context,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return grpcRequest;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
httpRequest: {
|
|
||||||
getById: async (args) => {
|
|
||||||
const payload = {
|
|
||||||
type: "get_http_request_by_id_request",
|
|
||||||
...args,
|
|
||||||
} as const;
|
|
||||||
const { httpRequest } = await this.#sendForReply<GetHttpRequestByIdResponse>(
|
|
||||||
context,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return httpRequest;
|
|
||||||
},
|
|
||||||
send: async (args) => {
|
|
||||||
const payload = {
|
|
||||||
type: "send_http_request_request",
|
|
||||||
...args,
|
|
||||||
} as const;
|
|
||||||
const { httpResponse, body } = await this.#sendForReply<SendHttpRequestResponse>(
|
|
||||||
context,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
|
|
||||||
// A send with no request behind it saves nothing, so the reply
|
|
||||||
// carries the only copy of its body. A saved one is read back from
|
|
||||||
// the host like any other. Callers get the same thing either way.
|
|
||||||
if (body == null) {
|
|
||||||
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = decodeBase64Chunk(body);
|
onChange(values ?? {})
|
||||||
return {
|
.then((next) => {
|
||||||
httpResponse: forPlugin(httpResponse),
|
if (next != null) this.#sendPayload(context, next, eventToSend.id);
|
||||||
body: createResponseBody(
|
})
|
||||||
{
|
.catch((err: unknown) => {
|
||||||
responseId: httpResponse.id,
|
console.error("Failed to resolve dynamic form inputs", err);
|
||||||
contentLength: bytes.byteLength,
|
});
|
||||||
contentType:
|
};
|
||||||
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")
|
this.#appToPluginEvents.listen(cb);
|
||||||
?.value ?? null,
|
|
||||||
// The host waited for the whole send before replying.
|
// Sent after the listener is attached, to prevent a race.
|
||||||
complete: true,
|
this.#sendEvent(eventToSend);
|
||||||
},
|
});
|
||||||
async (offset, length) => bytes.slice(offset, offset + length),
|
},
|
||||||
),
|
};
|
||||||
};
|
|
||||||
},
|
#newCtx(context: PluginContext): Context {
|
||||||
render: async (args) => {
|
return createPluginContext(this.#transport, context);
|
||||||
const payload = {
|
|
||||||
type: "render_http_request_request",
|
|
||||||
...args,
|
|
||||||
} as const;
|
|
||||||
const { httpRequest } = await this.#sendForReply<RenderHttpRequestResponse>(
|
|
||||||
context,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return httpRequest;
|
|
||||||
},
|
|
||||||
list: async (args?: { folderId?: string }) => {
|
|
||||||
const payload: InternalEventPayload = {
|
|
||||||
type: "list_http_requests_request",
|
|
||||||
folderId: args?.folderId,
|
|
||||||
} satisfies ListHttpRequestsRequest & { type: "list_http_requests_request" };
|
|
||||||
const { httpRequests } = await this.#sendForReply<ListHttpRequestsResponse>(
|
|
||||||
context,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return httpRequests;
|
|
||||||
},
|
|
||||||
create: async (args) => {
|
|
||||||
const payload = {
|
|
||||||
type: "upsert_model_request",
|
|
||||||
model: {
|
|
||||||
name: "",
|
|
||||||
method: "GET",
|
|
||||||
...args,
|
|
||||||
id: "",
|
|
||||||
model: "http_request",
|
|
||||||
},
|
|
||||||
} as InternalEventPayload;
|
|
||||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
|
||||||
return response.model as HttpRequest;
|
|
||||||
},
|
|
||||||
update: async (args) => {
|
|
||||||
const payload = {
|
|
||||||
type: "upsert_model_request",
|
|
||||||
model: {
|
|
||||||
model: "http_request",
|
|
||||||
...args,
|
|
||||||
},
|
|
||||||
} as InternalEventPayload;
|
|
||||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
|
||||||
return response.model as HttpRequest;
|
|
||||||
},
|
|
||||||
delete: async (args) => {
|
|
||||||
const payload = {
|
|
||||||
type: "delete_model_request",
|
|
||||||
model: "http_request",
|
|
||||||
id: args.id,
|
|
||||||
} as InternalEventPayload;
|
|
||||||
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
|
|
||||||
return response.model as HttpRequest;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
folder: {
|
|
||||||
list: async () => {
|
|
||||||
const payload = { type: "list_folders_request" } as const;
|
|
||||||
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
|
|
||||||
return folders;
|
|
||||||
},
|
|
||||||
getById: async (args: { id: string }) => {
|
|
||||||
const payload = { type: "list_folders_request" } as const;
|
|
||||||
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
|
|
||||||
return folders.find((f) => f.id === args.id) ?? null;
|
|
||||||
},
|
|
||||||
create: async ({ name, ...args }) => {
|
|
||||||
const payload = {
|
|
||||||
type: "upsert_model_request",
|
|
||||||
model: {
|
|
||||||
...args,
|
|
||||||
name: name ?? "",
|
|
||||||
id: "",
|
|
||||||
model: "folder",
|
|
||||||
},
|
|
||||||
} as InternalEventPayload;
|
|
||||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
|
||||||
return response.model as Folder;
|
|
||||||
},
|
|
||||||
update: async (args) => {
|
|
||||||
const payload = {
|
|
||||||
type: "upsert_model_request",
|
|
||||||
model: {
|
|
||||||
model: "folder",
|
|
||||||
...args,
|
|
||||||
},
|
|
||||||
} as InternalEventPayload;
|
|
||||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
|
||||||
return response.model as Folder;
|
|
||||||
},
|
|
||||||
delete: async (args: { id: string }) => {
|
|
||||||
const payload = {
|
|
||||||
type: "delete_model_request",
|
|
||||||
model: "folder",
|
|
||||||
id: args.id,
|
|
||||||
} as InternalEventPayload;
|
|
||||||
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
|
|
||||||
return response.model as Folder;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cookies: {
|
|
||||||
getValue: async (args: GetCookieValueRequest) => {
|
|
||||||
const payload = {
|
|
||||||
type: "get_cookie_value_request",
|
|
||||||
...args,
|
|
||||||
} as const;
|
|
||||||
const { value } = await this.#sendForReply<GetCookieValueResponse>(context, payload);
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
listNames: async () => {
|
|
||||||
const payload = { type: "list_cookie_names_request" } as const;
|
|
||||||
const { names } = await this.#sendForReply<ListCookieNamesResponse>(context, payload);
|
|
||||||
return names;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
templates: {
|
|
||||||
/**
|
|
||||||
* Invoke Yaak's template engine to render a value. If the value is a nested type
|
|
||||||
* (eg. object), it will be recursively rendered.
|
|
||||||
*/
|
|
||||||
render: async (args: TemplateRenderRequest) => {
|
|
||||||
const payload = { type: "template_render_request", ...args } as const;
|
|
||||||
const result = await this.#sendForReply<TemplateRenderResponse>(context, payload);
|
|
||||||
// oxlint-disable-next-line no-explicit-any -- That's okay
|
|
||||||
return result.data as any;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
store: {
|
|
||||||
get: async <T>(key: string) => {
|
|
||||||
const payload = { type: "get_key_value_request", key } as const;
|
|
||||||
const result = await this.#sendForReply<GetKeyValueResponse>(context, payload);
|
|
||||||
return result.value ? (JSON.parse(result.value) as T) : undefined;
|
|
||||||
},
|
|
||||||
set: async <T>(key: string, value: T) => {
|
|
||||||
const valueStr = JSON.stringify(value);
|
|
||||||
const payload: InternalEventPayload = {
|
|
||||||
type: "set_key_value_request",
|
|
||||||
key,
|
|
||||||
value: valueStr,
|
|
||||||
};
|
|
||||||
await this.#sendForReply<GetKeyValueResponse>(context, payload);
|
|
||||||
},
|
|
||||||
delete: async (key: string) => {
|
|
||||||
const payload = { type: "delete_key_value_request", key } as const;
|
|
||||||
const result = await this.#sendForReply<DeleteKeyValueResponse>(context, payload);
|
|
||||||
return result.deleted;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugin: {
|
|
||||||
reload: () => {
|
|
||||||
this.#sendPayload(context, { type: "reload_response", silent: true }, null);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
workspace: {
|
|
||||||
list: async () => {
|
|
||||||
const payload = {
|
|
||||||
type: "list_open_workspaces_request",
|
|
||||||
} as InternalEventPayload;
|
|
||||||
const response = await this.#sendForReply<ListOpenWorkspacesResponse>(context, payload);
|
|
||||||
return response.workspaces.map((w) => {
|
|
||||||
// Internal workspace info includes label field not in public API
|
|
||||||
type WorkspaceInfoInternal = typeof w & { label?: string };
|
|
||||||
return {
|
|
||||||
id: w.id,
|
|
||||||
name: w.name,
|
|
||||||
// Hide label from plugin authors, but keep it for internal routing
|
|
||||||
_label: (w as WorkspaceInfoInternal).label as string,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
withContext: (workspaceHandle: { id: string; name: string; _label?: string }) => {
|
|
||||||
// Create a new context with the workspace's window label
|
|
||||||
const newContext: PluginContext = {
|
|
||||||
...context,
|
|
||||||
label: workspaceHandle._label || null,
|
|
||||||
workspaceId: workspaceHandle.id,
|
|
||||||
};
|
|
||||||
return this.#newCtx(newContext);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -35,7 +35,10 @@ import type {
|
|||||||
PluginContext,
|
PluginContext,
|
||||||
TemplateFunction,
|
TemplateFunction,
|
||||||
} from "@yaakapp-internal/plugins";
|
} from "@yaakapp-internal/plugins";
|
||||||
import { newContext } from "./context";
|
import {
|
||||||
|
createPluginContext,
|
||||||
|
type PluginTransport,
|
||||||
|
} from "@yaakapp-internal/lib/pluginContext";
|
||||||
import { installGlobals } from "./globals";
|
import { installGlobals } from "./globals";
|
||||||
|
|
||||||
declare const __yaak_call: (payloadJson: string) => Promise<string>;
|
declare const __yaak_call: (payloadJson: string) => Promise<string>;
|
||||||
@@ -107,11 +110,40 @@ const EMPTY: InternalEventPayload = { type: "empty_response" };
|
|||||||
* `empty_response` rather than silence, so a caller never waits forever for a
|
* `empty_response` rather than silence, so a caller never waits forever for a
|
||||||
* capability this module doesn't have.
|
* capability this module doesn't have.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* How a plugin reaches the world from in here: one JSON envelope out, one back.
|
||||||
|
*
|
||||||
|
* No `stream` and no `form`, and that is the honest shape rather than a
|
||||||
|
* shortcut. Both need the host to hold a conversation open, which the sandbox
|
||||||
|
* protocol deliberately does not do — so `ctx.window.openUrl` refuses and a
|
||||||
|
* prompt form is drawn once from its defaults, instead of either quietly doing
|
||||||
|
* nothing.
|
||||||
|
*/
|
||||||
|
const transport: PluginTransport = {
|
||||||
|
async request(context, payload) {
|
||||||
|
const replyJson = await __yaak_call(
|
||||||
|
// The id rides along because the host multiplexes every loaded module
|
||||||
|
// through one handler, and a plugin's storage is namespaced by which
|
||||||
|
// plugin it is.
|
||||||
|
JSON.stringify({ pluginRefId, context, payload }),
|
||||||
|
);
|
||||||
|
const reply = JSON.parse(replyJson) as InternalEventPayload & { error?: string };
|
||||||
|
if (reply.type === "error_response") {
|
||||||
|
throw new Error(reply.error || `Host failed to handle ${payload.type}`);
|
||||||
|
}
|
||||||
|
const { type: _type, ...rest } = reply;
|
||||||
|
return rest as Record<string, unknown>;
|
||||||
|
},
|
||||||
|
notify(context, payload) {
|
||||||
|
void __yaak_call(JSON.stringify({ pluginRefId, context, payload }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
async function dispatch(
|
async function dispatch(
|
||||||
context: PluginContext,
|
context: PluginContext,
|
||||||
payload: InternalEventPayload,
|
payload: InternalEventPayload,
|
||||||
): Promise<InternalEventPayload> {
|
): Promise<InternalEventPayload> {
|
||||||
const ctx = newContext(hostCall, context);
|
const ctx = createPluginContext(transport, context);
|
||||||
|
|
||||||
if (payload.type === "boot_request") {
|
if (payload.type === "boot_request") {
|
||||||
await mod.init?.(ctx);
|
await mod.init?.(ctx);
|
||||||
@@ -288,7 +320,7 @@ async function dispatch(
|
|||||||
|
|
||||||
/** The five action kinds, which differ only in which list they index into. */
|
/** The five action kinds, which differ only in which list they index into. */
|
||||||
async function callAction(
|
async function callAction(
|
||||||
ctx: ReturnType<typeof newContext>,
|
ctx: ReturnType<typeof createPluginContext>,
|
||||||
payload: InternalEventPayload,
|
payload: InternalEventPayload,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const lists = {
|
const lists = {
|
||||||
@@ -309,22 +341,6 @@ async function callAction(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One outgoing request, JSON out and JSON back. */
|
|
||||||
async function hostCall(
|
|
||||||
context: PluginContext,
|
|
||||||
payload: InternalEventPayload,
|
|
||||||
): Promise<Record<string, unknown>> {
|
|
||||||
// The id rides along because the host multiplexes every loaded module
|
|
||||||
// through one handler, and a plugin's storage is namespaced by which plugin
|
|
||||||
// it is.
|
|
||||||
const replyJson = await __yaak_call(JSON.stringify({ pluginRefId, context, payload }));
|
|
||||||
const reply = JSON.parse(replyJson) as InternalEventPayload & { error?: string };
|
|
||||||
if (reply.type === "error_response") {
|
|
||||||
throw new Error(reply.error || `Host failed to handle ${payload.type}`);
|
|
||||||
}
|
|
||||||
const { type: _type, ...rest } = reply;
|
|
||||||
return rest as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the host can reach.
|
* What the host can reach.
|
||||||
|
|||||||
Reference in New Issue
Block a user