snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
1
packages/plugin-runtime/.node-version
Normal file
1
packages/plugin-runtime/.node-version
Normal file
@@ -0,0 +1 @@
|
||||
24.11.1
|
||||
16
packages/plugin-runtime/package.json
Normal file
16
packages/plugin-runtime/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/plugin-runtime",
|
||||
"scripts": {
|
||||
"bootstrap": "npm run build",
|
||||
"build": "run-p build:*",
|
||||
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/ws": "^8.5.13",
|
||||
"esbuild": "^0.28.0"
|
||||
}
|
||||
}
|
||||
19
packages/plugin-runtime/src/EventChannel.ts
Normal file
19
packages/plugin-runtime/src/EventChannel.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { InternalEvent } from "@yaakapp/api";
|
||||
|
||||
export class EventChannel {
|
||||
#listeners = new Set<(event: InternalEvent) => void>();
|
||||
|
||||
emit(e: InternalEvent) {
|
||||
for (const l of this.#listeners) {
|
||||
l(e);
|
||||
}
|
||||
}
|
||||
|
||||
listen(cb: (e: InternalEvent) => void) {
|
||||
this.#listeners.add(cb);
|
||||
}
|
||||
|
||||
unlisten(cb: (e: InternalEvent) => void) {
|
||||
this.#listeners.delete(cb);
|
||||
}
|
||||
}
|
||||
26
packages/plugin-runtime/src/PluginHandle.ts
Normal file
26
packages/plugin-runtime/src/PluginHandle.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { BootRequest, InternalEvent } from "@yaakapp/api";
|
||||
import type { PluginContext } from "@yaakapp-internal/plugins";
|
||||
import type { EventChannel } from "./EventChannel";
|
||||
import { PluginInstance, type PluginWorkerData } from "./PluginInstance";
|
||||
|
||||
export class PluginHandle {
|
||||
#instance: PluginInstance;
|
||||
|
||||
constructor(
|
||||
pluginRefId: string,
|
||||
context: PluginContext,
|
||||
bootRequest: BootRequest,
|
||||
pluginToAppEvents: EventChannel,
|
||||
) {
|
||||
const workerData: PluginWorkerData = { pluginRefId, context, bootRequest };
|
||||
this.#instance = new PluginInstance(workerData, pluginToAppEvents);
|
||||
}
|
||||
|
||||
sendToWorker(event: InternalEvent) {
|
||||
this.#instance.postMessage(event);
|
||||
}
|
||||
|
||||
async terminate() {
|
||||
await this.#instance.terminate();
|
||||
}
|
||||
}
|
||||
1099
packages/plugin-runtime/src/PluginInstance.ts
Normal file
1099
packages/plugin-runtime/src/PluginInstance.ts
Normal file
File diff suppressed because it is too large
Load Diff
75
packages/plugin-runtime/src/common.ts
Normal file
75
packages/plugin-runtime/src/common.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
CallPromptFormDynamicArgs,
|
||||
Context,
|
||||
DynamicAuthenticationArg,
|
||||
DynamicPromptFormArg,
|
||||
DynamicTemplateFunctionArg,
|
||||
} from "@yaakapp/api";
|
||||
import type {
|
||||
CallHttpAuthenticationActionArgs,
|
||||
CallTemplateFunctionArgs,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
|
||||
type AnyDynamicArg = DynamicTemplateFunctionArg | DynamicAuthenticationArg | DynamicPromptFormArg;
|
||||
type AnyCallArgs =
|
||||
| CallTemplateFunctionArgs
|
||||
| CallHttpAuthenticationActionArgs
|
||||
| CallPromptFormDynamicArgs;
|
||||
|
||||
export async function applyDynamicFormInput(
|
||||
ctx: Context,
|
||||
args: DynamicTemplateFunctionArg[],
|
||||
callArgs: CallTemplateFunctionArgs,
|
||||
): Promise<DynamicTemplateFunctionArg[]>;
|
||||
|
||||
export async function applyDynamicFormInput(
|
||||
ctx: Context,
|
||||
args: DynamicAuthenticationArg[],
|
||||
callArgs: CallHttpAuthenticationActionArgs,
|
||||
): Promise<DynamicAuthenticationArg[]>;
|
||||
|
||||
export async function applyDynamicFormInput(
|
||||
ctx: Context,
|
||||
args: DynamicPromptFormArg[],
|
||||
callArgs: CallPromptFormDynamicArgs,
|
||||
): Promise<DynamicPromptFormArg[]>;
|
||||
|
||||
export async function applyDynamicFormInput(
|
||||
ctx: Context,
|
||||
args: AnyDynamicArg[],
|
||||
callArgs: AnyCallArgs,
|
||||
): Promise<AnyDynamicArg[]> {
|
||||
const resolvedArgs: AnyDynamicArg[] = [];
|
||||
for (const { dynamic, ...arg } of args) {
|
||||
const dynamicResult =
|
||||
typeof dynamic === "function"
|
||||
? await dynamic(
|
||||
ctx,
|
||||
callArgs as CallTemplateFunctionArgs &
|
||||
CallHttpAuthenticationActionArgs &
|
||||
CallPromptFormDynamicArgs,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const newArg = {
|
||||
...arg,
|
||||
...dynamicResult,
|
||||
} as AnyDynamicArg;
|
||||
|
||||
if ("inputs" in newArg && Array.isArray(newArg.inputs)) {
|
||||
try {
|
||||
newArg.inputs = await applyDynamicFormInput(
|
||||
ctx,
|
||||
newArg.inputs as DynamicTemplateFunctionArg[],
|
||||
callArgs as CallTemplateFunctionArgs &
|
||||
CallHttpAuthenticationActionArgs &
|
||||
CallPromptFormDynamicArgs,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to apply dynamic form input", e);
|
||||
}
|
||||
}
|
||||
resolvedArgs.push(newArg);
|
||||
}
|
||||
return resolvedArgs;
|
||||
}
|
||||
73
packages/plugin-runtime/src/index.ts
Normal file
73
packages/plugin-runtime/src/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { InternalEvent } from "@yaakapp/api";
|
||||
import WebSocket from "ws";
|
||||
import { EventChannel } from "./EventChannel";
|
||||
import { PluginHandle } from "./PluginHandle";
|
||||
|
||||
const port = process.env.PORT;
|
||||
if (!port) {
|
||||
throw new Error("Plugin runtime missing PORT");
|
||||
}
|
||||
|
||||
const host = process.env.HOST;
|
||||
if (!host) {
|
||||
throw new Error("Plugin runtime missing HOST");
|
||||
}
|
||||
|
||||
const pluginToAppEvents = new EventChannel();
|
||||
const plugins: Record<string, PluginHandle> = {};
|
||||
|
||||
const ws = new WebSocket(`ws://${host}:${port}`);
|
||||
|
||||
ws.on("message", async (e: Buffer) => {
|
||||
try {
|
||||
await handleIncoming(e.toString());
|
||||
} catch (err) {
|
||||
console.log("Failed to handle incoming plugin event", err);
|
||||
}
|
||||
});
|
||||
ws.on("open", () => console.log("Plugin runtime connected to websocket"));
|
||||
ws.on("error", (err: unknown) => console.error("Plugin runtime websocket error", err));
|
||||
ws.on("close", (code: number) => console.log("Plugin runtime websocket closed", code));
|
||||
|
||||
// Listen for incoming events from plugins
|
||||
pluginToAppEvents.listen((e) => {
|
||||
const eventStr = JSON.stringify(e);
|
||||
ws.send(eventStr);
|
||||
});
|
||||
|
||||
async function handleIncoming(msg: string) {
|
||||
const pluginEvent: InternalEvent = JSON.parse(msg);
|
||||
// Handle special event to bootstrap plugin
|
||||
if (pluginEvent.payload.type === "boot_request") {
|
||||
const plugin = new PluginHandle(
|
||||
pluginEvent.pluginRefId,
|
||||
pluginEvent.context,
|
||||
pluginEvent.payload,
|
||||
pluginToAppEvents,
|
||||
);
|
||||
plugins[pluginEvent.pluginRefId] = plugin;
|
||||
}
|
||||
|
||||
// Once booted, forward all events to the plugin worker
|
||||
const plugin = plugins[pluginEvent.pluginRefId];
|
||||
if (!plugin) {
|
||||
console.warn("Failed to get plugin for event by", pluginEvent.pluginRefId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pluginEvent.payload.type === "terminate_request") {
|
||||
await plugin.terminate();
|
||||
console.log("Terminated plugin worker", pluginEvent.pluginRefId);
|
||||
delete plugins[pluginEvent.pluginRefId];
|
||||
}
|
||||
|
||||
plugin.sendToWorker(pluginEvent);
|
||||
}
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("Uncaught Exception:", error);
|
||||
});
|
||||
29
packages/plugin-runtime/src/interceptStdout.ts
Normal file
29
packages/plugin-runtime/src/interceptStdout.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/* oxlint-disable unbound-method */
|
||||
import process from "node:process";
|
||||
|
||||
export function interceptStdout(intercept: (text: string) => string) {
|
||||
const old_stdout_write = process.stdout.write;
|
||||
const old_stderr_write = process.stderr.write;
|
||||
|
||||
process.stdout.write = ((write) =>
|
||||
((text: string, ...args: never[]) => {
|
||||
write.call(process.stdout, interceptor(text, intercept), ...args);
|
||||
return true;
|
||||
}) as typeof process.stdout.write)(process.stdout.write);
|
||||
|
||||
process.stderr.write = ((write) =>
|
||||
((text: string, ...args: never[]) => {
|
||||
write.call(process.stderr, interceptor(text, intercept), ...args);
|
||||
return true;
|
||||
}) as typeof process.stderr.write)(process.stderr.write);
|
||||
|
||||
// puts back to original
|
||||
return function unhook() {
|
||||
process.stdout.write = old_stdout_write;
|
||||
process.stderr.write = old_stderr_write;
|
||||
};
|
||||
}
|
||||
|
||||
function interceptor(text: string, fn: (text: string) => string) {
|
||||
return fn(text).replace(/\n$/, "") + (fn(text) && text.endsWith("\n") ? "\n" : "");
|
||||
}
|
||||
190
packages/plugin-runtime/src/metadata.ts
Normal file
190
packages/plugin-runtime/src/metadata.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import type { PluginDefinition } from "@yaakapp/api";
|
||||
|
||||
type PluginFeatureKey = Exclude<
|
||||
Extract<keyof PluginDefinition, string>,
|
||||
"init" | "dispose"
|
||||
>;
|
||||
type PluginAPIKey = PluginFeatureKey | "lifecycle";
|
||||
|
||||
type MetadataDefinition = {
|
||||
key: PluginFeatureKey;
|
||||
label: string;
|
||||
array: boolean;
|
||||
};
|
||||
|
||||
type MetadataItem =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| MetadataItem[]
|
||||
| { [key: string]: MetadataItem };
|
||||
|
||||
type APITypeMetadata = {
|
||||
label: string;
|
||||
source: string;
|
||||
count: number;
|
||||
items: MetadataItem[];
|
||||
};
|
||||
|
||||
type PluginMetadata = {
|
||||
schemaVersion: 1;
|
||||
apiTypes: PluginAPIKey[];
|
||||
apis: Partial<Record<PluginAPIKey, APITypeMetadata>>;
|
||||
};
|
||||
|
||||
const definitions: MetadataDefinition[] = [
|
||||
{
|
||||
key: "authentication",
|
||||
label: "Authentication",
|
||||
array: false,
|
||||
},
|
||||
{ key: "filter", label: "Filter", array: false },
|
||||
{
|
||||
key: "folderActions",
|
||||
label: "Folder Action",
|
||||
array: true,
|
||||
},
|
||||
{
|
||||
key: "grpcRequestActions",
|
||||
label: "gRPC Request Action",
|
||||
array: true,
|
||||
},
|
||||
{
|
||||
key: "httpRequestActions",
|
||||
label: "HTTP Request Action",
|
||||
array: true,
|
||||
},
|
||||
{ key: "importer", label: "Importer", array: false },
|
||||
{
|
||||
key: "templateFunctions",
|
||||
label: "Template Tag",
|
||||
array: true,
|
||||
},
|
||||
{ key: "themes", label: "Theme", array: true },
|
||||
{
|
||||
key: "websocketRequestActions",
|
||||
label: "WebSocket Request Action",
|
||||
array: true,
|
||||
},
|
||||
{
|
||||
key: "workspaceActions",
|
||||
label: "Workspace Action",
|
||||
array: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function generatePluginMetadata(
|
||||
plugin: PluginDefinition,
|
||||
): PluginMetadata {
|
||||
const metadata: PluginMetadata = {
|
||||
schemaVersion: 1,
|
||||
apiTypes: [],
|
||||
apis: {},
|
||||
};
|
||||
|
||||
for (const definition of definitions) {
|
||||
const value = plugin[definition.key];
|
||||
const items = definition.array ? value : value ? [value] : [];
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata.apiTypes.push(definition.key);
|
||||
metadata.apis[definition.key] = {
|
||||
label: definition.label,
|
||||
source: definition.key,
|
||||
count: items.length,
|
||||
items: sanitize(items) as MetadataItem[],
|
||||
};
|
||||
}
|
||||
|
||||
const lifecycleHooks = ["init", "dispose"].filter(
|
||||
(key) =>
|
||||
typeof plugin[key as keyof Pick<PluginDefinition, "init" | "dispose">] ===
|
||||
"function",
|
||||
);
|
||||
if (lifecycleHooks.length > 0) {
|
||||
metadata.apiTypes.push("lifecycle");
|
||||
metadata.apis.lifecycle = {
|
||||
label: "Lifecycle Hook",
|
||||
source: lifecycleHooks.join(","),
|
||||
count: lifecycleHooks.length,
|
||||
items: lifecycleHooks.map((name) => ({ name })),
|
||||
};
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const entryPath = process.argv[1];
|
||||
const outputPath = process.argv[2];
|
||||
|
||||
if (!entryPath) {
|
||||
throw new Error("Missing plugin entrypoint path");
|
||||
}
|
||||
if (!outputPath) {
|
||||
throw new Error("Missing plugin metadata output path");
|
||||
}
|
||||
|
||||
const require = createRequire(path.join(process.cwd(), "plugin-metadata.js"));
|
||||
const moduleExports = require(path.resolve(entryPath)) as PluginDefinition & {
|
||||
plugin?: PluginDefinition;
|
||||
default?: PluginDefinition;
|
||||
};
|
||||
const plugin = moduleExports.plugin ?? moduleExports.default ?? moduleExports;
|
||||
|
||||
if (!plugin || typeof plugin !== "object") {
|
||||
throw new Error("Plugin entrypoint must export a plugin object");
|
||||
}
|
||||
|
||||
const metadata = generatePluginMetadata(plugin);
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
||||
|
||||
function sanitize(
|
||||
value: unknown,
|
||||
seen = new WeakSet<object>(),
|
||||
): MetadataItem | undefined {
|
||||
if (value === null) return null;
|
||||
|
||||
switch (typeof value) {
|
||||
case "boolean":
|
||||
case "number":
|
||||
case "string":
|
||||
return value;
|
||||
case "bigint":
|
||||
return value.toString();
|
||||
case "function":
|
||||
case "symbol":
|
||||
case "undefined":
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const objectValue = value as object;
|
||||
if (seen.has(objectValue)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
|
||||
seen.add(objectValue);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const output = value.map((item) => sanitize(item, seen) ?? null);
|
||||
seen.delete(objectValue);
|
||||
return output;
|
||||
}
|
||||
|
||||
const output: Record<string, MetadataItem> = {};
|
||||
for (const [key, item] of Object.entries(objectValue)) {
|
||||
const sanitized = sanitize(item, seen);
|
||||
if (sanitized !== undefined) {
|
||||
output[key] = sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
seen.delete(objectValue);
|
||||
return output;
|
||||
}
|
||||
22
packages/plugin-runtime/src/migrations.ts
Normal file
22
packages/plugin-runtime/src/migrations.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { TemplateFunctionPlugin } from "@yaakapp/api";
|
||||
|
||||
export function migrateTemplateFunctionSelectOptions(
|
||||
f: TemplateFunctionPlugin,
|
||||
): TemplateFunctionPlugin {
|
||||
const migratedArgs = f.args.map((a) => {
|
||||
if (a.type === "select") {
|
||||
// Migrate old options that had 'name' instead of 'label'
|
||||
type LegacyOption = { label?: string; value: string; name?: string };
|
||||
a.options = a.options.map((o) => {
|
||||
const legacy = o as LegacyOption;
|
||||
return {
|
||||
label: legacy.label ?? legacy.name ?? "",
|
||||
value: legacy.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
return a;
|
||||
});
|
||||
|
||||
return { ...f, args: migratedArgs };
|
||||
}
|
||||
194
packages/plugin-runtime/src/responseBody.ts
Normal file
194
packages/plugin-runtime/src/responseBody.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { HttpResponseBody, ReadHttpResponseBodyOptions } from "@yaakapp/api";
|
||||
|
||||
/** Bytes pulled from the host per round trip, when the caller doesn't say. */
|
||||
const DEFAULT_CHUNK_SIZE = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The most a plugin buffers by default.
|
||||
*
|
||||
* Reading a body used to be unbounded, so any ceiling is an improvement; this
|
||||
* one is set well above what an API returns and well below what makes the
|
||||
* plugin runtime fall over. `chunks()` has no ceiling, and any caller that
|
||||
* really wants the whole thing can raise `maxBytes`.
|
||||
*/
|
||||
const DEFAULT_MAX_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/** How long to wait, having caught up with a body still arriving, before looking again. */
|
||||
const DEFAULT_POLL_INTERVAL_MS = 100;
|
||||
|
||||
/** Fetch one window of body bytes from the host. */
|
||||
export type ReadResponseBodyChunk = (offset: number, length: number) => Promise<Uint8Array>;
|
||||
|
||||
export interface ResponseBodyInfo {
|
||||
responseId: string;
|
||||
contentLength: number;
|
||||
contentType: string | null;
|
||||
/** Whether the response has finished arriving, so `contentLength` is final. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** What can change while a body is still arriving. */
|
||||
export type ResponseBodyProgress = Pick<ResponseBodyInfo, "contentLength" | "complete">;
|
||||
|
||||
export interface CreateResponseBodyOptions {
|
||||
/**
|
||||
* Ask the host where the body has got to. Needed only for a body that was
|
||||
* not complete when opened; a reader that has caught up calls this to learn
|
||||
* whether to wait for more or stop.
|
||||
*/
|
||||
refresh?: () => Promise<ResponseBodyProgress>;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export function createResponseBody(
|
||||
info: ResponseBodyInfo,
|
||||
readChunk: ReadResponseBodyChunk,
|
||||
{ refresh, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS }: CreateResponseBodyOptions = {},
|
||||
): HttpResponseBody {
|
||||
const { responseId, contentLength, contentType, complete } = info;
|
||||
|
||||
/**
|
||||
* Yield the body from the start until it has all arrived.
|
||||
*
|
||||
* A complete body is read up to its known length and no further. One still
|
||||
* arriving is followed: on catching up, ask the host whether it has finished,
|
||||
* and if not, wait and look again. So this ends when the response does —
|
||||
* which for a stream that never closes means it doesn't, exactly as
|
||||
* iterating `fetch`'s body would not.
|
||||
*/
|
||||
async function* chunks(
|
||||
options?: Pick<ReadHttpResponseBodyOptions, "chunkSize">,
|
||||
): AsyncIterable<Uint8Array> {
|
||||
const chunkSize = Math.max(1, Math.floor(options?.chunkSize ?? DEFAULT_CHUNK_SIZE));
|
||||
let known = contentLength;
|
||||
let done = complete;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
if (done && offset >= known) return;
|
||||
|
||||
const want = done ? Math.min(chunkSize, known - offset) : chunkSize;
|
||||
const chunk = await readChunk(offset, want);
|
||||
if (chunk.byteLength > 0) {
|
||||
yield chunk;
|
||||
offset += chunk.byteLength;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Caught up. A complete body that came up short simply ended sooner than
|
||||
// the host said; one still arriving needs asking about.
|
||||
if (done || refresh == null) return;
|
||||
({ contentLength: known, complete: done } = await refresh());
|
||||
if (offset < known) continue;
|
||||
if (done) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
async function readAll(accessor: string, options?: ReadHttpResponseBodyOptions) {
|
||||
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
refuseIfTooBig(accessor, contentLength, maxBytes);
|
||||
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for await (const chunk of chunks(options)) {
|
||||
total += chunk.byteLength;
|
||||
// The size the host reported is a claim about a moment ago, so check the
|
||||
// bytes actually arriving too.
|
||||
refuseIfTooBig(accessor, total, maxBytes);
|
||||
parts.push(chunk);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
bytes.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
return {
|
||||
responseId,
|
||||
contentLength,
|
||||
contentType,
|
||||
complete,
|
||||
chunks,
|
||||
async arrayBuffer(options) {
|
||||
const bytes = await readAll("arrayBuffer", options);
|
||||
return bytes.buffer as ArrayBuffer;
|
||||
},
|
||||
async text(options) {
|
||||
return decodeBody(await readAll("text", options), contentType);
|
||||
},
|
||||
async json<T>(options?: ReadHttpResponseBodyOptions) {
|
||||
return JSON.parse(decodeBody(await readAll("json", options), contentType)) as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function refuseIfTooBig(accessor: string, bytes: number, maxBytes: number) {
|
||||
if (bytes <= maxBytes) return;
|
||||
throw new Error(
|
||||
`Response body is ${formatBytes(bytes)}, over the ${formatBytes(maxBytes)} limit for ` +
|
||||
`${accessor}(). Read it with chunks() instead, or pass a larger maxBytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode using the charset the response declared.
|
||||
*
|
||||
* Assuming UTF-8 mangles every response that isn't, and the header is right
|
||||
* there. An unknown label is the one case worth guessing on, since the
|
||||
* alternative is refusing to read a body we can very likely still read.
|
||||
*/
|
||||
function decodeBody(bytes: Uint8Array, contentType: string | null): string {
|
||||
const charset = parseCharset(contentType);
|
||||
if (charset != null) {
|
||||
try {
|
||||
return new TextDecoder(charset).decode(bytes);
|
||||
} catch {
|
||||
// Not a label this runtime knows.
|
||||
}
|
||||
}
|
||||
// TextDecoder drops a leading BOM on its own.
|
||||
return new TextDecoder("utf-8").decode(bytes);
|
||||
}
|
||||
|
||||
function parseCharset(contentType: string | null): string | null {
|
||||
const match = contentType?.match(/;\s*charset\s*=\s*"?([^";]+)"?/i);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === Infinity) return "unlimited";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a chunk that arrived as base64.
|
||||
*
|
||||
* The desktop transport is a WebSocket carrying JSON text frames, so bytes
|
||||
* have to be spelled out. A host that can pass an ArrayBuffer along skips this.
|
||||
*/
|
||||
export function decodeBase64Chunk(data: string): Uint8Array {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
const buf = Buffer.from(data, "base64");
|
||||
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
}
|
||||
|
||||
const binary = atob(data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
151
packages/plugin-runtime/tests/common.test.ts
Normal file
151
packages/plugin-runtime/tests/common.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { applyFormInputDefaults } from "@yaakapp-internal/lib/templateFunction";
|
||||
import type { CallTemplateFunctionArgs } from "@yaakapp-internal/plugins";
|
||||
import type { Context, DynamicTemplateFunctionArg } from "@yaakapp/api";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { applyDynamicFormInput } from "../src/common";
|
||||
|
||||
describe("applyFormInputDefaults", () => {
|
||||
test("Works with top-level select", () => {
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{
|
||||
type: "select",
|
||||
name: "test",
|
||||
options: [{ label: "Option 1", value: "one" }],
|
||||
defaultValue: "one",
|
||||
},
|
||||
];
|
||||
expect(applyFormInputDefaults(args, {})).toEqual({
|
||||
test: "one",
|
||||
});
|
||||
});
|
||||
|
||||
test("Works with existing value", () => {
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{
|
||||
type: "select",
|
||||
name: "test",
|
||||
options: [{ label: "Option 1", value: "one" }],
|
||||
defaultValue: "one",
|
||||
},
|
||||
];
|
||||
expect(applyFormInputDefaults(args, { test: "explicit" })).toEqual({
|
||||
test: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
test("Works with recursive select", () => {
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{ type: "text", name: "dummy", defaultValue: "top" },
|
||||
{
|
||||
type: "accordion",
|
||||
label: "Test",
|
||||
inputs: [
|
||||
{ type: "text", name: "name", defaultValue: "hello" },
|
||||
{
|
||||
type: "select",
|
||||
name: "test",
|
||||
options: [{ label: "Option 1", value: "one" }],
|
||||
defaultValue: "one",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(applyFormInputDefaults(args, {})).toEqual({
|
||||
dummy: "top",
|
||||
test: "one",
|
||||
name: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
test("Works with dynamic options", () => {
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{
|
||||
type: "select",
|
||||
name: "test",
|
||||
defaultValue: "one",
|
||||
options: [],
|
||||
dynamic() {
|
||||
return { options: [{ label: "Option 1", value: "one" }] };
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(applyFormInputDefaults(args, {})).toEqual({
|
||||
test: "one",
|
||||
});
|
||||
expect(applyFormInputDefaults(args, {})).toEqual({
|
||||
test: "one",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDynamicFormInput", () => {
|
||||
test("Works with plain input", async () => {
|
||||
const ctx = {} as Context;
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{ type: "text", name: "name" },
|
||||
{ type: "checkbox", name: "checked" },
|
||||
];
|
||||
const callArgs: CallTemplateFunctionArgs = {
|
||||
values: {},
|
||||
purpose: "preview",
|
||||
};
|
||||
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
|
||||
{ type: "text", name: "name" },
|
||||
{ type: "checkbox", name: "checked" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Works with dynamic input", async () => {
|
||||
const ctx = {} as Context;
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{
|
||||
type: "text",
|
||||
name: "name",
|
||||
async dynamic(_ctx, _args) {
|
||||
return { hidden: true };
|
||||
},
|
||||
},
|
||||
];
|
||||
const callArgs: CallTemplateFunctionArgs = {
|
||||
values: {},
|
||||
purpose: "preview",
|
||||
};
|
||||
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
|
||||
{ type: "text", name: "name", hidden: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Works with recursive dynamic input", async () => {
|
||||
const ctx = {} as Context;
|
||||
const callArgs: CallTemplateFunctionArgs = {
|
||||
values: { hello: "world" },
|
||||
purpose: "preview",
|
||||
};
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{
|
||||
type: "banner",
|
||||
inputs: [
|
||||
{
|
||||
type: "text",
|
||||
name: "name",
|
||||
async dynamic(_ctx, args) {
|
||||
return { hidden: args.values.hello === "world" };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
|
||||
{
|
||||
type: "banner",
|
||||
inputs: [
|
||||
{
|
||||
type: "text",
|
||||
name: "name",
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
203
packages/plugin-runtime/tests/responseBody.test.ts
Normal file
203
packages/plugin-runtime/tests/responseBody.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { createResponseBody, decodeBase64Chunk } from "../src/responseBody";
|
||||
|
||||
/** A finished body, in a store that records every window it was asked for. */
|
||||
function fakeBody(bytes: Uint8Array, contentType: string | null) {
|
||||
const reads: Array<[number, number]> = [];
|
||||
const body = createResponseBody(
|
||||
{ responseId: "rs_test", contentLength: bytes.byteLength, contentType, complete: true },
|
||||
async (offset, length) => {
|
||||
reads.push([offset, length]);
|
||||
return bytes.slice(offset, offset + length);
|
||||
},
|
||||
);
|
||||
return { body, reads };
|
||||
}
|
||||
|
||||
/**
|
||||
* A body still arriving: it grows by one script step every time the reader
|
||||
* asks the host where it has got to, and completes on the last step.
|
||||
*/
|
||||
function streamingBody(steps: string[], contentType = "text/plain") {
|
||||
let stored = new Uint8Array();
|
||||
let step = 0;
|
||||
let refreshes = 0;
|
||||
const advance = () => {
|
||||
if (step < steps.length) {
|
||||
const next = utf8(steps[step]!);
|
||||
const grown = new Uint8Array(stored.byteLength + next.byteLength);
|
||||
grown.set(stored);
|
||||
grown.set(next, stored.byteLength);
|
||||
stored = grown;
|
||||
step++;
|
||||
}
|
||||
return { contentLength: stored.byteLength, complete: step >= steps.length };
|
||||
};
|
||||
const body = createResponseBody(
|
||||
{ responseId: "rs_live", contentLength: 0, contentType, complete: false },
|
||||
async (offset, length) => stored.slice(offset, offset + length),
|
||||
{
|
||||
refresh: async () => {
|
||||
refreshes++;
|
||||
return advance();
|
||||
},
|
||||
pollIntervalMs: 1,
|
||||
},
|
||||
);
|
||||
return { body, refreshCount: () => refreshes };
|
||||
}
|
||||
|
||||
function utf8(text: string) {
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
|
||||
describe("response body", () => {
|
||||
test("pulls a body in chunks and reassembles it", async () => {
|
||||
const { body, reads } = fakeBody(utf8("abcdefghij"), "text/plain");
|
||||
|
||||
expect(await body.text({ chunkSize: 4 })).toEqual("abcdefghij");
|
||||
expect(reads).toEqual([
|
||||
[0, 4],
|
||||
[4, 4],
|
||||
[8, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
test("can be read more than once, unlike fetch", async () => {
|
||||
const { body } = fakeBody(utf8('{"a":1}'), "application/json");
|
||||
|
||||
expect(await body.text()).toEqual('{"a":1}');
|
||||
expect(await body.json()).toEqual({ a: 1 });
|
||||
expect(new Uint8Array(await body.arrayBuffer())).toEqual(utf8('{"a":1}'));
|
||||
});
|
||||
|
||||
test("decodes using the charset the response declared", async () => {
|
||||
// "café naïve" as Latin-1, which is mojibake if read as UTF-8.
|
||||
const latin1 = new Uint8Array([0x63, 0x61, 0x66, 0xe9, 0x20, 0x6e, 0x61, 0xef, 0x76, 0x65]);
|
||||
|
||||
const declared = fakeBody(latin1, "text/plain; charset=iso-8859-1");
|
||||
expect(await declared.body.text()).toEqual("café naïve");
|
||||
|
||||
const undeclared = fakeBody(latin1, "text/plain");
|
||||
expect(await undeclared.body.text()).not.toEqual("café naïve");
|
||||
});
|
||||
|
||||
test("falls back to UTF-8 for a charset the runtime doesn't know", async () => {
|
||||
const { body } = fakeBody(utf8("hello"), "text/plain; charset=not-a-real-charset");
|
||||
expect(await body.text()).toEqual("hello");
|
||||
});
|
||||
|
||||
test("drops a UTF-8 BOM", async () => {
|
||||
const withBom = new Uint8Array([0xef, 0xbb, 0xbf, ...utf8('{"a":1}')]);
|
||||
const { body } = fakeBody(withBom, "application/json");
|
||||
|
||||
expect(await body.text()).toEqual('{"a":1}');
|
||||
expect(await body.json()).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test("refuses to buffer past maxBytes, and says what to do instead", async () => {
|
||||
const { body } = fakeBody(utf8("x".repeat(100)), "text/plain");
|
||||
|
||||
await expect(body.text({ maxBytes: 50 })).rejects.toThrow(/chunks\(\)/);
|
||||
await expect(body.json({ maxBytes: 50 })).rejects.toThrow(/over the/);
|
||||
await expect(body.arrayBuffer({ maxBytes: 50 })).rejects.toThrow(/arrayBuffer\(\)/);
|
||||
|
||||
// The ceiling is the caller's to raise.
|
||||
expect(await body.text({ maxBytes: 100 })).toHaveLength(100);
|
||||
});
|
||||
|
||||
test("streams past maxBytes through chunks()", async () => {
|
||||
const { body } = fakeBody(utf8("x".repeat(100)), "text/plain");
|
||||
|
||||
let total = 0;
|
||||
for await (const chunk of body.chunks({ chunkSize: 10 })) {
|
||||
total += chunk.byteLength;
|
||||
}
|
||||
expect(total).toEqual(100);
|
||||
});
|
||||
|
||||
test("stops early when the host runs out of bytes sooner than it claimed", async () => {
|
||||
// contentLength says 100; the store only ever hands back 10.
|
||||
const body = createResponseBody(
|
||||
{ responseId: "rs_test", contentLength: 100, contentType: "text/plain", complete: true },
|
||||
async (offset) => (offset === 0 ? utf8("0123456789") : new Uint8Array()),
|
||||
);
|
||||
|
||||
expect(await body.text()).toEqual("0123456789");
|
||||
});
|
||||
|
||||
test("a response with no body reads as empty", async () => {
|
||||
const { body, reads } = fakeBody(new Uint8Array(), "application/json");
|
||||
|
||||
expect(body.contentLength).toEqual(0);
|
||||
expect(await body.text()).toEqual("");
|
||||
expect(reads).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps binary bytes intact", async () => {
|
||||
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]);
|
||||
const { body } = fakeBody(png, "image/png");
|
||||
|
||||
expect(new Uint8Array(await body.arrayBuffer({ chunkSize: 3 }))).toEqual(png);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a response still arriving", () => {
|
||||
test("chunks() follows it until it finishes", async () => {
|
||||
const { body, refreshCount } = streamingBody(["data: 1\n", "data: 2\n", "data: 3\n"]);
|
||||
expect(body.complete).toBe(false);
|
||||
|
||||
const seen: string[] = [];
|
||||
for await (const chunk of body.chunks()) {
|
||||
seen.push(new TextDecoder().decode(chunk));
|
||||
}
|
||||
|
||||
expect(seen.join("")).toEqual("data: 1\ndata: 2\ndata: 3\n");
|
||||
// Asked once per catch-up, and stopped as soon as the host said it was done.
|
||||
expect(refreshCount()).toEqual(3);
|
||||
});
|
||||
|
||||
test("text() waits for the rest rather than returning a prefix", async () => {
|
||||
const { body } = streamingBody(['{"token":', '"abc"}']);
|
||||
expect(await body.json()).toEqual({ token: "abc" });
|
||||
});
|
||||
|
||||
test("keeps waiting through a stretch with nothing new", async () => {
|
||||
// Two refreshes report no growth before the body finally moves.
|
||||
const { body } = streamingBody(["", "", "late"]);
|
||||
expect(await body.text()).toEqual("late");
|
||||
});
|
||||
|
||||
test("still refuses to buffer past maxBytes as it streams", async () => {
|
||||
const { body } = streamingBody(["x".repeat(40), "x".repeat(40), "x".repeat(40)]);
|
||||
await expect(body.text({ maxBytes: 100 })).rejects.toThrow(/chunks\(\)/);
|
||||
});
|
||||
|
||||
test("a finished body never asks the host again", async () => {
|
||||
let refreshes = 0;
|
||||
const body = createResponseBody(
|
||||
{ responseId: "rs_done", contentLength: 5, contentType: null, complete: true },
|
||||
async (offset, length) => utf8("hello").slice(offset, offset + length),
|
||||
{
|
||||
refresh: async () => {
|
||||
refreshes++;
|
||||
return { contentLength: 5, complete: true };
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(await body.text()).toEqual("hello");
|
||||
expect(refreshes).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeBase64Chunk", () => {
|
||||
test("round-trips arbitrary bytes", () => {
|
||||
const bytes = new Uint8Array([0, 1, 127, 128, 254, 255]);
|
||||
const base64 = Buffer.from(bytes).toString("base64");
|
||||
expect(decodeBase64Chunk(base64)).toEqual(bytes);
|
||||
});
|
||||
|
||||
test("decodes an empty chunk", () => {
|
||||
expect(decodeBase64Chunk("")).toEqual(new Uint8Array());
|
||||
});
|
||||
});
|
||||
65
packages/plugin-runtime/tests/sendHttpRequest.test.ts
Normal file
65
packages/plugin-runtime/tests/sendHttpRequest.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { InternalEvent } from "@yaakapp/api";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { EventChannel } from "../src/EventChannel";
|
||||
import { PluginInstance } from "../src/PluginInstance";
|
||||
|
||||
test("the runtime forwards per-send environments without changing plugin context", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "yaak-send-environment-"));
|
||||
await mkdir(path.join(dir, "build"));
|
||||
await writeFile(
|
||||
path.join(dir, "build/index.js"),
|
||||
`exports.plugin = { async init(ctx) {
|
||||
await Promise.all(["ev_a", "ev_b", undefined].map(environmentId =>
|
||||
ctx.httpRequest.send({ httpRequest: { id: "rq_test" }, environmentId })
|
||||
));
|
||||
} };`,
|
||||
);
|
||||
const channel = new EventChannel();
|
||||
const context = { workspaceId: "wk_test", label: "test-window" };
|
||||
const bootRequest = { dir, watch: false };
|
||||
const instance = new PluginInstance({ bootRequest, pluginRefId: "test", context }, channel);
|
||||
const sends: InternalEvent[] = [];
|
||||
const booted = new Promise<void>((resolve, reject) => {
|
||||
channel.listen((event) => {
|
||||
if (event.payload.type === "send_http_request_request") {
|
||||
// Match the JSON transport used by the real plugin host.
|
||||
sends.push(JSON.parse(JSON.stringify(event)) as InternalEvent);
|
||||
instance.postMessage({
|
||||
...event,
|
||||
id: `reply-${event.id}`,
|
||||
replyId: event.id,
|
||||
payload: { type: "error_response", error: "test host received send" },
|
||||
});
|
||||
} else if (event.payload.type === "error_response") {
|
||||
// The stub host rejects the sends after capturing them.
|
||||
if (event.payload.error === "test host received send") resolve();
|
||||
else reject(new Error(event.payload.error));
|
||||
} else if (event.payload.type === "boot_response") {
|
||||
reject(new Error("Expected the test host's send error"));
|
||||
}
|
||||
});
|
||||
});
|
||||
try {
|
||||
instance.postMessage({
|
||||
id: "boot",
|
||||
replyId: null,
|
||||
pluginRefId: "test",
|
||||
pluginName: "test",
|
||||
context,
|
||||
payload: { type: "boot_request", ...bootRequest },
|
||||
});
|
||||
await booted;
|
||||
expect(sends.map((event) => event.payload)).toEqual([
|
||||
{ type: "send_http_request_request", httpRequest: { id: "rq_test" }, environmentId: "ev_a" },
|
||||
{ type: "send_http_request_request", httpRequest: { id: "rq_test" }, environmentId: "ev_b" },
|
||||
{ type: "send_http_request_request", httpRequest: { id: "rq_test" } },
|
||||
]);
|
||||
expect(sends.map((event) => event.context)).toEqual([context, context, context]);
|
||||
} finally {
|
||||
await instance.terminate();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
17
packages/plugin-runtime/tsconfig.json
Normal file
17
packages/plugin-runtime/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "node16",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2021",
|
||||
"lib": ["es2021"],
|
||||
"noImplicitAny": false,
|
||||
"moduleResolution": "node16",
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "build"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user