fix(plugins): install local plugins on packaged desktop
The plugin compiler spawned esbuild through app.asar, and the subprocess imported the React client barrel. Point esbuild at the unpacked binary, add a react-free @paseo/plugin/server entry, and pass a typed PluginTheme so plugin UI follows the active theme on desktop and mobile.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
compilePlugin,
|
||||
resolveExistingAsarUnpackedEsbuildBinary,
|
||||
unpackedEsbuildBinaryFromPackageDir,
|
||||
} from "./compiler.js";
|
||||
|
||||
const asarEsbuildDir = path.join("Resources", "app.asar", "node_modules", "esbuild");
|
||||
|
||||
describe("asar esbuild binary resolution", () => {
|
||||
it("rewrites an asar package path to the unpacked platform binary", () => {
|
||||
expect(unpackedEsbuildBinaryFromPackageDir(asarEsbuildDir, "darwin", "arm64")).toBe(
|
||||
path.join(
|
||||
"Resources",
|
||||
"app.asar.unpacked",
|
||||
"node_modules",
|
||||
"@esbuild",
|
||||
"darwin-arm64",
|
||||
"bin",
|
||||
"esbuild",
|
||||
),
|
||||
);
|
||||
expect(unpackedEsbuildBinaryFromPackageDir(asarEsbuildDir, "win32", "x64")).toBe(
|
||||
path.join(
|
||||
"Resources",
|
||||
"app.asar.unpacked",
|
||||
"node_modules",
|
||||
"@esbuild",
|
||||
"win32-x64",
|
||||
"esbuild.exe",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores package paths that are not inside app.asar", () => {
|
||||
expect(
|
||||
unpackedEsbuildBinaryFromPackageDir(path.join("node_modules", "esbuild"), "darwin", "arm64"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the unpacked binary is missing", () => {
|
||||
expect(
|
||||
resolveExistingAsarUnpackedEsbuildBinary(asarEsbuildDir, "darwin", "arm64", () => false),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the unpacked path when the binary exists", () => {
|
||||
expect(
|
||||
resolveExistingAsarUnpackedEsbuildBinary(asarEsbuildDir, "linux", "x64", () => true),
|
||||
).toBe(
|
||||
path.join(
|
||||
"Resources",
|
||||
"app.asar.unpacked",
|
||||
"node_modules",
|
||||
"@esbuild",
|
||||
"linux-x64",
|
||||
"bin",
|
||||
"esbuild",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("plugin author module externals", () => {
|
||||
it("leaves @paseo/plugin/server external in both bundles", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const entryPath = path.join(directory, "index.ts");
|
||||
await writeFile(
|
||||
entryPath,
|
||||
`import type { PluginContext } from "@paseo/plugin";
|
||||
import { defineRpc } from "@paseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const ping = defineRpc({
|
||||
name: "ping",
|
||||
input: z.object({}),
|
||||
output: z.object({ ok: z.boolean() }),
|
||||
});
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(ping, async () => ({ ok: true }));
|
||||
return () => undefined;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
|
||||
expect(clientBundle).toContain("@paseo/plugin/server");
|
||||
expect(serverBundle).toContain("@paseo/plugin/server");
|
||||
expect(clientBundle).not.toContain("Invalid plugin RPC method");
|
||||
expect(serverBundle).not.toContain("Invalid plugin RPC method");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,57 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { parse } from "@babel/parser";
|
||||
import { build, type Plugin } from "esbuild";
|
||||
import type { Plugin } from "esbuild";
|
||||
|
||||
// esbuild resolves its own platform binary via require.resolve() the first time its
|
||||
// module is evaluated. Inside the packaged desktop app that resolves to a path under
|
||||
// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked.
|
||||
// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path
|
||||
// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads.
|
||||
export function unpackedEsbuildBinaryFromPackageDir(
|
||||
esbuildPackageDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
arch: string,
|
||||
): string | null {
|
||||
const asarSegment = `${path.sep}app.asar${path.sep}`;
|
||||
const asarIndex = esbuildPackageDir.indexOf(asarSegment);
|
||||
if (asarIndex === -1) return null;
|
||||
return path.join(
|
||||
esbuildPackageDir.slice(0, asarIndex),
|
||||
"app.asar.unpacked",
|
||||
"node_modules",
|
||||
`@esbuild/${platform}-${arch}`,
|
||||
...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveExistingAsarUnpackedEsbuildBinary(
|
||||
esbuildPackageDir: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
exists: (file: string) => boolean = existsSync,
|
||||
): string | null {
|
||||
const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch);
|
||||
return binaryPath && exists(binaryPath) ? binaryPath : null;
|
||||
}
|
||||
|
||||
function resolveAsarUnpackedEsbuildBinary(): string | null {
|
||||
const require = createRequire(import.meta.url);
|
||||
let esbuildDir: string;
|
||||
try {
|
||||
esbuildDir = path.dirname(require.resolve("esbuild/package.json"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
|
||||
}
|
||||
|
||||
if (!process.env.ESBUILD_BINARY_PATH) {
|
||||
const unpackedBinary = resolveAsarUnpackedEsbuildBinary();
|
||||
if (unpackedBinary) process.env.ESBUILD_BINARY_PATH = unpackedBinary;
|
||||
}
|
||||
|
||||
type PluginBuildTarget = "client" | "server";
|
||||
|
||||
@@ -228,6 +278,7 @@ function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
|
||||
}
|
||||
|
||||
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
|
||||
const { build } = await import("esbuild");
|
||||
const source = await readFile(entryPath, "utf8");
|
||||
const filteredSource = filterEntrypoint(source, target);
|
||||
const result = await build({
|
||||
@@ -245,13 +296,14 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
|
||||
target === "client"
|
||||
? [
|
||||
"@paseo/plugin",
|
||||
"@paseo/plugin/server",
|
||||
"@tanstack/react-query",
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"react-native",
|
||||
"zod",
|
||||
]
|
||||
: ["@paseo/plugin", "zod"],
|
||||
: ["@paseo/plugin", "@paseo/plugin/server", "zod"],
|
||||
plugins: [createRuntimeBoundaryPlugin(target), createUnusedPlatformModulePlugin(target)],
|
||||
logLevel: "silent",
|
||||
treeShaking: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
defineRpc,
|
||||
type PluginHandlerContext,
|
||||
type PluginRpcContract,
|
||||
} from "@paseo/plugin";
|
||||
} from "@paseo/plugin/server";
|
||||
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { createPluginDaemonTransportFactory } from "./daemon-transport.js";
|
||||
@@ -61,8 +61,10 @@ function register(contract: PluginRpcContract, handler: RpcHandler): void {
|
||||
handlers.set(method, { contract: { ...contract, name: method }, handler });
|
||||
}
|
||||
|
||||
const pluginAuthorRuntime = { defineAttachmentSource, defineRpc };
|
||||
|
||||
function runtimeRequire(name: string): unknown {
|
||||
if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc };
|
||||
if (name === "@paseo/plugin" || name === "@paseo/plugin/server") return pluginAuthorRuntime;
|
||||
return nodeRequire(name);
|
||||
}
|
||||
|
||||
|
||||
@@ -597,7 +597,7 @@ export function Surface() {
|
||||
),
|
||||
writeFile(
|
||||
path.join(directory, "inspect.shared.ts"),
|
||||
`import { defineRpc } from "@paseo/plugin";
|
||||
`import { defineRpc } from "@paseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
export const inspectRpc = defineRpc({
|
||||
|
||||
Reference in New Issue
Block a user