Skip to content

Commit 6498c0f

Browse files
sanchitrkclaude
andcommitted
feat(harness): share effect with plugins from a bundled host
A host bundled into one file, like the standalone binary, has no effect on disk for plugins to resolve to. It now publishes its modules through Plugin.share on globalThis[Symbol.for("codework.v1.effect")] (and codework.v1.plugin), and the plugin loader serves them as virtual modules that load each one on first import. Unbundled hosts resolve as before. scripts/shared.mjs generates that registry, with every public module's export names, for the CLI bundle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9830006 commit 6498c0f

3 files changed

Lines changed: 132 additions & 8 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Generates the module a bundled CLI passes to the harness's `Plugin.share`: every public module
2+
// of the packages plugins share with it, with its export names and a lazy loader.
3+
//
4+
// node --conditions=development scripts/shared.mjs <outfile>
5+
//
6+
// `development` resolves `@codeworksh/plugin` to source, as the bundle does.
7+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
8+
import { createRequire } from "node:module";
9+
import { dirname, join } from "node:path";
10+
11+
const packages = ["effect", "@codeworksh/plugin"];
12+
// The harness owns the sharing, so its dependencies are the copies to describe.
13+
const require = createRequire(new URL("../../harness/package.json", import.meta.url));
14+
15+
/** Every subpath the package exports, with `*` patterns expanded against its files. */
16+
function specifiers(name) {
17+
const manifest = require.resolve(`${name}/package.json`);
18+
const { exports } = JSON.parse(readFileSync(manifest, "utf8"));
19+
const result = [];
20+
for (const [key, target] of Object.entries(exports)) {
21+
if (key === "./package.json" || target === null) continue;
22+
if (!key.includes("*")) {
23+
result.push(name + key.slice(1));
24+
continue;
25+
}
26+
const pattern = typeof target === "string" ? target : (target.import ?? target.default);
27+
const [prefix, suffix] = pattern.split("*");
28+
const directory = join(dirname(manifest), prefix);
29+
for (const file of readdirSync(directory, { recursive: true })) {
30+
if (file.endsWith(suffix)) result.push(name + key.slice(1).replace("*", file.slice(0, -suffix.length)));
31+
}
32+
}
33+
return [...new Set(result)].sort();
34+
}
35+
36+
async function describe(name) {
37+
const entries = [];
38+
for (const specifier of specifiers(name)) {
39+
let namespace;
40+
try {
41+
namespace = await import(require.resolve(specifier));
42+
} catch (error) {
43+
// A `*` expansion also reaches files a more specific `null` export hides.
44+
if (error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") continue;
45+
throw error;
46+
}
47+
const names = JSON.stringify(Object.keys(namespace).sort());
48+
entries.push(
49+
`\t\t${JSON.stringify(specifier)}: { names: ${names}, load: () => import(${JSON.stringify(specifier)}) },`,
50+
);
51+
}
52+
return `\t${JSON.stringify(name)}: {\n${entries.join("\n")}\n\t},`;
53+
}
54+
55+
const [outfile] = process.argv.slice(2);
56+
if (outfile === undefined) {
57+
console.error("usage: node --conditions=development scripts/shared.mjs <outfile>");
58+
process.exit(1);
59+
}
60+
const body = await Promise.all(packages.map(describe));
61+
writeFileSync(outfile, `// Generated by scripts/shared.mjs.\nexport default {\n${body.join("\n")}\n};\n`);

‎packages/harness/src/plugin/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ export {
2222
type Updated,
2323
} from "./store.ts";
2424
export * as Tool from "./tool/schema.ts";
25+
export { share, type SharedModule, type SharedRegistry } from "../util/module.ts";

‎packages/harness/src/util/module.ts‎

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,54 @@ import { Script, constants } from "node:vm";
4848
* immutable and shared by every project on the machine, while the harness doing the loading is
4949
* whichever one is running now, and two applications embedding the harness need not agree on
5050
* where their `effect` lives.
51+
*
52+
* Each package maps to the `Symbol.for` key of its {@link SharedRegistry}.
53+
*/
54+
const sharedPackages: ReadonlyMap<string, string> = new Map([
55+
["effect", "codework.v1.effect"],
56+
["@codeworksh/plugin", "codework.v1.plugin"],
57+
]);
58+
59+
/** One module of a shared package, as a bundled host publishes it. */
60+
export interface SharedModule {
61+
/** Its export names; an ES module's exports must be known before it runs. */
62+
readonly names: ReadonlyArray<string>;
63+
readonly load: () => Promise<Record<string, unknown>>;
64+
}
65+
66+
/**
67+
* A shared package's modules by specifier (`effect/Schema`), published on
68+
* `globalThis[Symbol.for(key)]` by a host that is a single bundled file, such as the standalone
69+
* binary. Such a host has no `effect` on disk to resolve to, so plugins get virtual modules that
70+
* re-export its own instances. Modules load on first import: `effect` alone has hundreds.
5171
*/
52-
const sharedPackages: ReadonlySet<string> = new Set(["effect", "@codeworksh/plugin"]);
72+
export type SharedRegistry = Readonly<Record<string, SharedModule>>;
73+
74+
const sharedScheme = "codeworkshared:";
75+
76+
function registryOf(key: string): SharedRegistry | undefined {
77+
return (globalThis as Record<symbol, SharedRegistry | undefined>)[Symbol.for(key)];
78+
}
79+
80+
/** Publish a bundled host's registries, by package name, before any plugin loads. */
81+
export function share(packages: Readonly<Record<string, SharedRegistry>>): void {
82+
for (const [name, registry] of Object.entries(packages)) {
83+
const key = sharedPackages.get(name);
84+
if (key === undefined) throw new Error(`${name} is not a package shared with plugins`);
85+
(globalThis as Record<symbol, SharedRegistry>)[Symbol.for(key)] = registry;
86+
}
87+
}
88+
89+
/** A virtual module re-exporting the host's instance of `specifier`. */
90+
function sharedSource(key: string, specifier: string, names: ReadonlyArray<string>): string {
91+
const registry = `globalThis[Symbol.for(${JSON.stringify(key)})]`;
92+
return [
93+
`const m = await ${registry}[${JSON.stringify(specifier)}].load();`,
94+
...names.map((name, index) => `const $${index} = m[${JSON.stringify(name)}];`),
95+
// String export names, since some are reserved words (`Effect.void`).
96+
`export { ${names.map((name, index) => `$${index} as ${JSON.stringify(name)}`).join(", ")} };`,
97+
].join("\n");
98+
}
5399

54100
/** The package a bare specifier names: `effect/Schema` -> `effect`, `@a/b/c` -> `@a/b`. */
55101
function packageOf(specifier: string): string {
@@ -59,18 +105,34 @@ function packageOf(specifier: string): string {
59105
}
60106

61107
/**
62-
* Resolve the shared packages as though this module had imported them, for as long as `run`
63-
* takes. Anything else resolves exactly as it would have.
108+
* Resolve the shared packages to the host's own instances, for as long as `run` takes: from its
109+
* registry when it published one, otherwise as though this module had imported them. Anything
110+
* else resolves exactly as it would have.
64111
*/
65-
function withSharedModules<A>(run: () => Promise<A>): Promise<A> {
112+
async function withSharedModules<A>(run: () => Promise<A>): Promise<A> {
66113
const hook = registerHooks({
67114
resolve(specifier, context, nextResolve) {
68-
return sharedPackages.has(packageOf(specifier))
69-
? nextResolve(specifier, { ...context, parentURL: import.meta.url })
70-
: nextResolve(specifier, context);
115+
const key = sharedPackages.get(packageOf(specifier));
116+
if (key === undefined) return nextResolve(specifier, context);
117+
const registry = registryOf(key);
118+
if (registry === undefined) return nextResolve(specifier, { ...context, parentURL: import.meta.url });
119+
// Falling back to the plugin's own copy would bring back the two-instance failure above.
120+
if (!Object.hasOwn(registry, specifier)) throw new Error(`${specifier} is not shared with plugins`);
121+
return { url: `${sharedScheme}${specifier}`, shortCircuit: true };
122+
},
123+
load(url, context, nextLoad) {
124+
if (!url.startsWith(sharedScheme)) return nextLoad(url, context);
125+
const specifier = url.slice(sharedScheme.length);
126+
const key = sharedPackages.get(packageOf(specifier))!;
127+
const shared = registryOf(key)![specifier]!;
128+
return { format: "module", source: sharedSource(key, specifier, shared.names), shortCircuit: true };
71129
},
72130
});
73-
return run().finally(() => hook.deregister());
131+
try {
132+
return await run();
133+
} finally {
134+
hook.deregister();
135+
}
74136
}
75137

76138
/** Use Node's main loader even when the caller runs inside a bundler or test VM. */

0 commit comments

Comments
 (0)