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:
@@ -162,6 +162,45 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toThrow("must return a cleanup function");
|
||||
});
|
||||
|
||||
it("resolves @paseo/plugin/server for shared RPC contracts", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
`(function(require) {
|
||||
const { defineRpc, defineAttachmentSource } = require("@paseo/plugin/server");
|
||||
const search = defineRpc({ name: "issues.search", input: {}, output: {} });
|
||||
const module = { exports: {} };
|
||||
module.exports.default = function(plugin) {
|
||||
plugin.addAttachmentSource(defineAttachmentSource({
|
||||
id: "issues",
|
||||
title: "Issue",
|
||||
icon: "CircleDot",
|
||||
pickerTitle: "Attach issue",
|
||||
searchPlaceholder: "Search",
|
||||
search,
|
||||
}));
|
||||
return function() {};
|
||||
};
|
||||
return module.exports;
|
||||
})`,
|
||||
);
|
||||
|
||||
expect(plugin.attachmentSources.map((source) => source.search.name)).toEqual(["issues.search"]);
|
||||
});
|
||||
|
||||
it("rejects modules that are not part of the client runtime", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"example",
|
||||
`(function(require) {
|
||||
require("fs");
|
||||
const module = { exports: {} };
|
||||
module.exports.default = function() { return function() {}; };
|
||||
return module.exports;
|
||||
})`,
|
||||
),
|
||||
).toThrow('Module "fs" is not available in plugin client code');
|
||||
});
|
||||
|
||||
it("does not publish partial contributions when setup fails", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
|
||||
@@ -159,6 +159,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
useRpc,
|
||||
};
|
||||
}
|
||||
if (name === "@paseo/plugin/server") {
|
||||
return { defineAttachmentSource, defineRpc };
|
||||
}
|
||||
if (name === "@tanstack/react-query") return ReactQuery;
|
||||
if (name === "zod") return Zod;
|
||||
throw new Error(`Module "${name}" is not available in plugin client code`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import type { PluginSurfaceProps } from "@paseo/plugin";
|
||||
import type { PluginSurfaceProps, PluginTheme } from "@paseo/plugin";
|
||||
import { ChevronDown, X } from "lucide-react-native";
|
||||
import { useCallback, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
import { Platform, Pressable, Text, View } from "react-native";
|
||||
@@ -14,6 +14,7 @@ import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { toPluginTheme } from "./theme";
|
||||
import { useInstalledPlugin, usePluginInstallations } from "./registry";
|
||||
import { buildPluginSurfaceRoute } from "./routes";
|
||||
import { rememberPluginContributionHost } from "./sidebar-groups";
|
||||
@@ -27,10 +28,9 @@ import {
|
||||
} from "./surface-contribution";
|
||||
|
||||
const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = [];
|
||||
const EMPTY_THEME_DTO: Record<string, unknown> = {};
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const pluginThemeMapping = (theme: Theme) => ({
|
||||
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>,
|
||||
theme: toPluginTheme(theme),
|
||||
});
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
@@ -57,18 +57,18 @@ function SurfaceRenderer({
|
||||
plugin,
|
||||
layout,
|
||||
host,
|
||||
themeDto = EMPTY_THEME_DTO,
|
||||
theme,
|
||||
}: {
|
||||
Surface: ComponentType<PluginSurfaceProps>;
|
||||
runtime: NonNullable<ReturnType<typeof createPluginSurfaceRuntime>>;
|
||||
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
|
||||
layout: PluginSurfaceProps["layout"];
|
||||
host: PluginSurfaceProps["host"];
|
||||
themeDto?: Record<string, unknown>;
|
||||
theme: PluginTheme;
|
||||
}) {
|
||||
return (
|
||||
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
|
||||
<Surface theme={themeDto} host={host} layout={layout} />
|
||||
<Surface theme={theme} host={host} layout={layout} />
|
||||
</PluginRuntimeBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { lightTheme } from "@/styles/theme";
|
||||
import { toPluginTheme } from "./theme";
|
||||
|
||||
describe("toPluginTheme", () => {
|
||||
it("maps the app theme into the plugin color tokens", () => {
|
||||
expect(toPluginTheme(lightTheme)).toEqual({
|
||||
colors: {
|
||||
surface0: lightTheme.colors.surface0,
|
||||
foreground: lightTheme.colors.foreground,
|
||||
foregroundMuted: lightTheme.colors.foregroundMuted,
|
||||
accent: lightTheme.colors.accent,
|
||||
accentForeground: lightTheme.colors.accentForeground,
|
||||
statusDanger: lightTheme.colors.statusDanger,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PluginTheme } from "@paseo/plugin";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
export function toPluginTheme(theme: Theme): PluginTheme {
|
||||
return {
|
||||
colors: {
|
||||
surface0: theme.colors.surface0,
|
||||
foreground: theme.colors.foreground,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
accent: theme.colors.accent,
|
||||
accentForeground: theme.colors.accentForeground,
|
||||
statusDanger: theme.colors.statusDanger,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
PluginAgentPanelProps,
|
||||
PluginHostProps,
|
||||
PluginTheme,
|
||||
PluginWorkspacePanelProps,
|
||||
} from "@paseo/plugin";
|
||||
import { PluginClientStateProvider } from "@paseo/plugin/host";
|
||||
@@ -18,6 +19,7 @@ import { useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { useInstalledPlugin } from "../registry";
|
||||
import { PluginRuntimeBoundary } from "../runtime-boundary";
|
||||
@@ -25,9 +27,8 @@ import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { resolvePluginWorkspacePanel } from "./resolution";
|
||||
|
||||
const EMPTY_THEME_DTO: Record<string, unknown> = {};
|
||||
const pluginThemeMapping = (theme: Theme) => ({
|
||||
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>,
|
||||
theme: toPluginTheme(theme),
|
||||
});
|
||||
|
||||
function resolvePlatform(): PluginHostProps["layout"]["platform"] {
|
||||
@@ -36,7 +37,7 @@ function resolvePlatform(): PluginHostProps["layout"]["platform"] {
|
||||
return "web";
|
||||
}
|
||||
|
||||
function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<string, unknown> }) {
|
||||
function PluginPanelBody({ theme }: { theme: PluginTheme }) {
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
invariant(target.kind === "plugin", "PluginPanel requires plugin target");
|
||||
const plugin = useInstalledPlugin(serverId, target.pluginId);
|
||||
@@ -75,7 +76,7 @@ function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<str
|
||||
if (contribution.context === "workspace") {
|
||||
const props: PluginWorkspacePanelProps = {
|
||||
context: "workspace",
|
||||
theme: themeDto,
|
||||
theme,
|
||||
host,
|
||||
layout,
|
||||
workspaceId,
|
||||
@@ -86,7 +87,7 @@ function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<str
|
||||
} else if (agentExists && target.context === "agent") {
|
||||
const props: PluginAgentPanelProps = {
|
||||
context: "agent",
|
||||
theme: themeDto,
|
||||
theme,
|
||||
host,
|
||||
layout,
|
||||
workspaceId,
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("plugin scaffold", () => {
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(directory, "inspect.shared.ts"),
|
||||
`import { defineRpc } from "@paseo/plugin";
|
||||
`import { defineRpc } from "@paseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
export const inspect = defineRpc({
|
||||
@@ -62,7 +62,7 @@ export const inspect = defineRpc({
|
||||
),
|
||||
writeFile(
|
||||
path.join(directory, "inspect.server.ts"),
|
||||
`import type { PluginHandlerContext } from "@paseo/plugin";
|
||||
`import type { PluginHandlerContext } from "@paseo/plugin/server";
|
||||
import type { output as ZodOutput } from "zod";
|
||||
import { inspect } from "./inspect.shared";
|
||||
|
||||
|
||||
@@ -2,13 +2,95 @@ import { mkdir, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { PluginIdSchema } from "@getpaseo/protocol/messages";
|
||||
|
||||
const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
|
||||
import type { ComponentType } from "react";
|
||||
const SDK_DECLARATIONS = `declare module "@paseo/plugin/server" {
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
|
||||
export interface PluginRpcContract<
|
||||
InputSchema extends ZodType = ZodType,
|
||||
OutputSchema extends ZodType = ZodType,
|
||||
> {
|
||||
name: string;
|
||||
input: InputSchema;
|
||||
output: OutputSchema;
|
||||
}
|
||||
|
||||
export interface PluginAttachmentItem {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
url: string;
|
||||
text: string;
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
export interface PluginAttachmentSearchPayload {
|
||||
items: PluginAttachmentItem[];
|
||||
}
|
||||
|
||||
export interface PluginAttachmentSourceContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pickerTitle: string;
|
||||
searchPlaceholder: string;
|
||||
search: PluginRpcContract;
|
||||
}
|
||||
|
||||
export interface PluginHandlerContext {
|
||||
paseo: PaseoApi;
|
||||
}
|
||||
|
||||
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(definition: {
|
||||
name: string;
|
||||
input: InputSchema;
|
||||
output: OutputSchema;
|
||||
}): PluginRpcContract<InputSchema, OutputSchema>;
|
||||
|
||||
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
|
||||
definition: Definition,
|
||||
): Definition;
|
||||
|
||||
export const PluginAttachmentItemSchema: import("zod").ZodType<PluginAttachmentItem>;
|
||||
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
|
||||
}
|
||||
|
||||
declare module "@paseo/plugin" {
|
||||
import type { ComponentType } from "react";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
import type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginHandlerContext,
|
||||
PluginRpcContract,
|
||||
} from "@paseo/plugin/server";
|
||||
|
||||
export {
|
||||
PluginAttachmentItemSchema,
|
||||
PluginAttachmentSearchPayloadSchema,
|
||||
defineAttachmentSource,
|
||||
defineRpc,
|
||||
type PluginAttachmentItem,
|
||||
type PluginAttachmentSearchPayload,
|
||||
type PluginAttachmentSourceContribution,
|
||||
type PluginHandlerContext,
|
||||
type PluginRpcContract,
|
||||
} from "@paseo/plugin/server";
|
||||
|
||||
export interface PluginTheme {
|
||||
readonly colors: {
|
||||
readonly surface0: string;
|
||||
readonly foreground: string;
|
||||
readonly foregroundMuted: string;
|
||||
readonly accent: string;
|
||||
readonly accentForeground: string;
|
||||
readonly statusDanger: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginHostProps {
|
||||
theme: Record<string, unknown>;
|
||||
theme: PluginTheme;
|
||||
host: { id: string; label: string };
|
||||
layout: { compact: boolean; platform: "ios" | "android" | "web" };
|
||||
}
|
||||
@@ -65,38 +147,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
|
||||
| { id: string; title: string; icon: string; context: "workspace"; Component: ComponentType<PluginWorkspacePanelProps> }
|
||||
| { id: string; title: string; icon: string; context: "agent"; Component: ComponentType<PluginAgentPanelProps> };
|
||||
|
||||
export interface PluginRpcContract<
|
||||
InputSchema extends ZodType = ZodType,
|
||||
OutputSchema extends ZodType = ZodType,
|
||||
> {
|
||||
name: string;
|
||||
input: InputSchema;
|
||||
output: OutputSchema;
|
||||
}
|
||||
|
||||
export interface PluginAttachmentItem {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
url: string;
|
||||
text: string;
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
export interface PluginAttachmentSearchPayload {
|
||||
items: PluginAttachmentItem[];
|
||||
}
|
||||
|
||||
export interface PluginAttachmentSourceContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pickerTitle: string;
|
||||
searchPlaceholder: string;
|
||||
search: PluginRpcContract;
|
||||
}
|
||||
|
||||
export interface PluginSidebarContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -140,10 +190,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
|
||||
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise<void> }
|
||||
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise<void> };
|
||||
|
||||
export interface PluginHandlerContext {
|
||||
paseo: PaseoApi;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
@@ -162,12 +208,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
|
||||
export type PluginCleanup = () => void | Promise<void>;
|
||||
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
|
||||
|
||||
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(definition: {
|
||||
name: string;
|
||||
input: InputSchema;
|
||||
output: OutputSchema;
|
||||
}): PluginRpcContract<InputSchema, OutputSchema>;
|
||||
|
||||
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>>;
|
||||
@@ -183,13 +223,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
|
||||
agentId: string,
|
||||
selector: (agent: PluginAgentSnapshot) => Selection,
|
||||
): Selection | null;
|
||||
|
||||
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
|
||||
definition: Definition,
|
||||
): Definition;
|
||||
|
||||
export const PluginAttachmentItemSchema: import("zod").ZodType<PluginAttachmentItem>;
|
||||
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -218,11 +251,27 @@ export default function contribute(plugin: PluginContext) {
|
||||
}
|
||||
`;
|
||||
|
||||
const CLIENT_SURFACE = `import React from "react";
|
||||
import { Text } from "react-native";
|
||||
const CLIENT_SURFACE = `import type { PluginSurfaceProps } from "@paseo/plugin";
|
||||
import React, { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
export function MainSurface() {
|
||||
return <Text>Hello from my plugin</Text>;
|
||||
export function MainSurface({ theme, layout }: PluginSurfaceProps) {
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
screen: {
|
||||
flex: 1,
|
||||
padding: layout.compact ? 16 : 24,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
text: { color: theme.colors.foreground },
|
||||
}),
|
||||
[theme, layout.compact],
|
||||
);
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<Text style={styles.text}>Hello from my plugin</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
"source": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server.d.ts",
|
||||
"source": "./src/server.ts",
|
||||
"default": "./dist/server.js"
|
||||
},
|
||||
"./host": {
|
||||
"types": "./dist/host.d.ts",
|
||||
"source": "./src/host.ts",
|
||||
|
||||
@@ -3,8 +3,19 @@ import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
import type { PluginRpcContract } from "./rpc.js";
|
||||
|
||||
export interface PluginTheme {
|
||||
readonly colors: {
|
||||
readonly surface0: string;
|
||||
readonly foreground: string;
|
||||
readonly foregroundMuted: string;
|
||||
readonly accent: string;
|
||||
readonly accentForeground: string;
|
||||
readonly statusDanger: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginHostProps {
|
||||
theme: Record<string, unknown>;
|
||||
theme: PluginTheme;
|
||||
host: {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
export {
|
||||
PluginAttachmentItemSchema,
|
||||
PluginAttachmentSearchPayloadSchema,
|
||||
defineAttachmentSource,
|
||||
defineRpc,
|
||||
type PluginAttachmentItem,
|
||||
type PluginAttachmentSearchPayload,
|
||||
} from "./attachments.js";
|
||||
type PluginRpcContract,
|
||||
} from "./server.js";
|
||||
export type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginAgentCommandContext,
|
||||
@@ -17,6 +20,7 @@ export type {
|
||||
PluginGlobalCommandContext,
|
||||
PluginHandlerContext,
|
||||
PluginHostProps,
|
||||
PluginTheme,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
PluginSurfaceProps,
|
||||
@@ -27,12 +31,4 @@ export type {
|
||||
} from "./contracts.js";
|
||||
export { usePaseo } from "./paseo-context.js";
|
||||
export { useAgent, useWorkspace } from "./client-state.js";
|
||||
import type { PluginAttachmentSourceContribution } from "./contracts.js";
|
||||
export { useRpc } from "./rpc-context.js";
|
||||
export { defineRpc, type PluginRpcContract } from "./rpc.js";
|
||||
|
||||
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
|
||||
definition: Definition,
|
||||
): Definition {
|
||||
return definition;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const srcDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const forbiddenSpecifiers = new Set([
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"react-native",
|
||||
"use-sync-external-store",
|
||||
"use-sync-external-store/shim/with-selector",
|
||||
]);
|
||||
|
||||
function localFile(fromFile: string, specifier: string): string {
|
||||
const withoutJs = specifier.replace(/\.js$/, "");
|
||||
for (const candidate of [`${withoutJs}.ts`, `${withoutJs}.tsx`, withoutJs]) {
|
||||
const resolved = path.resolve(path.dirname(fromFile), candidate);
|
||||
if (existsSync(resolved)) return resolved;
|
||||
}
|
||||
throw new Error(`Could not resolve ${specifier} from ${fromFile}`);
|
||||
}
|
||||
|
||||
function valueImportSpecifiers(source: string): string[] {
|
||||
const specifiers: string[] = [];
|
||||
const pattern = /(?:^|\n)(?:export|import)\s+(?!type\b)(?:[\s\S]*?)from\s+["']([^"']+)["']/g;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
if (match[1]) specifiers.push(match[1]);
|
||||
}
|
||||
return specifiers;
|
||||
}
|
||||
|
||||
function collectBareSpecifiers(entry: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const bare: string[] = [];
|
||||
const queue = [entry];
|
||||
while (queue.length > 0) {
|
||||
const file = queue.pop();
|
||||
if (!file || seen.has(file)) continue;
|
||||
seen.add(file);
|
||||
for (const specifier of valueImportSpecifiers(readFileSync(file, "utf8"))) {
|
||||
if (specifier.startsWith(".")) {
|
||||
queue.push(localFile(file, specifier));
|
||||
continue;
|
||||
}
|
||||
bare.push(specifier);
|
||||
}
|
||||
}
|
||||
return bare;
|
||||
}
|
||||
|
||||
describe("@paseo/plugin/server", () => {
|
||||
it("does not load react or the client hook graph", () => {
|
||||
const specifiers = collectBareSpecifiers(path.join(srcDir, "server.ts"));
|
||||
expect(specifiers.filter((specifier) => forbiddenSpecifiers.has(specifier))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PluginAttachmentSourceContribution } from "./contracts.js";
|
||||
|
||||
export {
|
||||
PluginAttachmentItemSchema,
|
||||
PluginAttachmentSearchPayloadSchema,
|
||||
type PluginAttachmentItem,
|
||||
type PluginAttachmentSearchPayload,
|
||||
} from "./attachments.js";
|
||||
export type { PluginAttachmentSourceContribution, PluginHandlerContext } from "./contracts.js";
|
||||
export { defineRpc, type PluginRpcContract } from "./rpc.js";
|
||||
|
||||
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
|
||||
definition: Definition,
|
||||
): Definition {
|
||||
return definition;
|
||||
}
|
||||
@@ -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