refactor: harden extension runtime-api seams

This commit is contained in:
Peter Steinberger
2026-03-23 19:36:19 -07:00
parent d8e77c423a
commit a3f2fbf5a2
6 changed files with 333 additions and 222 deletions

View File

@@ -1,198 +1,222 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import { readFileSync } from "node:fs";
import path from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { loadRuntimeApiExportTypesViaJiti } from "../../test/helpers/extensions/jiti-runtime-api.ts";
function normalizeModuleSpecifier(specifier: string): string | null {
if (specifier.startsWith("./src/")) {
return specifier;
}
if (specifier.startsWith("../../extensions/line/src/")) {
return `./src/${specifier.slice("../../extensions/line/src/".length)}`;
}
return null;
}
function collectModuleExportNames(filePath: string): string[] {
const sourcePath = filePath.replace(/\.js$/, ".ts");
const sourceText = readFileSync(sourcePath, "utf8");
const sourceFile = ts.createSourceFile(sourcePath, sourceText, ts.ScriptTarget.Latest, true);
const names = new Set<string>();
for (const statement of sourceFile.statements) {
if (
ts.isExportDeclaration(statement) &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)
) {
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
names.add(element.name.text);
}
}
continue;
}
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
if (!isExported) {
continue;
}
if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
continue;
}
if (
ts.isFunctionDeclaration(statement) ||
ts.isClassDeclaration(statement) ||
ts.isEnumDeclaration(statement)
) {
if (statement.name) {
names.add(statement.name.text);
}
}
}
return Array.from(names).toSorted();
}
function collectRuntimeApiOverlapExports(params: {
lineRuntimePath: string;
runtimeApiPath: string;
}): string[] {
const runtimeApiSource = readFileSync(params.runtimeApiPath, "utf8");
const runtimeApiFile = ts.createSourceFile(
params.runtimeApiPath,
runtimeApiSource,
ts.ScriptTarget.Latest,
true,
);
const runtimeApiLocalModules = new Set<string>();
let pluginSdkLineRuntimeSeen = false;
for (const statement of runtimeApiFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (!moduleSpecifier) {
continue;
}
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
pluginSdkLineRuntimeSeen = true;
continue;
}
if (!pluginSdkLineRuntimeSeen) {
continue;
}
const normalized = normalizeModuleSpecifier(moduleSpecifier);
if (normalized) {
runtimeApiLocalModules.add(normalized);
}
}
const lineRuntimeSource = readFileSync(params.lineRuntimePath, "utf8");
const lineRuntimeFile = ts.createSourceFile(
params.lineRuntimePath,
lineRuntimeSource,
ts.ScriptTarget.Latest,
true,
);
const overlapExports = new Set<string>();
for (const statement of lineRuntimeFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
const normalized = moduleSpecifier ? normalizeModuleSpecifier(moduleSpecifier) : null;
if (!normalized || !runtimeApiLocalModules.has(normalized)) {
continue;
}
if (!statement.exportClause) {
for (const name of collectModuleExportNames(
path.join(process.cwd(), "extensions", "line", normalized),
)) {
overlapExports.add(name);
}
continue;
}
if (!ts.isNamedExports(statement.exportClause)) {
continue;
}
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
overlapExports.add(element.name.text);
}
}
}
return Array.from(overlapExports).toSorted();
}
function collectRuntimeApiPreExports(runtimeApiPath: string): string[] {
const runtimeApiSource = readFileSync(runtimeApiPath, "utf8");
const runtimeApiFile = ts.createSourceFile(
runtimeApiPath,
runtimeApiSource,
ts.ScriptTarget.Latest,
true,
);
const preExports = new Set<string>();
for (const statement of runtimeApiFile.statements) {
if (!ts.isExportDeclaration(statement)) {
continue;
}
const moduleSpecifier =
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
? statement.moduleSpecifier.text
: undefined;
if (!moduleSpecifier) {
continue;
}
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
break;
}
const normalized = normalizeModuleSpecifier(moduleSpecifier);
if (!normalized || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) {
continue;
}
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
preExports.add(element.name.text);
}
}
}
return Array.from(preExports).toSorted();
}
describe("line runtime api", () => {
it("loads through Jiti without duplicate export errors", () => {
const root = process.cwd();
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-line-jiti-"));
const runtimeApiPath = path.join(fixtureRoot, "runtime-api.ts");
const pluginSdkRoot = path.join(fixtureRoot, "plugin-sdk");
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
fs.mkdirSync(pluginSdkRoot, { recursive: true });
const writeFile = (relativePath: string, contents: string) => {
const filePath = path.join(fixtureRoot, relativePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, contents, "utf8");
return filePath;
};
const botAccessPath = writeFile(
"src/bot-access.js",
`export const firstDefined = (...values) => values.find((value) => value !== undefined);
export const isSenderAllowed = () => true;
export const normalizeAllowFrom = (value) => value;
export const normalizeDmAllowFromWithStore = (value) => value;
`,
);
const downloadPath = writeFile(
"src/download.js",
`export const downloadLineMedia = () => "downloaded";
`,
);
const probePath = writeFile(
"src/probe.js",
`export const probeLineBot = () => "probed";
`,
);
const templateMessagesPath = writeFile(
"src/template-messages.js",
`export const buildTemplateMessageFromPayload = () => ({ type: "template" });
`,
);
const sendPath = writeFile(
"src/send.js",
`export const createQuickReplyItems = () => [];
export const pushFlexMessage = () => "flex";
export const pushLocationMessage = () => "location";
export const pushMessageLine = () => "push";
export const pushMessagesLine = () => "pushMany";
export const pushTemplateMessage = () => "template";
export const pushTextMessageWithQuickReplies = () => "quick";
export const sendMessageLine = () => "send";
`,
);
const writePluginSdkShim = (subpath: string, contents: string) => {
writeFile(path.join("plugin-sdk", `${subpath}.ts`), contents);
};
writePluginSdkShim(
"core",
`export const clearAccountEntryFields = () => ({});
`,
);
writePluginSdkShim(
"channel-config-schema",
`export const buildChannelConfigSchema = () => ({});
`,
);
writePluginSdkShim(
"reply-runtime",
`export {};
`,
);
writePluginSdkShim(
"testing",
`export {};
`,
);
writePluginSdkShim(
"channel-contract",
`export {};
`,
);
writePluginSdkShim(
"setup",
`export const DEFAULT_ACCOUNT_ID = "default";
export const formatDocsLink = (href, fallback) => href ?? fallback;
export const setSetupChannelEnabled = () => {};
export const splitSetupEntries = (entries) => entries;
`,
);
writePluginSdkShim(
"status-helpers",
`export const buildComputedAccountStatusSnapshot = () => ({});
export const buildTokenChannelStatusSummary = () => "ok";
`,
);
writePluginSdkShim(
"line-runtime",
`export { firstDefined, isSenderAllowed, normalizeAllowFrom, normalizeDmAllowFromWithStore } from ${JSON.stringify(botAccessPath)};
export { downloadLineMedia } from ${JSON.stringify(downloadPath)};
export { probeLineBot } from ${JSON.stringify(probePath)};
export { buildTemplateMessageFromPayload } from ${JSON.stringify(templateMessagesPath)};
export {
createQuickReplyItems,
pushFlexMessage,
pushLocationMessage,
pushMessageLine,
pushMessagesLine,
pushTemplateMessage,
pushTextMessageWithQuickReplies,
sendMessageLine,
} from ${JSON.stringify(sendPath)};
`,
);
fs.writeFileSync(
runtimeApiPath,
`export { clearAccountEntryFields } from "openclaw/plugin-sdk/core";
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
export { buildComputedAccountStatusSnapshot, buildTokenChannelStatusSummary } from "openclaw/plugin-sdk/status-helpers";
export { DEFAULT_ACCOUNT_ID, formatDocsLink, setSetupChannelEnabled, splitSetupEntries } from "openclaw/plugin-sdk/setup";
export { firstDefined, isSenderAllowed, normalizeAllowFrom, normalizeDmAllowFromWithStore } from ${JSON.stringify(botAccessPath)};
export { downloadLineMedia } from ${JSON.stringify(downloadPath)};
export { probeLineBot } from ${JSON.stringify(probePath)};
export { buildTemplateMessageFromPayload } from ${JSON.stringify(templateMessagesPath)};
export {
createQuickReplyItems,
pushFlexMessage,
pushLocationMessage,
pushMessageLine,
pushMessagesLine,
pushTemplateMessage,
pushTextMessageWithQuickReplies,
sendMessageLine,
} from ${JSON.stringify(sendPath)};
export * from "openclaw/plugin-sdk/line-runtime";
`,
"utf8",
);
const script = `
import path from "node:path";
import { createJiti } from "jiti";
const root = ${JSON.stringify(root)};
const runtimeApiPath = ${JSON.stringify(runtimeApiPath)};
const pluginSdkRoot = ${JSON.stringify(pluginSdkRoot)};
const alias = Object.fromEntries([
"core",
"channel-config-schema",
"reply-runtime",
"testing",
"channel-contract",
"setup",
"status-helpers",
"line-runtime",
].map((name) => ["openclaw/plugin-sdk/" + name, path.join(pluginSdkRoot, name + ".ts")]));
const jiti = createJiti(path.join(root, "openclaw.mjs"), {
interopDefault: true,
tryNative: false,
fsCache: false,
moduleCache: false,
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
alias,
});
const mod = jiti(runtimeApiPath);
console.log(
JSON.stringify({
buildTemplateMessageFromPayload: typeof mod.buildTemplateMessageFromPayload,
downloadLineMedia: typeof mod.downloadLineMedia,
isSenderAllowed: typeof mod.isSenderAllowed,
probeLineBot: typeof mod.probeLineBot,
pushMessageLine: typeof mod.pushMessageLine,
}),
);
`;
try {
const raw = execFileSync(process.execPath, ["--input-type=module", "--eval", script], {
cwd: root,
encoding: "utf-8",
});
expect(JSON.parse(raw)).toEqual({
buildTemplateMessageFromPayload: "function",
downloadLineMedia: "function",
isSenderAllowed: "function",
probeLineBot: "function",
pushMessageLine: "function",
});
} finally {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
expect(
loadRuntimeApiExportTypesViaJiti({
modulePath: runtimeApiPath,
exportNames: [
"buildTemplateMessageFromPayload",
"downloadLineMedia",
"isSenderAllowed",
"probeLineBot",
"pushMessageLine",
],
}),
).toEqual({
buildTemplateMessageFromPayload: "function",
downloadLineMedia: "function",
isSenderAllowed: "function",
probeLineBot: "function",
pushMessageLine: "function",
});
}, 240_000);
it("keeps the LINE pre-export block aligned with plugin-sdk/line-runtime overlap", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
const lineRuntimePath = path.join(process.cwd(), "src", "plugin-sdk", "line-runtime.ts");
expect(collectRuntimeApiPreExports(runtimeApiPath)).toEqual(
collectRuntimeApiOverlapExports({
lineRuntimePath,
runtimeApiPath,
}),
);
});
});

View File

@@ -5,7 +5,7 @@ import {
normalizeAccountId,
resolveLineAccount,
type LineConfig,
} from "../runtime-api.js";
} from "./setup-runtime-api.js";
const channel = "line" as const;

View File

@@ -0,0 +1,9 @@
export {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
setSetupChannelEnabled,
splitSetupEntries,
} from "openclaw/plugin-sdk/setup";
export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
export { listLineAccountIds, normalizeAccountId, resolveLineAccount } from "./accounts.js";
export type { LineConfig } from "./types.js";

View File

@@ -3,6 +3,12 @@ import {
createStandardChannelSetupStatus,
createTopLevelChannelDmPolicy,
} from "openclaw/plugin-sdk/setup";
import {
isLineConfigured,
listLineAccountIds,
parseLineAllowFromId,
patchLineAccountConfig,
} from "./setup-core.js";
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
@@ -11,13 +17,7 @@ import {
splitSetupEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "../runtime-api.js";
import {
isLineConfigured,
listLineAccountIds,
parseLineAllowFromId,
patchLineAccountConfig,
} from "./setup-core.js";
} from "./setup-runtime-api.js";
const channel = "line" as const;

View File

@@ -1,10 +1,6 @@
import path from "node:path";
import { createJiti } from "jiti";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildPluginLoaderJitiOptions,
resolvePluginSdkScopedAliasMap,
} from "../../src/plugins/sdk-alias.ts";
import { loadRuntimeApiExportTypesViaJiti } from "../../test/helpers/extensions/jiti-runtime-api.ts";
const setMatrixRuntimeMock = vi.hoisted(() => vi.fn());
const registerChannelMock = vi.hoisted(() => vi.fn());
@@ -22,16 +18,17 @@ describe("matrix plugin registration", () => {
it("loads the matrix runtime api through Jiti", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "matrix", "runtime-api.ts");
const jiti = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(
resolvePluginSdkScopedAliasMap({ modulePath: runtimeApiPath }),
),
tryNative: false,
});
expect(jiti(runtimeApiPath)).toMatchObject({
requiresExplicitMatrixDefaultAccount: expect.any(Function),
resolveMatrixDefaultOrOnlyAccountId: expect.any(Function),
expect(
loadRuntimeApiExportTypesViaJiti({
modulePath: runtimeApiPath,
exportNames: [
"requiresExplicitMatrixDefaultAccount",
"resolveMatrixDefaultOrOnlyAccountId",
],
}),
).toEqual({
requiresExplicitMatrixDefaultAccount: "function",
resolveMatrixDefaultOrOnlyAccountId: "function",
});
}, 240_000);
@@ -43,15 +40,13 @@ describe("matrix plugin registration", () => {
"src",
"runtime-api.ts",
);
const jiti = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(
resolvePluginSdkScopedAliasMap({ modulePath: runtimeApiPath }),
),
tryNative: false,
});
expect(jiti(runtimeApiPath)).toMatchObject({
resolveMatrixAccountStringValues: expect.any(Function),
expect(
loadRuntimeApiExportTypesViaJiti({
modulePath: runtimeApiPath,
exportNames: ["resolveMatrixAccountStringValues"],
}),
).toEqual({
resolveMatrixAccountStringValues: "function",
});
}, 240_000);

View File

@@ -0,0 +1,83 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
const JITI_EXTENSIONS = [
".ts",
".tsx",
".mts",
".cts",
".mtsx",
".ctsx",
".js",
".mjs",
".cjs",
".json",
] as const;
const PLUGIN_SDK_SPECIFIER_PREFIX = "openclaw/plugin-sdk/";
function collectPluginSdkDistAliases(params: {
modulePath: string;
root: string;
}): Record<string, string> {
const sourceText = readFileSync(params.modulePath, "utf8");
const specifiers = new Set<string>();
for (const match of sourceText.matchAll(/["'](openclaw\/plugin-sdk(?:\/[^"']+)?)["']/g)) {
const specifier = match[1];
if (!specifier?.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX)) {
continue;
}
specifiers.add(specifier);
}
return Object.fromEntries(
Array.from(specifiers, (specifier) => {
const subpath = specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length);
return [specifier, path.join(params.root, "dist", "plugin-sdk", `${subpath}.js`)];
}),
);
}
export function loadRuntimeApiExportTypesViaJiti(params: {
modulePath: string;
exportNames: readonly string[];
additionalAliases?: Record<string, string>;
}): Record<string, string> {
const root = process.cwd();
const alias = {
...collectPluginSdkDistAliases({ modulePath: params.modulePath, root }),
...params.additionalAliases,
};
const script = `
import path from "node:path";
import { createJiti } from "jiti";
const modulePath = ${JSON.stringify(params.modulePath)};
const exportNames = ${JSON.stringify(params.exportNames)};
const alias = ${JSON.stringify(alias)};
const jiti = createJiti(path.join(${JSON.stringify(root)}, "openclaw.mjs"), {
interopDefault: true,
tryNative: false,
fsCache: false,
moduleCache: false,
extensions: ${JSON.stringify(JITI_EXTENSIONS)},
alias,
});
const mod = jiti(modulePath);
console.log(
JSON.stringify(
Object.fromEntries(exportNames.map((name) => [name, typeof mod[name]])),
),
);
`;
const raw = execFileSync(process.execPath, ["--input-type=module", "--eval", script], {
cwd: root,
encoding: "utf-8",
});
return JSON.parse(raw) as Record<string, string>;
}