diff --git a/packages/plugin-sandbox/src/guest/context.ts b/packages/common-lib/pluginContext.ts similarity index 67% rename from packages/plugin-sandbox/src/guest/context.ts rename to packages/common-lib/pluginContext.ts index 7ce1655c..7e046368 100644 --- a/packages/plugin-sandbox/src/guest/context.ts +++ b/packages/common-lib/pluginContext.ts @@ -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, - * and awaits a reply payload. That is the whole capability surface: the sandbox - * has no socket, no clock it owns, no storage and no DOM, so anything a plugin - * does to the world is a message the host chose to answer. The payload shapes - * are the ones in `crates/yaak-plugins/src/events.rs`, unchanged, so a plugin - * written for the Node runtime runs here without knowing which host it has. + * Two runtimes host plugins today — the Node sidecar over a WebSocket, and the + * QuickJS sandbox over a message port — and a third will when the sandbox is + * embedded in Rust. What `ctx.httpRequest.send(...)` *means* is the same in all + * of them, so it is built here, and the only thing a runtime supplies is how a + * payload gets to its host and back. + * + * `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 { @@ -14,12 +17,6 @@ import type { Context, DynamicPromptFormArg, } 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 { DeleteKeyValueResponse, DeleteModelResponse, @@ -51,19 +48,53 @@ import type { UpsertModelResponse, WindowInfoResponse, } 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 type HostCall = ( - context: PluginContext, - payload: InternalEventPayload, -) => Promise>; +export interface PluginTransport { + /** One request out, one reply back. Rejects if the host couldn't answer. */ + request( + context: PluginContext, + payload: InternalEventPayload, + ): Promise>; + + /** 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, + ) => Promise, + ): Promise; +} /** * 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 - * none in a browser, and plugins address bodies by response id, so it is - * dropped rather than left for one to grow a dependency on. + * `bodyPath` names a file on a host's disk: meaningless to a plugin, absent + * once bodies move off the filesystem, impossible in a browser. Plugins address + * bodies by response id, so it is dropped rather than left for one to grow a + * dependency on. */ function forPlugin(httpResponse: HttpResponse): HttpResponse { const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & { @@ -72,9 +103,12 @@ function forPlugin(httpResponse: HttpResponse): HttpResponse { return rest; } -export function newContext(call: HostCall, context: PluginContext): Context { +export function createPluginContext( + transport: PluginTransport, + context: PluginContext, +): Context { const send = (payload: InternalEventPayload): Promise => - call(context, payload) as Promise; + transport.request(context, payload) as Promise; /** Read a body the host has stored, a chunk at a time, following it if it is still arriving. */ const storedBody = async (responseId: string) => { @@ -132,11 +166,20 @@ export function newContext(call: HostCall, context: PluginContext): Context { requestId: async () => (await windowInfo()).requestId, workspaceId: async () => (await windowInfo()).workspaceId, environmentId: async () => (await windowInfo()).environmentId, - openUrl: async () => { - // A window is the host's to open, and the browser host has one tab. A - // plugin asking is told so rather than handed a handle that does - // nothing when it calls `close()`. - throw new Error("ctx.window.openUrl is not available in the sandbox runtime"); + openUrl: async ({ onNavigate, onClose, ...args }) => { + if (transport.stream == null) { + throw new Error("ctx.window.openUrl is not available in this 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) => { await send({ type: "open_external_url_request", url }); @@ -148,22 +191,36 @@ export function newContext(call: HostCall, context: PluginContext): Context { return reply.value; }, form: async (args) => { - // The inputs a plugin declares may compute themselves from the values - // entered so far. The host draws a static form, so they are resolved - // against the defaults before it is drawn and the callbacks stripped - // — a function cannot cross the boundary, and one left in would - // serialize to nothing and take its input's shape with it. - const defaults = applyFormInputDefaults(args.inputs, {}); - const callArgs: CallPromptFormDynamicArgs = { values: defaults }; - const resolved = await applyDynamicFormInput( - ctx, - args.inputs as DynamicPromptFormArg[], - callArgs, - ); - const reply = await send({ + // Inputs may compute themselves from the values entered so far, and a + // function cannot cross to a host — so they are resolved against the + // defaults before the form is drawn, then stripped. + const resolve = async (values: Record) => { + const callArgs: CallPromptFormDynamicArgs = { values } as CallPromptFormDynamicArgs; + const resolved = await applyDynamicFormInput( + ctx, + args.inputs as DynamicPromptFormArg[], + callArgs, + ); + return stripDynamicCallbacks(resolved) as FormInput[]; + }; + + const initial = await resolve(applyFormInputDefaults(args.inputs, {})); + const payload: InternalEventPayload = { type: "prompt_form_request", ...args, - inputs: stripDynamicCallbacks(resolved) as FormInput[], + inputs: initial, + }; + + if (transport.form == null) { + const reply = await send(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; }, @@ -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 - // 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. + // 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), - }; + return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) }; } const bytes = decodeBase64Chunk(body); @@ -312,6 +366,10 @@ export function newContext(call: HostCall, context: PluginContext): Context { }, }, 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 result = await send({ type: "template_render_request", @@ -343,7 +401,7 @@ export function newContext(call: HostCall, context: PluginContext): Context { }, plugin: { reload: () => { - void send({ type: "reload_response", silent: true }); + transport.notify(context, { type: "reload_response", silent: true }); }, }, workspace: { @@ -362,7 +420,11 @@ export function newContext(call: HostCall, context: PluginContext): Context { }); }, 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, + }), }, }; diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index ddbf4320..c6253957 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -2,75 +2,37 @@ import console from "node:console"; import { type Stats, statSync, watch } from "node:fs"; import path from "node:path"; import type { - CallPromptFormDynamicArgs, Context, DynamicPromptFormArg, PluginDefinition, } from "@yaakapp/api"; +import { + createPluginContext, + type PluginTransport, +} from "@yaakapp-internal/lib/pluginContext"; import { applyDynamicFormInput, migrateTemplateFunctionSelectOptions, stripDynamicCallbacks, } from "@yaakapp-internal/lib/pluginForms"; -import { createResponseBody, decodeBase64Chunk } from "@yaakapp-internal/lib/responseBody"; import { applyFormInputDefaults, validateTemplateFunctionArgs, } from "@yaakapp-internal/lib/templateFunction"; import type { BootRequest, - DeleteKeyValueResponse, - DeleteModelResponse, - FindHttpResponsesResponse, - Folder, - GetCookieValueRequest, - GetCookieValueResponse, - GetHttpRequestByIdResponse, - GetHttpResponseBodyInfoResponse, - GetKeyValueResponse, GrpcRequestAction, HttpAuthenticationAction, - HttpRequest, HttpRequestAction, - HttpResponse, ImportResources, InternalEvent, InternalEventPayload, - ListCookieNamesResponse, - ListFoldersResponse, - ListHttpRequestsRequest, - ListHttpRequestsResponse, - ListOpenWorkspacesResponse, PluginContext, PromptFormResponse, - PromptTextResponse, - ReadHttpResponseBodyChunkResponse, - RenderGrpcRequestResponse, - RenderHttpRequestResponse, - SendHttpRequestResponse, TemplateFunction, - TemplateRenderRequest, - TemplateRenderResponse, - UpsertModelResponse, - WindowInfoResponse, } from "@yaakapp-internal/plugins"; 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 { bootRequest: BootRequest; pluginRefId: string; @@ -631,431 +593,61 @@ export class PluginInstance { 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. */ - const storedBody = async (responseId: string) => { - const bodyInfo = () => - this.#sendForReply(context, { - type: "get_http_response_body_info_request", - responseId, - }); - const info = await bodyInfo(); + /** + * How a plugin reaches the app from this runtime. + * + * Every request is an event whose reply is matched by id. This runtime can + * hold a conversation open, so it supplies `stream` and `form`: a window + * reports navigation until it closes, and a prompt form re-renders as values + * change. `ctx` itself is built from these in @yaakapp-internal/lib, the same + * way the sandbox runtime builds it. + */ + #transport: PluginTransport = { + request: (context, payload) => this.#sendForReply(context, payload), - return createResponseBody( - { - responseId, - contentLength: info.contentLength, - contentType: info.contentType ?? null, - complete: info.complete, - }, - async (offset, length) => { - const chunk = await this.#sendForReply( - context, - { type: "read_http_response_body_chunk_request", responseId, offset, length }, - ); - return decodeBase64Chunk(chunk.data); - }, - { refresh: bodyInfo }, - ); - }; + notify: (context, payload) => { + this.#sendPayload(context, payload, null); + }, - const _windowInfo = async () => { - if (context.label == null) { - throw new Error("Can't get window context without an active window"); - } - const payload: InternalEventPayload = { - type: "window_info_request", - label: context.label, - }; + stream: (context, payload, onReply) => { + this.#sendAndListenForEvents(context, payload, onReply); + }, - return this.#sendForReply(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 { - clipboard: { - copyText: async (text) => { - await this.#sendForReply(context, { - 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 { - return (await _windowInfo()).workspaceId; - }, - async environmentId(): Promise { - 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); + return new Promise((resolve) => { + const cb = (event: InternalEvent) => { + if (event.replyId !== eventToSend.id) return; + if (event.payload.type !== "prompt_form_response") return; - // Build the event manually so we can get the event ID for keying - const eventToSend = this.#buildEventToSend( - context, - { type: "prompt_form_request", ...args, inputs: strippedInputs }, - null, - ); - - // Store original inputs (with dynamic callbacks) for later resolution - this.#pendingDynamicForms.set(eventToSend.id, args.inputs); - - const reply = await new Promise((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( - 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( - 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( - context, - payload, - ); - return httpRequest; - }, - send: async (args) => { - const payload = { - type: "send_http_request_request", - ...args, - } as const; - const { httpResponse, body } = await this.#sendForReply( - 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 { done, values } = event.payload as PromptFormResponse; + if (done) { + this.#appToPluginEvents.unlisten(cb); + resolve({ values } as PromptFormResponse); + return; } - const bytes = decodeBase64Chunk(body); - return { - httpResponse: forPlugin(httpResponse), - body: createResponseBody( - { - responseId: httpResponse.id, - contentLength: bytes.byteLength, - contentType: - httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type") - ?.value ?? null, - // The host waited for the whole send before replying. - complete: true, - }, - async (offset, length) => bytes.slice(offset, offset + length), - ), - }; - }, - render: async (args) => { - const payload = { - type: "render_http_request_request", - ...args, - } as const; - const { httpRequest } = await this.#sendForReply( - 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( - 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(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(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(context, payload); - return response.model as HttpRequest; - }, - }, - folder: { - list: async () => { - const payload = { type: "list_folders_request" } as const; - const { folders } = await this.#sendForReply(context, payload); - return folders; - }, - getById: async (args: { id: string }) => { - const payload = { type: "list_folders_request" } as const; - const { folders } = await this.#sendForReply(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(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(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(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(context, payload); - return value; - }, - listNames: async () => { - const payload = { type: "list_cookie_names_request" } as const; - const { names } = await this.#sendForReply(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(context, payload); - // oxlint-disable-next-line no-explicit-any -- That's okay - return result.data as any; - }, - }, - store: { - get: async (key: string) => { - const payload = { type: "get_key_value_request", key } as const; - const result = await this.#sendForReply(context, payload); - return result.value ? (JSON.parse(result.value) as T) : undefined; - }, - set: async (key: string, value: T) => { - const valueStr = JSON.stringify(value); - const payload: InternalEventPayload = { - type: "set_key_value_request", - key, - value: valueStr, - }; - await this.#sendForReply(context, payload); - }, - delete: async (key: string) => { - const payload = { type: "delete_key_value_request", key } as const; - const result = await this.#sendForReply(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(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); - }, - }, - }; + onChange(values ?? {}) + .then((next) => { + if (next != null) this.#sendPayload(context, next, eventToSend.id); + }) + .catch((err: unknown) => { + console.error("Failed to resolve dynamic form inputs", err); + }); + }; + this.#appToPluginEvents.listen(cb); + + // Sent after the listener is attached, to prevent a race. + this.#sendEvent(eventToSend); + }); + }, + }; + + #newCtx(context: PluginContext): Context { + return createPluginContext(this.#transport, context); } } diff --git a/packages/plugin-sandbox/src/generated/guest.ts b/packages/plugin-sandbox/src/generated/guest.ts index 3c592b75..4f9769e3 100644 --- a/packages/plugin-sandbox/src/generated/guest.ts +++ b/packages/plugin-sandbox/src/generated/guest.ts @@ -3,4 +3,4 @@ // The runtime shell, as source text, for evaluation inside QuickJS. // Regenerate with `npm run build --workspace @yaakapp-internal/plugin-sandbox`. -export const GUEST_SOURCE = "\"use strict\";\n(() => {\n // ../common-lib/templateFunction.ts\n function validateTemplateFunctionArgs(fnName, args, values) {\n for (const arg of args) {\n if (\"inputs\" in arg && arg.inputs) {\n const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);\n if (err) return err;\n }\n if (!(\"name\" in arg)) continue;\n if (arg.optional) continue;\n if (arg.defaultValue != null) continue;\n if (arg.hidden) continue;\n if (values[arg.name] != null) continue;\n return `Missing required argument \"${arg.label || arg.name}\" for template function ${fnName}()`;\n }\n return null;\n }\n function applyFormInputDefaults(inputs, values) {\n let newValues = { ...values };\n for (const input of inputs) {\n if (\"defaultValue\" in input && values[input.name] === void 0) {\n newValues[input.name] = input.defaultValue;\n }\n if (input.type === \"checkbox\" && values[input.name] === void 0) {\n newValues[input.name] = false;\n }\n if (\"inputs\" in input) {\n newValues = applyFormInputDefaults(input.inputs ?? [], newValues);\n }\n }\n return newValues;\n }\n\n // ../common-lib/pluginForms.ts\n async function applyDynamicFormInput(ctx, args, callArgs) {\n const resolvedArgs = [];\n for (const { dynamic, ...arg } of args) {\n const dynamicResult = typeof dynamic === \"function\" ? await dynamic(\n ctx,\n callArgs\n ) : void 0;\n const newArg = {\n ...arg,\n ...dynamicResult\n };\n if (\"inputs\" in newArg && Array.isArray(newArg.inputs)) {\n try {\n newArg.inputs = await applyDynamicFormInput(\n ctx,\n newArg.inputs,\n callArgs\n );\n } catch (e) {\n console.error(\"Failed to apply dynamic form input\", e);\n }\n }\n resolvedArgs.push(newArg);\n }\n return resolvedArgs;\n }\n function stripDynamicCallbacks(inputs) {\n return inputs.map((input) => {\n const { dynamic: _dynamic, ...rest } = input;\n if (\"inputs\" in rest && Array.isArray(rest.inputs)) {\n rest.inputs = stripDynamicCallbacks(rest.inputs);\n }\n return rest;\n });\n }\n function migrateTemplateFunctionSelectOptions(f) {\n const migratedArgs = f.args.map((a) => {\n if (a.type === \"select\") {\n a.options = a.options.map((o) => {\n const legacy = o;\n return { label: legacy.label ?? legacy.name ?? \"\", value: legacy.value };\n });\n }\n return a;\n });\n return { ...f, args: migratedArgs };\n }\n\n // ../common-lib/responseBody.ts\n var DEFAULT_CHUNK_SIZE = 1024 * 1024;\n var DEFAULT_MAX_BYTES = 32 * 1024 * 1024;\n var DEFAULT_POLL_INTERVAL_MS = 100;\n function createResponseBody(info, readChunk, { refresh, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS } = {}) {\n const { responseId, contentLength, contentType, complete } = info;\n async function* chunks(options) {\n const chunkSize = Math.max(1, Math.floor(options?.chunkSize ?? DEFAULT_CHUNK_SIZE));\n let known = contentLength;\n let done = complete;\n let offset = 0;\n while (true) {\n if (done && offset >= known) return;\n const want = done ? Math.min(chunkSize, known - offset) : chunkSize;\n const chunk = await readChunk(offset, want);\n if (chunk.byteLength > 0) {\n yield chunk;\n offset += chunk.byteLength;\n continue;\n }\n if (done || refresh == null) return;\n ({ contentLength: known, complete: done } = await refresh());\n if (offset < known) continue;\n if (done) return;\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n async function readAll(accessor, options) {\n const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;\n refuseIfTooBig(accessor, contentLength, maxBytes);\n const parts = [];\n let total = 0;\n for await (const chunk of chunks(options)) {\n total += chunk.byteLength;\n refuseIfTooBig(accessor, total, maxBytes);\n parts.push(chunk);\n }\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n bytes.set(part, offset);\n offset += part.byteLength;\n }\n return bytes;\n }\n return {\n responseId,\n contentLength,\n contentType,\n complete,\n chunks,\n async arrayBuffer(options) {\n const bytes = await readAll(\"arrayBuffer\", options);\n return bytes.buffer;\n },\n async text(options) {\n return decodeBody(await readAll(\"text\", options), contentType);\n },\n async json(options) {\n return JSON.parse(decodeBody(await readAll(\"json\", options), contentType));\n }\n };\n }\n function refuseIfTooBig(accessor, bytes, maxBytes) {\n if (bytes <= maxBytes) return;\n throw new Error(\n `Response body is ${formatBytes(bytes)}, over the ${formatBytes(maxBytes)} limit for ${accessor}(). Read it with chunks() instead, or pass a larger maxBytes.`\n );\n }\n function decodeBody(bytes, contentType) {\n const charset = parseCharset(contentType);\n if (charset != null) {\n try {\n return new TextDecoder(charset).decode(bytes);\n } catch {\n }\n }\n return new TextDecoder(\"utf-8\").decode(bytes);\n }\n function parseCharset(contentType) {\n const match = contentType?.match(/;\\s*charset\\s*=\\s*\"?([^\";]+)\"?/i);\n return match?.[1]?.trim() || null;\n }\n function formatBytes(bytes) {\n if (bytes === Infinity) return \"unlimited\";\n if (bytes < 1024) return `${bytes} B`;\n const units = [\"KB\", \"MB\", \"GB\"];\n let value = bytes / 1024;\n let unit = 0;\n while (value >= 1024 && unit < units.length - 1) {\n value /= 1024;\n unit++;\n }\n return `${value.toFixed(1)} ${units[unit]}`;\n }\n function decodeBase64Chunk(data) {\n if (typeof Buffer !== \"undefined\") {\n const buf = Buffer.from(data, \"base64\");\n return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);\n }\n const binary = atob(data);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n }\n\n // src/guest/context.ts\n function forPlugin(httpResponse) {\n const { bodyPath: _bodyPath, ...rest } = httpResponse;\n return rest;\n }\n function newContext(call, context) {\n const send = (payload) => call(context, payload);\n const storedBody = async (responseId) => {\n const bodyInfo = () => send({\n type: \"get_http_response_body_info_request\",\n responseId\n });\n const info = await bodyInfo();\n return createResponseBody(\n {\n responseId,\n contentLength: info.contentLength,\n contentType: info.contentType ?? null,\n complete: info.complete\n },\n async (offset, length) => {\n const chunk = await send({\n type: \"read_http_response_body_chunk_request\",\n responseId,\n offset,\n length\n });\n return decodeBase64Chunk(chunk.data);\n },\n { refresh: bodyInfo }\n );\n };\n const windowInfo = async () => {\n if (context.label == null) {\n throw new Error(\"Can't get window context without an active window\");\n }\n return send({ type: \"window_info_request\", label: context.label });\n };\n const ctx = {\n clipboard: {\n copyText: async (text) => {\n await send({ type: \"copy_text_request\", text });\n }\n },\n toast: {\n show: async (args) => {\n await send({\n type: \"show_toast_request\",\n // Defaulted here because null and undefined both become None in Rust.\n timeout: args.timeout === void 0 ? 5e3 : args.timeout,\n ...args\n });\n }\n },\n window: {\n requestId: async () => (await windowInfo()).requestId,\n workspaceId: async () => (await windowInfo()).workspaceId,\n environmentId: async () => (await windowInfo()).environmentId,\n openUrl: async () => {\n throw new Error(\"ctx.window.openUrl is not available in the sandbox runtime\");\n },\n openExternalUrl: async (url) => {\n await send({ type: \"open_external_url_request\", url });\n }\n },\n prompt: {\n text: async (args) => {\n const reply = await send({ type: \"prompt_text_request\", ...args });\n return reply.value;\n },\n form: async (args) => {\n const defaults = applyFormInputDefaults(args.inputs, {});\n const callArgs = { values: defaults };\n const resolved = await applyDynamicFormInput(\n ctx,\n args.inputs,\n callArgs\n );\n const reply = await send({\n type: \"prompt_form_request\",\n ...args,\n inputs: stripDynamicCallbacks(resolved)\n });\n return reply.values;\n }\n },\n httpResponse: {\n find: async (args) => {\n const { httpResponses } = await send({\n type: \"find_http_responses_request\",\n ...args\n });\n return httpResponses.map(forPlugin);\n },\n body: ({ responseId }) => storedBody(responseId)\n },\n grpcRequest: {\n render: async (args) => {\n const { grpcRequest } = await send({\n type: \"render_grpc_request_request\",\n ...args\n });\n return grpcRequest;\n }\n },\n httpRequest: {\n getById: async (args) => {\n const { httpRequest } = await send({\n type: \"get_http_request_by_id_request\",\n ...args\n });\n return httpRequest;\n },\n send: async (args) => {\n const { httpResponse, body } = await send({\n type: \"send_http_request_request\",\n ...args\n });\n if (body == null) {\n return {\n httpResponse: forPlugin(httpResponse),\n body: await storedBody(httpResponse.id)\n };\n }\n const bytes = decodeBase64Chunk(body);\n return {\n httpResponse: forPlugin(httpResponse),\n body: createResponseBody(\n {\n responseId: httpResponse.id,\n contentLength: bytes.byteLength,\n contentType: httpResponse.headers.find((h) => h.name.toLowerCase() === \"content-type\")?.value ?? null,\n // The host waited for the whole send before replying.\n complete: true\n },\n async (offset, length) => bytes.slice(offset, offset + length)\n )\n };\n },\n render: async (args) => {\n const { httpRequest } = await send({\n type: \"render_http_request_request\",\n ...args\n });\n return httpRequest;\n },\n list: async (args) => {\n const payload = {\n type: \"list_http_requests_request\",\n folderId: args?.folderId\n };\n const { httpRequests } = await send(payload);\n return httpRequests;\n },\n create: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { name: \"\", method: \"GET\", ...args, id: \"\", model: \"http_request\" }\n });\n return response.model;\n },\n update: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { model: \"http_request\", ...args }\n });\n return response.model;\n },\n delete: async (args) => {\n const response = await send({\n type: \"delete_model_request\",\n model: \"http_request\",\n id: args.id\n });\n return response.model;\n }\n },\n folder: {\n list: async () => {\n const { folders } = await send({ type: \"list_folders_request\" });\n return folders;\n },\n getById: async (args) => {\n const { folders } = await send({ type: \"list_folders_request\" });\n return folders.find((f) => f.id === args.id) ?? null;\n },\n create: async ({ name, ...args }) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { ...args, name: name ?? \"\", id: \"\", model: \"folder\" }\n });\n return response.model;\n },\n update: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { model: \"folder\", ...args }\n });\n return response.model;\n },\n delete: async (args) => {\n const response = await send({\n type: \"delete_model_request\",\n model: \"folder\",\n id: args.id\n });\n return response.model;\n }\n },\n cookies: {\n getValue: async (args) => {\n const { value } = await send({\n type: \"get_cookie_value_request\",\n ...args\n });\n return value;\n },\n listNames: async () => {\n const { names } = await send({ type: \"list_cookie_names_request\" });\n return names;\n }\n },\n templates: {\n render: async (args) => {\n const result = await send({\n type: \"template_render_request\",\n ...args\n });\n return result.data;\n }\n },\n store: {\n get: async (key) => {\n const result = await send({ type: \"get_key_value_request\", key });\n return result.value ? JSON.parse(result.value) : void 0;\n },\n set: async (key, value) => {\n await send({\n type: \"set_key_value_request\",\n key,\n value: JSON.stringify(value)\n });\n },\n delete: async (key) => {\n const result = await send({\n type: \"delete_key_value_request\",\n key\n });\n return result.deleted;\n }\n },\n plugin: {\n reload: () => {\n void send({ type: \"reload_response\", silent: true });\n }\n },\n workspace: {\n list: async () => {\n const response = await send({\n type: \"list_open_workspaces_request\"\n });\n return response.workspaces.map((w) => {\n return {\n id: w.id,\n name: w.name,\n // Kept for routing, hidden from plugin authors.\n _label: w.label\n };\n });\n },\n withContext: (handle) => newContext(call, { ...context, label: handle._label || null, workspaceId: handle.id })\n }\n };\n return ctx;\n }\n\n // src/guest/globals.ts\n function formatArgs(args) {\n return args.map((arg) => {\n if (typeof arg === \"string\") return arg;\n if (arg instanceof Error) return arg.stack ?? `${arg.name}: ${arg.message}`;\n try {\n return JSON.stringify(arg, replacer()) ?? String(arg);\n } catch {\n return String(arg);\n }\n }).join(\" \");\n }\n function replacer() {\n const seen = /* @__PURE__ */ new WeakSet();\n return (_key, value) => {\n if (typeof value === \"bigint\") return `${value}n`;\n if (typeof value === \"function\") return `[Function ${value.name || \"anonymous\"}]`;\n if (typeof value === \"object\" && value !== null) {\n if (seen.has(value)) return \"[Circular]\";\n seen.add(value);\n }\n return value;\n };\n }\n function installConsole() {\n const log = (level) => (...args) => __yaak_log(level, formatArgs(args));\n globalThis.console = {\n log: log(\"log\"),\n info: log(\"info\"),\n warn: log(\"warn\"),\n error: log(\"error\"),\n debug: log(\"debug\"),\n trace: log(\"debug\")\n };\n }\n var timerCallbacks = /* @__PURE__ */ new Map();\n var nextTimerId = 1;\n function installTimers() {\n const g = globalThis;\n g.setTimeout = (callback, ms, ...args) => {\n const id = nextTimerId++;\n timerCallbacks.set(id, () => callback(...args));\n __yaak_timer_start(id, Math.max(0, Number(ms) || 0));\n return id;\n };\n g.clearTimeout = (id) => {\n if (!timerCallbacks.delete(id)) return;\n __yaak_timer_cancel(id);\n };\n g.setInterval = void 0;\n g.clearInterval = void 0;\n }\n function fireTimer(id) {\n const callback = timerCallbacks.get(id);\n timerCallbacks.delete(id);\n callback?.();\n }\n var SandboxTextEncoder = class {\n encoding = \"utf-8\";\n encode(input = \"\") {\n const out = [];\n for (let i = 0; i < input.length; i++) {\n let code = input.charCodeAt(i);\n if (code >= 55296 && code <= 56319) {\n const next = input.charCodeAt(i + 1);\n if (next >= 56320 && next <= 57343) {\n code = (code - 55296) * 1024 + (next - 56320) + 65536;\n i++;\n } else {\n code = 65533;\n }\n } else if (code >= 56320 && code <= 57343) {\n code = 65533;\n }\n if (code < 128) out.push(code);\n else if (code < 2048) out.push(192 | code >> 6, 128 | code & 63);\n else if (code < 65536)\n out.push(224 | code >> 12, 128 | code >> 6 & 63, 128 | code & 63);\n else\n out.push(\n 240 | code >> 18,\n 128 | code >> 12 & 63,\n 128 | code >> 6 & 63,\n 128 | code & 63\n );\n }\n return new Uint8Array(out);\n }\n };\n var SandboxTextDecoder = class {\n encoding = \"utf-8\";\n decode(input) {\n if (input == null) return \"\";\n const bytes = input instanceof Uint8Array ? input : ArrayBuffer.isView(input) ? new Uint8Array(input.buffer, input.byteOffset, input.byteLength) : new Uint8Array(input);\n let out = \"\";\n for (let i = 0; i < bytes.length; ) {\n const byte = bytes[i];\n let code;\n let size;\n if (byte < 128) {\n code = byte;\n size = 1;\n } else if ((byte & 224) === 192) {\n code = byte & 31;\n size = 2;\n } else if ((byte & 240) === 224) {\n code = byte & 15;\n size = 3;\n } else if ((byte & 248) === 240) {\n code = byte & 7;\n size = 4;\n } else {\n out += \"\\uFFFD\";\n i++;\n continue;\n }\n if (i + size > bytes.length) {\n out += \"\\uFFFD\";\n break;\n }\n for (let k = 1; k < size; k++) {\n const cont = bytes[i + k];\n if ((cont & 192) !== 128) {\n code = -1;\n break;\n }\n code = code << 6 | cont & 63;\n }\n i += size;\n if (code < 0 || code > 1114111 || code >= 55296 && code <= 57343) out += \"\\uFFFD\";\n else if (code < 65536) out += String.fromCharCode(code);\n else {\n const c = code - 65536;\n out += String.fromCharCode(55296 + (c >> 10), 56320 + (c & 1023));\n }\n }\n return out;\n }\n };\n function installTextCodecs() {\n const g = globalThis;\n g.TextEncoder = SandboxTextEncoder;\n g.TextDecoder = SandboxTextDecoder;\n }\n var B64 = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\";\n function installBase64() {\n const g = globalThis;\n g.btoa = (input) => {\n let out = \"\";\n for (let i = 0; i < input.length; i += 3) {\n const a = input.charCodeAt(i);\n const b = input.charCodeAt(i + 1);\n const c = input.charCodeAt(i + 2);\n if (a > 255 || b > 255 || c > 255) {\n throw new Error(\"btoa: string contains characters outside of the Latin1 range\");\n }\n const chunk = a << 16 | (Number.isNaN(b) ? 0 : b) << 8 | (Number.isNaN(c) ? 0 : c);\n out += B64[chunk >> 18 & 63] + B64[chunk >> 12 & 63];\n out += Number.isNaN(b) ? \"=\" : B64[chunk >> 6 & 63];\n out += Number.isNaN(c) ? \"=\" : B64[chunk & 63];\n }\n return out;\n };\n g.atob = (input) => {\n const clean = input.replace(/[\\t\\n\\f\\r ]/g, \"\").replace(/=+$/, \"\");\n let out = \"\";\n let bits = 0;\n let acc = 0;\n for (const ch of clean) {\n const value = B64.indexOf(ch);\n if (value < 0) throw new Error(\"atob: string contains invalid characters\");\n acc = acc << 6 | value;\n bits += 6;\n if (bits >= 8) {\n bits -= 8;\n out += String.fromCharCode(acc >> bits & 255);\n }\n }\n return out;\n };\n }\n function installGlobals() {\n installConsole();\n installTimers();\n installTextCodecs();\n installBase64();\n return { fireTimer };\n }\n\n // src/guest/index.ts\n var { fireTimer: fireTimer2 } = installGlobals();\n var mod = {};\n var pluginRefId = \"\";\n function load(source, refId) {\n const module = { exports: {} };\n const require2 = (specifier) => {\n throw new Error(\n `Module \"${specifier}\" is not available in the sandbox runtime. Plugins must be bundled with no external or built-in modules.`\n );\n };\n const factory = new Function(\"module\", \"exports\", \"require\", source);\n factory(module, module.exports, require2);\n const loaded = module.exports.plugin ?? module.exports.default;\n if (loaded == null || typeof loaded !== \"object\") {\n throw new Error(\"Module did not export `plugin`\");\n }\n mod = loaded;\n pluginRefId = refId;\n }\n function summary() {\n return {\n templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name),\n authentication: mod.authentication?.name ?? null,\n importer: mod.importer != null,\n filter: mod.filter != null,\n themes: (mod.themes ?? []).length,\n httpRequestActions: (mod.httpRequestActions ?? []).length,\n workspaceActions: (mod.workspaceActions ?? []).length,\n folderActions: (mod.folderActions ?? []).length,\n grpcRequestActions: (mod.grpcRequestActions ?? []).length,\n websocketRequestActions: (mod.websocketRequestActions ?? []).length\n };\n }\n var EMPTY = { type: \"empty_response\" };\n async function dispatch(context, payload) {\n const ctx = newContext(hostCall, context);\n if (payload.type === \"boot_request\") {\n await mod.init?.(ctx);\n return { type: \"boot_response\" };\n }\n if (payload.type === \"terminate_request\") {\n await mod.dispose?.();\n return { type: \"terminate_response\" };\n }\n if (payload.type === \"import_request\" && typeof mod.importer?.onImport === \"function\") {\n const reply = await mod.importer.onImport(ctx, { text: payload.content });\n if (reply != null) {\n return { type: \"import_response\", resources: reply.resources };\n }\n return EMPTY;\n }\n if (payload.type === \"filter_request\" && typeof mod.filter?.onFilter === \"function\") {\n const reply = await mod.filter.onFilter(ctx, {\n filter: payload.filter,\n payload: payload.content,\n mimeType: payload.type\n });\n return { type: \"filter_response\", ...reply };\n }\n if (payload.type === \"get_themes_request\" && Array.isArray(mod.themes)) {\n return { type: \"get_themes_response\", themes: mod.themes };\n }\n if (payload.type === \"get_template_function_summary_request\" && Array.isArray(mod.templateFunctions)) {\n const functions = mod.templateFunctions.map((f) => ({\n ...migrateTemplateFunctionSelectOptions(f),\n onRender: void 0\n }));\n return { type: \"get_template_function_summary_response\", pluginRefId, functions };\n }\n if (payload.type === \"get_template_function_config_request\" && Array.isArray(mod.templateFunctions)) {\n const found = mod.templateFunctions.find((f) => f.name === payload.name);\n if (found == null) return EMPTY;\n const fn = { ...migrateTemplateFunctionSelectOptions(found), onRender: void 0 };\n payload.values = applyFormInputDefaults(fn.args, payload.values);\n const resolved = await applyDynamicFormInput(ctx, fn.args, {\n ...payload,\n purpose: \"preview\"\n });\n return {\n type: \"get_template_function_config_response\",\n pluginRefId,\n function: { ...fn, args: stripDynamicCallbacks(resolved) }\n };\n }\n if (payload.type === \"call_template_function_request\" && Array.isArray(mod.templateFunctions)) {\n const fn = mod.templateFunctions.find((f) => f.name === payload.name);\n if (payload.args.purpose === \"preview\" && (fn?.previewType === \"click\" || fn?.previewType === \"none\")) {\n return {\n type: \"call_template_function_response\",\n value: null,\n error: \"Live preview disabled for this function\"\n };\n }\n if (typeof fn?.onRender === \"function\") {\n const resolved = await applyDynamicFormInput(ctx, fn.args, payload.args);\n const values = applyFormInputDefaults(resolved, payload.args.values);\n const error = validateTemplateFunctionArgs(fn.name, resolved, values);\n if (error && payload.args.purpose !== \"preview\") {\n return { type: \"call_template_function_response\", value: null, error };\n }\n const result = await fn.onRender(ctx, { ...payload.args, values });\n return { type: \"call_template_function_response\", value: result ?? null };\n }\n }\n if (payload.type === \"get_http_authentication_summary_request\" && mod.authentication) {\n return { type: \"get_http_authentication_summary_response\", ...mod.authentication };\n }\n if (payload.type === \"get_http_authentication_config_request\" && mod.authentication) {\n const { args, actions } = mod.authentication;\n payload.values = applyFormInputDefaults(args, payload.values);\n const resolved = await applyDynamicFormInput(ctx, args, payload);\n const resolvedActions = [];\n for (const { onSelect: _onSelect, ...action } of actions ?? []) resolvedActions.push(action);\n return {\n type: \"get_http_authentication_config_response\",\n args: stripDynamicCallbacks(resolved),\n actions: resolvedActions,\n pluginRefId\n };\n }\n if (payload.type === \"call_http_authentication_request\" && mod.authentication) {\n const auth = mod.authentication;\n if (typeof auth.onApply === \"function\") {\n const resolved = await applyDynamicFormInput(ctx, auth.args, payload);\n payload.values = applyFormInputDefaults(resolved, payload.values);\n return { type: \"call_http_authentication_response\", ...await auth.onApply(ctx, payload) };\n }\n }\n if (payload.type === \"call_http_authentication_action_request\" && mod.authentication != null) {\n const action = mod.authentication.actions?.[payload.index];\n if (typeof action?.onSelect === \"function\") {\n await action.onSelect(ctx, payload.args);\n return EMPTY;\n }\n }\n if (payload.type === \"get_http_request_actions_request\" && Array.isArray(mod.httpRequestActions)) {\n const actions = mod.httpRequestActions.map((a) => ({\n ...a,\n onSelect: void 0\n }));\n return { type: \"get_http_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_websocket_request_actions_request\" && Array.isArray(mod.websocketRequestActions)) {\n const actions = mod.websocketRequestActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_websocket_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_grpc_request_actions_request\" && Array.isArray(mod.grpcRequestActions)) {\n const actions = mod.grpcRequestActions.map((a) => ({\n ...a,\n onSelect: void 0\n }));\n return { type: \"get_grpc_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_workspace_actions_request\" && Array.isArray(mod.workspaceActions)) {\n const actions = mod.workspaceActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_workspace_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_folder_actions_request\" && Array.isArray(mod.folderActions)) {\n const actions = mod.folderActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_folder_actions_response\", pluginRefId, actions };\n }\n const called = await callAction(ctx, payload);\n if (called) return EMPTY;\n return EMPTY;\n }\n async function callAction(ctx, payload) {\n const lists = {\n call_http_request_action_request: mod.httpRequestActions,\n call_websocket_request_action_request: mod.websocketRequestActions,\n call_grpc_request_action_request: mod.grpcRequestActions,\n call_workspace_action_request: mod.workspaceActions,\n call_folder_action_request: mod.folderActions\n };\n const list = lists[payload.type];\n if (!Array.isArray(list)) return false;\n const action = list[payload.index];\n if (typeof action?.onSelect !== \"function\") return false;\n await action.onSelect(ctx, payload.args);\n return true;\n }\n async function hostCall(context, payload) {\n const replyJson = await __yaak_call(JSON.stringify({ pluginRefId, context, payload }));\n const reply = JSON.parse(replyJson);\n if (reply.type === \"error_response\") {\n throw new Error(reply.error || `Host failed to handle ${payload.type}`);\n }\n const { type: _type, ...rest } = reply;\n return rest;\n }\n globalThis.__yaak_guest = {\n load,\n summary,\n fireTimer: fireTimer2,\n dispatch: async (envelopeJson) => {\n const { context, payload } = JSON.parse(envelopeJson);\n try {\n return JSON.stringify(await dispatch(context, payload));\n } catch (err) {\n const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\\s*/g, \"\");\n return JSON.stringify({ type: \"error_response\", error });\n }\n }\n };\n})();\n"; +export const GUEST_SOURCE = "\"use strict\";\n(() => {\n // ../common-lib/templateFunction.ts\n function validateTemplateFunctionArgs(fnName, args, values) {\n for (const arg of args) {\n if (\"inputs\" in arg && arg.inputs) {\n const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);\n if (err) return err;\n }\n if (!(\"name\" in arg)) continue;\n if (arg.optional) continue;\n if (arg.defaultValue != null) continue;\n if (arg.hidden) continue;\n if (values[arg.name] != null) continue;\n return `Missing required argument \"${arg.label || arg.name}\" for template function ${fnName}()`;\n }\n return null;\n }\n function applyFormInputDefaults(inputs, values) {\n let newValues = { ...values };\n for (const input of inputs) {\n if (\"defaultValue\" in input && values[input.name] === void 0) {\n newValues[input.name] = input.defaultValue;\n }\n if (input.type === \"checkbox\" && values[input.name] === void 0) {\n newValues[input.name] = false;\n }\n if (\"inputs\" in input) {\n newValues = applyFormInputDefaults(input.inputs ?? [], newValues);\n }\n }\n return newValues;\n }\n\n // ../common-lib/pluginForms.ts\n async function applyDynamicFormInput(ctx, args, callArgs) {\n const resolvedArgs = [];\n for (const { dynamic, ...arg } of args) {\n const dynamicResult = typeof dynamic === \"function\" ? await dynamic(\n ctx,\n callArgs\n ) : void 0;\n const newArg = {\n ...arg,\n ...dynamicResult\n };\n if (\"inputs\" in newArg && Array.isArray(newArg.inputs)) {\n try {\n newArg.inputs = await applyDynamicFormInput(\n ctx,\n newArg.inputs,\n callArgs\n );\n } catch (e) {\n console.error(\"Failed to apply dynamic form input\", e);\n }\n }\n resolvedArgs.push(newArg);\n }\n return resolvedArgs;\n }\n function stripDynamicCallbacks(inputs) {\n return inputs.map((input) => {\n const { dynamic: _dynamic, ...rest } = input;\n if (\"inputs\" in rest && Array.isArray(rest.inputs)) {\n rest.inputs = stripDynamicCallbacks(rest.inputs);\n }\n return rest;\n });\n }\n function migrateTemplateFunctionSelectOptions(f) {\n const migratedArgs = f.args.map((a) => {\n if (a.type === \"select\") {\n a.options = a.options.map((o) => {\n const legacy = o;\n return { label: legacy.label ?? legacy.name ?? \"\", value: legacy.value };\n });\n }\n return a;\n });\n return { ...f, args: migratedArgs };\n }\n\n // ../common-lib/responseBody.ts\n var DEFAULT_CHUNK_SIZE = 1024 * 1024;\n var DEFAULT_MAX_BYTES = 32 * 1024 * 1024;\n var DEFAULT_POLL_INTERVAL_MS = 100;\n function createResponseBody(info, readChunk, { refresh, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS } = {}) {\n const { responseId, contentLength, contentType, complete } = info;\n async function* chunks(options) {\n const chunkSize = Math.max(1, Math.floor(options?.chunkSize ?? DEFAULT_CHUNK_SIZE));\n let known = contentLength;\n let done = complete;\n let offset = 0;\n while (true) {\n if (done && offset >= known) return;\n const want = done ? Math.min(chunkSize, known - offset) : chunkSize;\n const chunk = await readChunk(offset, want);\n if (chunk.byteLength > 0) {\n yield chunk;\n offset += chunk.byteLength;\n continue;\n }\n if (done || refresh == null) return;\n ({ contentLength: known, complete: done } = await refresh());\n if (offset < known) continue;\n if (done) return;\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n async function readAll(accessor, options) {\n const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;\n refuseIfTooBig(accessor, contentLength, maxBytes);\n const parts = [];\n let total = 0;\n for await (const chunk of chunks(options)) {\n total += chunk.byteLength;\n refuseIfTooBig(accessor, total, maxBytes);\n parts.push(chunk);\n }\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n bytes.set(part, offset);\n offset += part.byteLength;\n }\n return bytes;\n }\n return {\n responseId,\n contentLength,\n contentType,\n complete,\n chunks,\n async arrayBuffer(options) {\n const bytes = await readAll(\"arrayBuffer\", options);\n return bytes.buffer;\n },\n async text(options) {\n return decodeBody(await readAll(\"text\", options), contentType);\n },\n async json(options) {\n return JSON.parse(decodeBody(await readAll(\"json\", options), contentType));\n }\n };\n }\n function refuseIfTooBig(accessor, bytes, maxBytes) {\n if (bytes <= maxBytes) return;\n throw new Error(\n `Response body is ${formatBytes(bytes)}, over the ${formatBytes(maxBytes)} limit for ${accessor}(). Read it with chunks() instead, or pass a larger maxBytes.`\n );\n }\n function decodeBody(bytes, contentType) {\n const charset = parseCharset(contentType);\n if (charset != null) {\n try {\n return new TextDecoder(charset).decode(bytes);\n } catch {\n }\n }\n return new TextDecoder(\"utf-8\").decode(bytes);\n }\n function parseCharset(contentType) {\n const match = contentType?.match(/;\\s*charset\\s*=\\s*\"?([^\";]+)\"?/i);\n return match?.[1]?.trim() || null;\n }\n function formatBytes(bytes) {\n if (bytes === Infinity) return \"unlimited\";\n if (bytes < 1024) return `${bytes} B`;\n const units = [\"KB\", \"MB\", \"GB\"];\n let value = bytes / 1024;\n let unit = 0;\n while (value >= 1024 && unit < units.length - 1) {\n value /= 1024;\n unit++;\n }\n return `${value.toFixed(1)} ${units[unit]}`;\n }\n function decodeBase64Chunk(data) {\n if (typeof Buffer !== \"undefined\") {\n const buf = Buffer.from(data, \"base64\");\n return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);\n }\n const binary = atob(data);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n }\n\n // ../common-lib/pluginContext.ts\n function forPlugin(httpResponse) {\n const { bodyPath: _bodyPath, ...rest } = httpResponse;\n return rest;\n }\n function createPluginContext(transport2, context) {\n const send = (payload) => transport2.request(context, payload);\n const storedBody = async (responseId) => {\n const bodyInfo = () => send({\n type: \"get_http_response_body_info_request\",\n responseId\n });\n const info = await bodyInfo();\n return createResponseBody(\n {\n responseId,\n contentLength: info.contentLength,\n contentType: info.contentType ?? null,\n complete: info.complete\n },\n async (offset, length) => {\n const chunk = await send({\n type: \"read_http_response_body_chunk_request\",\n responseId,\n offset,\n length\n });\n return decodeBase64Chunk(chunk.data);\n },\n { refresh: bodyInfo }\n );\n };\n const windowInfo = async () => {\n if (context.label == null) {\n throw new Error(\"Can't get window context without an active window\");\n }\n return send({ type: \"window_info_request\", label: context.label });\n };\n const ctx = {\n clipboard: {\n copyText: async (text) => {\n await send({ type: \"copy_text_request\", text });\n }\n },\n toast: {\n show: async (args) => {\n await send({\n type: \"show_toast_request\",\n // Defaulted here because null and undefined both become None in Rust.\n timeout: args.timeout === void 0 ? 5e3 : args.timeout,\n ...args\n });\n }\n },\n window: {\n requestId: async () => (await windowInfo()).requestId,\n workspaceId: async () => (await windowInfo()).workspaceId,\n environmentId: async () => (await windowInfo()).environmentId,\n openUrl: async ({ onNavigate, onClose, ...args }) => {\n if (transport2.stream == null) {\n throw new Error(\"ctx.window.openUrl is not available in this runtime\");\n }\n args.label = args.label || `${Math.random()}`;\n transport2.stream(context, { type: \"open_window_request\", ...args }, (event) => {\n if (event.type === \"window_navigate_event\") onNavigate?.(event);\n else if (event.type === \"window_close_event\") onClose?.();\n });\n return {\n close: () => {\n transport2.notify(context, { type: \"close_window_request\", label: args.label });\n }\n };\n },\n openExternalUrl: async (url) => {\n await send({ type: \"open_external_url_request\", url });\n }\n },\n prompt: {\n text: async (args) => {\n const reply = await send({ type: \"prompt_text_request\", ...args });\n return reply.value;\n },\n form: async (args) => {\n const resolve = async (values) => {\n const callArgs = { values };\n const resolved = await applyDynamicFormInput(\n ctx,\n args.inputs,\n callArgs\n );\n return stripDynamicCallbacks(resolved);\n };\n const initial = await resolve(applyFormInputDefaults(args.inputs, {}));\n const payload = {\n type: \"prompt_form_request\",\n ...args,\n inputs: initial\n };\n if (transport2.form == null) {\n const reply2 = await send(payload);\n return reply2.values;\n }\n const reply = await transport2.form(context, payload, async (values) => {\n if (values == null || Object.keys(values).length === 0) return null;\n return { type: \"prompt_form_request\", ...args, inputs: await resolve(values) };\n });\n return reply.values;\n }\n },\n httpResponse: {\n find: async (args) => {\n const { httpResponses } = await send({\n type: \"find_http_responses_request\",\n ...args\n });\n return httpResponses.map(forPlugin);\n },\n body: ({ responseId }) => storedBody(responseId)\n },\n grpcRequest: {\n render: async (args) => {\n const { grpcRequest } = await send({\n type: \"render_grpc_request_request\",\n ...args\n });\n return grpcRequest;\n }\n },\n httpRequest: {\n getById: async (args) => {\n const { httpRequest } = await send({\n type: \"get_http_request_by_id_request\",\n ...args\n });\n return httpRequest;\n },\n send: async (args) => {\n const { httpResponse, body } = await send({\n type: \"send_http_request_request\",\n ...args\n });\n if (body == null) {\n return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };\n }\n const bytes = decodeBase64Chunk(body);\n return {\n httpResponse: forPlugin(httpResponse),\n body: createResponseBody(\n {\n responseId: httpResponse.id,\n contentLength: bytes.byteLength,\n contentType: httpResponse.headers.find((h) => h.name.toLowerCase() === \"content-type\")?.value ?? null,\n // The host waited for the whole send before replying.\n complete: true\n },\n async (offset, length) => bytes.slice(offset, offset + length)\n )\n };\n },\n render: async (args) => {\n const { httpRequest } = await send({\n type: \"render_http_request_request\",\n ...args\n });\n return httpRequest;\n },\n list: async (args) => {\n const payload = {\n type: \"list_http_requests_request\",\n folderId: args?.folderId\n };\n const { httpRequests } = await send(payload);\n return httpRequests;\n },\n create: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { name: \"\", method: \"GET\", ...args, id: \"\", model: \"http_request\" }\n });\n return response.model;\n },\n update: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { model: \"http_request\", ...args }\n });\n return response.model;\n },\n delete: async (args) => {\n const response = await send({\n type: \"delete_model_request\",\n model: \"http_request\",\n id: args.id\n });\n return response.model;\n }\n },\n folder: {\n list: async () => {\n const { folders } = await send({ type: \"list_folders_request\" });\n return folders;\n },\n getById: async (args) => {\n const { folders } = await send({ type: \"list_folders_request\" });\n return folders.find((f) => f.id === args.id) ?? null;\n },\n create: async ({ name, ...args }) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { ...args, name: name ?? \"\", id: \"\", model: \"folder\" }\n });\n return response.model;\n },\n update: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { model: \"folder\", ...args }\n });\n return response.model;\n },\n delete: async (args) => {\n const response = await send({\n type: \"delete_model_request\",\n model: \"folder\",\n id: args.id\n });\n return response.model;\n }\n },\n cookies: {\n getValue: async (args) => {\n const { value } = await send({\n type: \"get_cookie_value_request\",\n ...args\n });\n return value;\n },\n listNames: async () => {\n const { names } = await send({ type: \"list_cookie_names_request\" });\n return names;\n }\n },\n templates: {\n /**\n * Invoke Yaak's template engine to render a value. If the value is a nested\n * type (eg. object), it will be recursively rendered.\n */\n render: async (args) => {\n const result = await send({\n type: \"template_render_request\",\n ...args\n });\n return result.data;\n }\n },\n store: {\n get: async (key) => {\n const result = await send({ type: \"get_key_value_request\", key });\n return result.value ? JSON.parse(result.value) : void 0;\n },\n set: async (key, value) => {\n await send({\n type: \"set_key_value_request\",\n key,\n value: JSON.stringify(value)\n });\n },\n delete: async (key) => {\n const result = await send({\n type: \"delete_key_value_request\",\n key\n });\n return result.deleted;\n }\n },\n plugin: {\n reload: () => {\n transport2.notify(context, { type: \"reload_response\", silent: true });\n }\n },\n workspace: {\n list: async () => {\n const response = await send({\n type: \"list_open_workspaces_request\"\n });\n return response.workspaces.map((w) => {\n return {\n id: w.id,\n name: w.name,\n // Kept for routing, hidden from plugin authors.\n _label: w.label\n };\n });\n },\n withContext: (handle) => createPluginContext(transport2, {\n ...context,\n label: handle._label || null,\n workspaceId: handle.id\n })\n }\n };\n return ctx;\n }\n\n // src/guest/globals.ts\n function formatArgs(args) {\n return args.map((arg) => {\n if (typeof arg === \"string\") return arg;\n if (arg instanceof Error) return arg.stack ?? `${arg.name}: ${arg.message}`;\n try {\n return JSON.stringify(arg, replacer()) ?? String(arg);\n } catch {\n return String(arg);\n }\n }).join(\" \");\n }\n function replacer() {\n const seen = /* @__PURE__ */ new WeakSet();\n return (_key, value) => {\n if (typeof value === \"bigint\") return `${value}n`;\n if (typeof value === \"function\") return `[Function ${value.name || \"anonymous\"}]`;\n if (typeof value === \"object\" && value !== null) {\n if (seen.has(value)) return \"[Circular]\";\n seen.add(value);\n }\n return value;\n };\n }\n function installConsole() {\n const log = (level) => (...args) => __yaak_log(level, formatArgs(args));\n globalThis.console = {\n log: log(\"log\"),\n info: log(\"info\"),\n warn: log(\"warn\"),\n error: log(\"error\"),\n debug: log(\"debug\"),\n trace: log(\"debug\")\n };\n }\n var timerCallbacks = /* @__PURE__ */ new Map();\n var nextTimerId = 1;\n function installTimers() {\n const g = globalThis;\n g.setTimeout = (callback, ms, ...args) => {\n const id = nextTimerId++;\n timerCallbacks.set(id, () => callback(...args));\n __yaak_timer_start(id, Math.max(0, Number(ms) || 0));\n return id;\n };\n g.clearTimeout = (id) => {\n if (!timerCallbacks.delete(id)) return;\n __yaak_timer_cancel(id);\n };\n g.setInterval = void 0;\n g.clearInterval = void 0;\n }\n function fireTimer(id) {\n const callback = timerCallbacks.get(id);\n timerCallbacks.delete(id);\n callback?.();\n }\n var SandboxTextEncoder = class {\n encoding = \"utf-8\";\n encode(input = \"\") {\n const out = [];\n for (let i = 0; i < input.length; i++) {\n let code = input.charCodeAt(i);\n if (code >= 55296 && code <= 56319) {\n const next = input.charCodeAt(i + 1);\n if (next >= 56320 && next <= 57343) {\n code = (code - 55296) * 1024 + (next - 56320) + 65536;\n i++;\n } else {\n code = 65533;\n }\n } else if (code >= 56320 && code <= 57343) {\n code = 65533;\n }\n if (code < 128) out.push(code);\n else if (code < 2048) out.push(192 | code >> 6, 128 | code & 63);\n else if (code < 65536)\n out.push(224 | code >> 12, 128 | code >> 6 & 63, 128 | code & 63);\n else\n out.push(\n 240 | code >> 18,\n 128 | code >> 12 & 63,\n 128 | code >> 6 & 63,\n 128 | code & 63\n );\n }\n return new Uint8Array(out);\n }\n };\n var SandboxTextDecoder = class {\n encoding = \"utf-8\";\n decode(input) {\n if (input == null) return \"\";\n const bytes = input instanceof Uint8Array ? input : ArrayBuffer.isView(input) ? new Uint8Array(input.buffer, input.byteOffset, input.byteLength) : new Uint8Array(input);\n let out = \"\";\n for (let i = 0; i < bytes.length; ) {\n const byte = bytes[i];\n let code;\n let size;\n if (byte < 128) {\n code = byte;\n size = 1;\n } else if ((byte & 224) === 192) {\n code = byte & 31;\n size = 2;\n } else if ((byte & 240) === 224) {\n code = byte & 15;\n size = 3;\n } else if ((byte & 248) === 240) {\n code = byte & 7;\n size = 4;\n } else {\n out += \"\\uFFFD\";\n i++;\n continue;\n }\n if (i + size > bytes.length) {\n out += \"\\uFFFD\";\n break;\n }\n for (let k = 1; k < size; k++) {\n const cont = bytes[i + k];\n if ((cont & 192) !== 128) {\n code = -1;\n break;\n }\n code = code << 6 | cont & 63;\n }\n i += size;\n if (code < 0 || code > 1114111 || code >= 55296 && code <= 57343) out += \"\\uFFFD\";\n else if (code < 65536) out += String.fromCharCode(code);\n else {\n const c = code - 65536;\n out += String.fromCharCode(55296 + (c >> 10), 56320 + (c & 1023));\n }\n }\n return out;\n }\n };\n function installTextCodecs() {\n const g = globalThis;\n g.TextEncoder = SandboxTextEncoder;\n g.TextDecoder = SandboxTextDecoder;\n }\n var B64 = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\";\n function installBase64() {\n const g = globalThis;\n g.btoa = (input) => {\n let out = \"\";\n for (let i = 0; i < input.length; i += 3) {\n const a = input.charCodeAt(i);\n const b = input.charCodeAt(i + 1);\n const c = input.charCodeAt(i + 2);\n if (a > 255 || b > 255 || c > 255) {\n throw new Error(\"btoa: string contains characters outside of the Latin1 range\");\n }\n const chunk = a << 16 | (Number.isNaN(b) ? 0 : b) << 8 | (Number.isNaN(c) ? 0 : c);\n out += B64[chunk >> 18 & 63] + B64[chunk >> 12 & 63];\n out += Number.isNaN(b) ? \"=\" : B64[chunk >> 6 & 63];\n out += Number.isNaN(c) ? \"=\" : B64[chunk & 63];\n }\n return out;\n };\n g.atob = (input) => {\n const clean = input.replace(/[\\t\\n\\f\\r ]/g, \"\").replace(/=+$/, \"\");\n let out = \"\";\n let bits = 0;\n let acc = 0;\n for (const ch of clean) {\n const value = B64.indexOf(ch);\n if (value < 0) throw new Error(\"atob: string contains invalid characters\");\n acc = acc << 6 | value;\n bits += 6;\n if (bits >= 8) {\n bits -= 8;\n out += String.fromCharCode(acc >> bits & 255);\n }\n }\n return out;\n };\n }\n function installGlobals() {\n installConsole();\n installTimers();\n installTextCodecs();\n installBase64();\n return { fireTimer };\n }\n\n // src/guest/index.ts\n var { fireTimer: fireTimer2 } = installGlobals();\n var mod = {};\n var pluginRefId = \"\";\n function load(source, refId) {\n const module = { exports: {} };\n const require2 = (specifier) => {\n throw new Error(\n `Module \"${specifier}\" is not available in the sandbox runtime. Plugins must be bundled with no external or built-in modules.`\n );\n };\n const factory = new Function(\"module\", \"exports\", \"require\", source);\n factory(module, module.exports, require2);\n const loaded = module.exports.plugin ?? module.exports.default;\n if (loaded == null || typeof loaded !== \"object\") {\n throw new Error(\"Module did not export `plugin`\");\n }\n mod = loaded;\n pluginRefId = refId;\n }\n function summary() {\n return {\n templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name),\n authentication: mod.authentication?.name ?? null,\n importer: mod.importer != null,\n filter: mod.filter != null,\n themes: (mod.themes ?? []).length,\n httpRequestActions: (mod.httpRequestActions ?? []).length,\n workspaceActions: (mod.workspaceActions ?? []).length,\n folderActions: (mod.folderActions ?? []).length,\n grpcRequestActions: (mod.grpcRequestActions ?? []).length,\n websocketRequestActions: (mod.websocketRequestActions ?? []).length\n };\n }\n var EMPTY = { type: \"empty_response\" };\n var transport = {\n async request(context, payload) {\n const replyJson = await __yaak_call(\n // The id rides along because the host multiplexes every loaded module\n // through one handler, and a plugin's storage is namespaced by which\n // plugin it is.\n JSON.stringify({ pluginRefId, context, payload })\n );\n const reply = JSON.parse(replyJson);\n if (reply.type === \"error_response\") {\n throw new Error(reply.error || `Host failed to handle ${payload.type}`);\n }\n const { type: _type, ...rest } = reply;\n return rest;\n },\n notify(context, payload) {\n void __yaak_call(JSON.stringify({ pluginRefId, context, payload }));\n }\n };\n async function dispatch(context, payload) {\n const ctx = createPluginContext(transport, context);\n if (payload.type === \"boot_request\") {\n await mod.init?.(ctx);\n return { type: \"boot_response\" };\n }\n if (payload.type === \"terminate_request\") {\n await mod.dispose?.();\n return { type: \"terminate_response\" };\n }\n if (payload.type === \"import_request\" && typeof mod.importer?.onImport === \"function\") {\n const reply = await mod.importer.onImport(ctx, { text: payload.content });\n if (reply != null) {\n return { type: \"import_response\", resources: reply.resources };\n }\n return EMPTY;\n }\n if (payload.type === \"filter_request\" && typeof mod.filter?.onFilter === \"function\") {\n const reply = await mod.filter.onFilter(ctx, {\n filter: payload.filter,\n payload: payload.content,\n mimeType: payload.type\n });\n return { type: \"filter_response\", ...reply };\n }\n if (payload.type === \"get_themes_request\" && Array.isArray(mod.themes)) {\n return { type: \"get_themes_response\", themes: mod.themes };\n }\n if (payload.type === \"get_template_function_summary_request\" && Array.isArray(mod.templateFunctions)) {\n const functions = mod.templateFunctions.map((f) => ({\n ...migrateTemplateFunctionSelectOptions(f),\n onRender: void 0\n }));\n return { type: \"get_template_function_summary_response\", pluginRefId, functions };\n }\n if (payload.type === \"get_template_function_config_request\" && Array.isArray(mod.templateFunctions)) {\n const found = mod.templateFunctions.find((f) => f.name === payload.name);\n if (found == null) return EMPTY;\n const fn = { ...migrateTemplateFunctionSelectOptions(found), onRender: void 0 };\n payload.values = applyFormInputDefaults(fn.args, payload.values);\n const resolved = await applyDynamicFormInput(ctx, fn.args, {\n ...payload,\n purpose: \"preview\"\n });\n return {\n type: \"get_template_function_config_response\",\n pluginRefId,\n function: { ...fn, args: stripDynamicCallbacks(resolved) }\n };\n }\n if (payload.type === \"call_template_function_request\" && Array.isArray(mod.templateFunctions)) {\n const fn = mod.templateFunctions.find((f) => f.name === payload.name);\n if (payload.args.purpose === \"preview\" && (fn?.previewType === \"click\" || fn?.previewType === \"none\")) {\n return {\n type: \"call_template_function_response\",\n value: null,\n error: \"Live preview disabled for this function\"\n };\n }\n if (typeof fn?.onRender === \"function\") {\n const resolved = await applyDynamicFormInput(ctx, fn.args, payload.args);\n const values = applyFormInputDefaults(resolved, payload.args.values);\n const error = validateTemplateFunctionArgs(fn.name, resolved, values);\n if (error && payload.args.purpose !== \"preview\") {\n return { type: \"call_template_function_response\", value: null, error };\n }\n const result = await fn.onRender(ctx, { ...payload.args, values });\n return { type: \"call_template_function_response\", value: result ?? null };\n }\n }\n if (payload.type === \"get_http_authentication_summary_request\" && mod.authentication) {\n return { type: \"get_http_authentication_summary_response\", ...mod.authentication };\n }\n if (payload.type === \"get_http_authentication_config_request\" && mod.authentication) {\n const { args, actions } = mod.authentication;\n payload.values = applyFormInputDefaults(args, payload.values);\n const resolved = await applyDynamicFormInput(ctx, args, payload);\n const resolvedActions = [];\n for (const { onSelect: _onSelect, ...action } of actions ?? []) resolvedActions.push(action);\n return {\n type: \"get_http_authentication_config_response\",\n args: stripDynamicCallbacks(resolved),\n actions: resolvedActions,\n pluginRefId\n };\n }\n if (payload.type === \"call_http_authentication_request\" && mod.authentication) {\n const auth = mod.authentication;\n if (typeof auth.onApply === \"function\") {\n const resolved = await applyDynamicFormInput(ctx, auth.args, payload);\n payload.values = applyFormInputDefaults(resolved, payload.values);\n return { type: \"call_http_authentication_response\", ...await auth.onApply(ctx, payload) };\n }\n }\n if (payload.type === \"call_http_authentication_action_request\" && mod.authentication != null) {\n const action = mod.authentication.actions?.[payload.index];\n if (typeof action?.onSelect === \"function\") {\n await action.onSelect(ctx, payload.args);\n return EMPTY;\n }\n }\n if (payload.type === \"get_http_request_actions_request\" && Array.isArray(mod.httpRequestActions)) {\n const actions = mod.httpRequestActions.map((a) => ({\n ...a,\n onSelect: void 0\n }));\n return { type: \"get_http_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_websocket_request_actions_request\" && Array.isArray(mod.websocketRequestActions)) {\n const actions = mod.websocketRequestActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_websocket_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_grpc_request_actions_request\" && Array.isArray(mod.grpcRequestActions)) {\n const actions = mod.grpcRequestActions.map((a) => ({\n ...a,\n onSelect: void 0\n }));\n return { type: \"get_grpc_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_workspace_actions_request\" && Array.isArray(mod.workspaceActions)) {\n const actions = mod.workspaceActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_workspace_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_folder_actions_request\" && Array.isArray(mod.folderActions)) {\n const actions = mod.folderActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_folder_actions_response\", pluginRefId, actions };\n }\n const called = await callAction(ctx, payload);\n if (called) return EMPTY;\n return EMPTY;\n }\n async function callAction(ctx, payload) {\n const lists = {\n call_http_request_action_request: mod.httpRequestActions,\n call_websocket_request_action_request: mod.websocketRequestActions,\n call_grpc_request_action_request: mod.grpcRequestActions,\n call_workspace_action_request: mod.workspaceActions,\n call_folder_action_request: mod.folderActions\n };\n const list = lists[payload.type];\n if (!Array.isArray(list)) return false;\n const action = list[payload.index];\n if (typeof action?.onSelect !== \"function\") return false;\n await action.onSelect(ctx, payload.args);\n return true;\n }\n globalThis.__yaak_guest = {\n load,\n summary,\n fireTimer: fireTimer2,\n dispatch: async (envelopeJson) => {\n const { context, payload } = JSON.parse(envelopeJson);\n try {\n return JSON.stringify(await dispatch(context, payload));\n } catch (err) {\n const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\\s*/g, \"\");\n return JSON.stringify({ type: \"error_response\", error });\n }\n }\n };\n})();\n"; diff --git a/packages/plugin-sandbox/src/guest/index.ts b/packages/plugin-sandbox/src/guest/index.ts index 0b0c305e..77974f87 100644 --- a/packages/plugin-sandbox/src/guest/index.ts +++ b/packages/plugin-sandbox/src/guest/index.ts @@ -35,7 +35,10 @@ import type { PluginContext, TemplateFunction, } from "@yaakapp-internal/plugins"; -import { newContext } from "./context"; +import { + createPluginContext, + type PluginTransport, +} from "@yaakapp-internal/lib/pluginContext"; import { installGlobals } from "./globals"; declare const __yaak_call: (payloadJson: string) => Promise; @@ -107,11 +110,40 @@ const EMPTY: InternalEventPayload = { type: "empty_response" }; * `empty_response` rather than silence, so a caller never waits forever for a * 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; + }, + notify(context, payload) { + void __yaak_call(JSON.stringify({ pluginRefId, context, payload })); + }, +}; + async function dispatch( context: PluginContext, payload: InternalEventPayload, ): Promise { - const ctx = newContext(hostCall, context); + const ctx = createPluginContext(transport, context); if (payload.type === "boot_request") { await mod.init?.(ctx); @@ -288,7 +320,7 @@ async function dispatch( /** The five action kinds, which differ only in which list they index into. */ async function callAction( - ctx: ReturnType, + ctx: ReturnType, payload: InternalEventPayload, ): Promise { const lists = { @@ -309,22 +341,6 @@ async function callAction( return true; } -/** One outgoing request, JSON out and JSON back. */ -async function hostCall( - context: PluginContext, - payload: InternalEventPayload, -): Promise> { - // 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; -} /** * What the host can reach.