mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(chord): v2 slice 7c, plugins component and per-plugin load and reload
Plugin discovery, esbuild builds (optional peer dependency), vm evaluation with pluginFile and mapStack, dependency installs, a watcher with dirty marks and rollout across hosts; host.load and host.reload per plugin with the closure rule; examples 35-39 as folders with real plugin directories.
This commit is contained in:
parent
b7e8249520
commit
a14cdf90f0
118 changed files with 5466 additions and 211 deletions
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -3387,7 +3387,7 @@
|
|||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
|
@ -5823,6 +5823,14 @@
|
|||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"esbuild": "0.28.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/codemode": {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
- Chord v2's store (`@earendil-works/chord/next`) gains `ChordStore.commitSeries(commits, receive, ctx)`: several commits in one backend transaction, each at its own seq with its own outbox row, the store metadata written once. Each commit's check runs on a step that sees the commits before it, before its writes; a check that throws refuses that commit and stores the ones before it, while a failed statement or write rolls the whole series back, and a failed `COMMIT` stops the store. It replaces `commitInBatch`, which is removed. Durable's `StoreStorage` stores each Session commit call through it.
|
||||
- Chord v2 has a package export again, `@earendil-works/chord/next` (experimental): every layer's API for apps (contracts, data, documents, hosts, jobs, links, state, the store), and, marked internal, the store's surface for adapters (`StoreOwnerTables`, `OwnerTable`, the documents engine's reads and writes, the commit step, `applyBatch`). v2's store gains one id allocator for every id in it, claims per table and per document namespace, Durable's Storage semantics for a refused commit (it rejects that commit only; an unknown outcome stops the store), document histories `none`, `latest` and `rewindable` with reads at a point, archives carrying document revisions, and batch apply; v2 objects gain `self.follow` (another runtime's synchronous observers as sources) and persisted jobs (`persist: { resume }`, a stored `abort(id, ctx)`, deferred state changes).
|
||||
- Chord v2 (`@earendil-works/chord/next`) gains registries, writables, wake signals and root scopes (slice 8): `defineRegistry<C, V>(name, { version })` tokens shared by plugins, served by the defining plugin's facet with `host.serve(R, { compose, accepts? })`; contributions from facets (`defineFacet((host, self) => self.contribute(R, value))`) and host-lifetime objects (`StoreSelf.contribute`), plain values or Chord watchables, in a table sorted by plugin name and composed lazily (once at the end of `load`'s setups, then when read or followed, in the batch that changed the table); readers through `self.registry(R)` (a declared dependency: an object activates once the registry has a value, and fails `transient` with the new reason `unavailable/undeclared` when nobody serves it) and `host.registry(R, ctx)`, a `RegistryValue<V>` with `observe(observer, ctx)`; older contributions and readers adapted by `accepts`; the helpers `keyedEntries` and `orderedEntries`; the `onError` kinds `registry_conflict`, `registry_failed`, `contribution_failed` and `contribution_refused`. `self.writable(initial)` is an in-process value an attempt sets (identity compared); native sources (writables, registry values, `ref.state`) are derive sources. `self.retryOn(...sources)` retries a failed object when a registry value changes. `host.root(fn, context, { principal? })` runs code outside objects with a handler's `ctx`, sealed when `fn` settles, `context` aborts or the host closes.
|
||||
- Chord v2 gains plugins (slice 7c): `src/next-node/plugins` (Node, to be exported as `@earendil-works/chord/next/plugins`) finds plugin directories, builds each facet with esbuild (now an optional peer dependency of `@earendil-works/chord`, imported only there), evaluates it with `vm.Script`, maps plugin stack traces (`mapStack`), installs plugin dependencies, watches plugin and base code, and rolls reloads out across hosts. `host.load` and `host.reload` work per plugin: a plugin whose setup, factory or declarations fail is left out at `load` (`{ failed }`) or kept on its old code at `reload` (`failed` in the report, `reload(facets, ctx, { failed })` for build and evaluation failures), with a closure rule keeping a consumer old when its provider was kept old; an abort after a reload's commit rejects with `details.committed`.
|
||||
- Chord v2, experimental and not exported yet: the next object runtime in `src/next` (contracts, hosts and objects, documents, derives, links and relays, routines and jobs, the store), with its tests and runnable examples in `test/next` and its design in `docs/next` (`design.md`, the decision log `decisions.md`, the slice plan `slices.md`). Its API changes with every slice until it gets an entry point.
|
||||
- `NativeWatchable<T>`: an in-process value that changes and can be watched (`value`, `watch(context)` returning a `NativeWatchHandle<T>`), the in-process counterpart of `Watchable<T>`.
|
||||
- `WatchHandle<T>` and `WatchEnd`, the one watch API, moved from Durable: a serialized observation with `value`, `start(listener)`, `stop()`, and `closed`; the listener gets a `WatchChange<T>` (`{ value, ops, retired }`) and the context. `WatchEnd` reasons are `stopped`, `cancelled`, `ended`, `listener_error`, and `failed`.
|
||||
|
|
|
|||
|
|
@ -880,3 +880,37 @@ the gap follows the shape of the text decoder's `fromFields`, not what runs: the
|
|||
(19.0-19.2 µs), the one change in it that matters is the never-executed `T` (retry) case, and writing that case with an
|
||||
object spread again (`...(f.length >= 2 ? { hops } : …)`) restores HEAD's 19.1 µs while other edits of the same size do
|
||||
not. With text measured first the gap is 22.4-22.5 → 23.0-23.3 µs (+2-3%). V8 code generation, recorded as noise.
|
||||
|
||||
## Slice 7c: plugins (2026-10-30)
|
||||
|
||||
**The plugins component** (`test/next/bench/plugins.ts`, new): 20 plugins, one worker facet each (a store object over a
|
||||
document, a derive, a method, a helper module and a copied contract; 80.9 KiB of scripts in all), Node 24.4.1. Medians
|
||||
of seven runs; three runs pinned to CPU 8, one unpinned:
|
||||
|
||||
| Measurement | CPU 8 | unpinned (32 CPUs) |
|
||||
| --- | --- | --- |
|
||||
| cold build of the 20 worker facets (one esbuild build each, all at once) | 240.1-240.7 ms | 26.4 ms |
|
||||
| rebuild of one facet after an edit | 13.1-13.3 ms | 11.0 ms |
|
||||
| evaluation of one facet (`vm.Script`, its source map registered) | 0.16-0.27 ms | 0.09 ms |
|
||||
| reload of one plugin on a host (evaluation and `host.reload`, a pure swap) | 0.44-0.45 ms | 0.44 ms |
|
||||
| heap after 100 reloads of one plugin, against after a warm-up | +230-235 KiB | +229 KiB |
|
||||
|
||||
The heap does not grow per reload: 2,000, 4,000 and 8,000 reloads leave +897, +1,186 and +1,432 KiB (sublinear); the
|
||||
same evaluations through plain `vm.Script` with V8's compilation cache off leave +420 KiB at 8,000 against +465 KiB
|
||||
through `evaluateFacet` (V8's own per-script bookkeeping), and the kernel's reloads alone wander between +200 and
|
||||
+600 KiB at any count. Old facets and their closures are collected (the GC test: 200 reloads, every facet but the last
|
||||
two collected, the heap within 2 MiB of the warm-up).
|
||||
|
||||
Fix round 1 (inline source maps without `sourcesContent`: positions only), CPU 8: the 20 scripts take 53.4 KiB (80.9
|
||||
KiB before); cold build 241.1 ms, rebuild 13.0 ms, evaluation 0.14 ms per facet, a reload 0.39 ms, heap after 100
|
||||
reloads +238 KiB.
|
||||
|
||||
**Kernel hot paths** (per plugin at load and reload changes only staging), HEAD `18a155e2f` → build at paths of equal
|
||||
length, CPU 6, three alternating runs. `calls.ts`: local call 2.69-2.71 → 2.71-2.75 µs (6,660 B both), watched
|
||||
derive commit 10.34-10.36 → 10.43-10.49 µs (14,899 → 14,901 B), activation churn 58.93-59.58 → 59.44-59.73 µs
|
||||
(41,452-41,470 → 41,470 B). `links.ts` binary commit to frame (median / p90): append 0.035-0.036 / 0.058-0.059 →
|
||||
0.034-0.036 / 0.058-0.060 ms, changed field 0.027-0.028 / 0.031-0.033 → 0.027-0.028 / 0.031-0.033 ms, removal 0.022 /
|
||||
0.026-0.028 → 0.022 / 0.026-0.027 ms; with the young generation pinned alike within 2 µs; text alike; small call
|
||||
0.026-0.030 → 0.028-0.033 ms (pinned 0.030-0.035 → 0.030-0.032 ms); retained per hold equal (one binary hold 176.2 →
|
||||
170.1 KiB). `reload.ts`: swap of 50 / 1,000 objects 1.44-1.73 / 30.83-32.71 → 1.52-1.53 / 30.55-33.25 ms, restart
|
||||
1.01-1.05 / 15.54-16.87 → 0.95-0.99 / 15.90-16.02 ms until the reload resolves.
|
||||
|
|
|
|||
|
|
@ -93,6 +93,14 @@
|
|||
"@earendil-works/pi-telemetry": "1.4.0",
|
||||
"typebox": "1.3.27"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"esbuild": "0.28.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "8.18.2",
|
||||
"shx": "0.3.4",
|
||||
|
|
|
|||
95
packages/chord/src/next-node/plugins/README.md
Normal file
95
packages/chord/src/next-node/plugins/README.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# plugins
|
||||
|
||||
The plugins component (slice 7c, design §21 "Loading plugins and the reload loop"): plugin directories found, each
|
||||
facet built into a script by esbuild and evaluated on Node, loaded and reloaded per plugin, watched and rolled out. A
|
||||
Chord app modifies itself (the owner or an agent edits a plugin while it runs), so this is core machinery, no dev mode.
|
||||
Node-only (`fs`, esbuild, `vm`); the kernel in `src/next` stays portable and takes facets however they were made.
|
||||
esbuild is an optional peer dependency of `@earendil-works/chord`, imported only here. Exported as
|
||||
`@earendil-works/chord/next/plugins` once v2 has its entry point.
|
||||
|
||||
## For apps (`index.ts`)
|
||||
|
||||
```ts
|
||||
const plugins = createPlugins({ roots: ["./plugins", join(dataDir, "plugins")], kinds: ["server", "worker", "web", "tui"],
|
||||
browserKinds: ["web"], hostPackages: HOST, base: ["./src"], onError }, ctx);
|
||||
await plugins.load(server, { kind: "server", core: [{ name: "@app/core", facet: coreServerFacet }] }, ctx);
|
||||
plugins.state // { dirty, install, set, base }, a JSON watchable
|
||||
const built = await plugins.build(plugins.state.value.dirty, ctx);
|
||||
await plugins.rollout([plugins.step("server", server), ...workerSteps], built, ctx); // the app's order
|
||||
```
|
||||
|
||||
- **A plugin** is a directory with a `package.json`: its `name` (npm style) is the plugin's name, `dependencies` are
|
||||
bundled, `peerDependencies` are host packages, `"chord": { "external": [...] }` keeps named packages external in
|
||||
Node builds (loaded from the plugin's `node_modules`, native addons included; they stay in Node's module cache until
|
||||
a restart). One file per facet kind the app names (`server.ts`, `worker.ts`, `web.tsx`, `tui.ts`; `.ts`, `.tsx`,
|
||||
`.js` or `.mjs`), its default export a facet; a plugin with no facet file holds contracts others import.
|
||||
- `discoverPlugins(roots, { kinds }, ctx)`: lists each root one level deep, reads only `package.json`, follows
|
||||
symlinks to real paths; the first root wins a duplicate name. `{ plugins, problems }`: a directory without a valid
|
||||
`package.json` or name, with two files of one kind, or a later duplicate is a problem, not a plugin.
|
||||
- `buildFacet(plugin, kind, { hostPackages, platform, plugins?, publicPath? }, ctx)`: one esbuild build from cold.
|
||||
Inlined: the plugin's own files, files of other discovered plugins (copies, listed in `copied`; they may import only
|
||||
host packages and files of their own plugin: contracts and side-effect-free modules), packages of its
|
||||
`dependencies`. External: host packages, `@earendil-works/chord/next/plugins`, `chord.external` packages and Node
|
||||
builtins (Node builds; a browser build refuses both). Browser builds collect CSS into one stylesheet (`css`) and
|
||||
assets (`import url from "./icon.svg?url"`, `.wasm`) into content-hashed files (`assets`) under `publicPath`.
|
||||
Refused, with the file and line: `import.meta`, `__dirname`, `__filename` (each use, found through the script's
|
||||
source map), a literal dynamic `import("x")` and top-level `await` (pointing to `pluginFile`; a computed `import(x)`
|
||||
cannot be seen at build: it runs through the facet's `require`, which names the facet when it does not answer), a
|
||||
relative path out of the plugin (base code is a host package), a package outside its `dependencies`. The result is plain data: `{ kind: "script", source, digest, inputs, copied, css?, assets }` (a
|
||||
wrapped CommonJS script, minified, with an inline source map of positions only, relative to the plugin, so the
|
||||
digest is the same on every checkout; `inputs` hashed as esbuild read them) or `{ kind: "failed", errors }`, never a throw.
|
||||
- `evaluateFacet(built, { hostPackages, plugin })`: `vm.Script` in this context under a stable filename per (plugin,
|
||||
kind, digest), the default export checked to be a facet. Its `require` answers the host packages (a CommonJS
|
||||
package's exports as they are, an ESM namespace wrapped once with `__esModule` so default and named imports both
|
||||
work), `@earendil-works/chord/next/plugins` with `pluginFile` bound to the plugin's directory, `chord.external`
|
||||
packages and Node builtins from the plugin's directory; anything else throws naming the facet. No module map, no
|
||||
cache: an old facet's closures are collected once nothing holds them.
|
||||
- `pluginFile(path)` (inside a facet script): the absolute path of a file of its own plugin; `..` out of it refused.
|
||||
- `mapStack(error)`: the error's stack with plugin scripts' positions mapped through their source maps
|
||||
(`notes/worker.ts:12:3`), for the app's reporters; nothing global. A script's map is kept while any evaluation of it
|
||||
lives (its facet, on any host, or the error its evaluation threw).
|
||||
- `installDependencies(plugin, ctx, { npmCommand? })`: `npm install --omit=dev --legacy-peer-deps --ignore-scripts
|
||||
--no-audit --no-fund` in the plugin's real directory.
|
||||
- `createPlugins(options, ctx)`: turns V8's compilation cache off (once per process) and starts the watcher.
|
||||
- `load(host, { kind, core }, ctx)`: discovers (once), builds and evaluates the `kind` facet of every plugin and
|
||||
calls `host.load([...core, ...facets])`; a plugin whose build or evaluation fails is left out, reported in the
|
||||
report's `failed` beside the host's own.
|
||||
- `build(names, ctx)`: discovers again when the plugin set or a plugin's `package.json` changed, builds every facet
|
||||
of `names` and of plugins discovered since: `{ facets, plugins, removed }`, plain data (`plugins`: the built
|
||||
plugins as discovered, by name; a clone reloads the same).
|
||||
- `reload(host, built, ctx)`, one after another per host: the complete set for the host's kind (the plugins it
|
||||
loaded and those `built` brings): core facets and unchanged plugins (same digest) as the same objects, new scripts
|
||||
evaluated, the plugins `built` removed or rebuilt without a facet of the host's kind left out (its own
|
||||
discovery's, never a later one's); a plugin whose build or evaluation failed is
|
||||
passed as its loaded facet (an added one left out) and named to `host.reload`'s `failed` option, so the host's
|
||||
closure rule sees it and its report lists it. What the host committed becomes the loaded set, also when the
|
||||
reload rejects `aborted` after its commit (`details.committed`).
|
||||
- `step(name, host)`: an in-process rollout step; `rollout(steps, built, ctx)` runs steps in order and stops at one
|
||||
that rejects (`stopped`); failed plugins do not stop it. It clears the dirty marks of the built plugins applied on
|
||||
every step and unchanged since the build; a plugin failed on any step stays dirty. Given a copy of a build (not the
|
||||
object `build` returned), it clears no marks.
|
||||
- `installDependencies(name, ctx)` (the install mark clears, the plugin turns dirty), `close()` (the watcher stops).
|
||||
- **The watcher** (`fs.watch` on every directory of the roots, the base code and plugin directories linked from
|
||||
outside the roots, never on `node_modules` or `.git`; a directory appearing later under a watched one gets its watch,
|
||||
a root missing at `createPlugins` is not watched: the app builds on demand) marks, never applies: a changed file
|
||||
dirties every plugin whose facets read it (a copied contract every user) and the plugin whose directory holds it; a
|
||||
`package.json` sets `install` (and the next build reads the plugin's metadata again; a plugin the next discovery no
|
||||
longer has loses its install mark); a directory under a root or a facet file at a plugin's top sets `set`; a file of
|
||||
base code sets `base` (the app restarts its hosts). Each mark records the generation of its latest change
|
||||
(generations only increase): a change during a build keeps the mark, however late an older rollout ends. A watcher
|
||||
that cannot watch reports `watch_failed` once; the app builds on demand.
|
||||
|
||||
## Examples
|
||||
|
||||
Each a folder in `test/next/examples/` with its hosts in `main.ts` and real plugin directories in `plugins/`:
|
||||
`35-plugins` (load, edit, mark, build, roll out), `36-plugin-failures` (per plugin failures at startup and reload, the
|
||||
closure rule, an abort after the commit), `37-plugin-imports` (what a facet may import, installs, the refusals),
|
||||
`38-watch-and-rollout` (every mark, added, removed and dropped facets, stopped rollouts) and `39-browser-build` (a
|
||||
browser build: stylesheet, assets by URL, plain data).
|
||||
|
||||
## Inside the directory
|
||||
|
||||
`discover.ts` (discovery), `build.ts` (one build, its import rules and input capture), `evaluate.ts` (evaluation, the
|
||||
`require` a script gets, `pluginFile`), `stack.ts` (the source maps of evaluated scripts while their facets live,
|
||||
`mapStack`), `install.ts`, `watcher.ts` (the watcher and its marks, a JSON watchable), `plugins.ts` (the component:
|
||||
per-host loaded facets, `build`, `load`, `reload`, `step`, `rollout`).
|
||||
372
packages/chord/src/next-node/plugins/build.ts
Normal file
372
packages/chord/src/next-node/plugins/build.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
// Building (slice 7c §2): one facet of one plugin as one script, by esbuild from cold. Its own files, files of other
|
||||
// discovered plugins (copies) and its npm `dependencies` are inlined; host packages, this component, `chord.external`
|
||||
// packages and Node builtins stay external, answered at evaluation by the `require` the script takes. The result is
|
||||
// plain data, the same value a worker or a page evaluates where it runs; a failed build is a result, never a throw.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { isBuiltin } from "node:module";
|
||||
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { type BuildFailure, build, type Plugin as EsbuildPlugin, type Loader, type Message } from "esbuild";
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Plugin } from "./discover.ts";
|
||||
import { inlineSourceMap } from "./stack.ts";
|
||||
|
||||
/** What `buildFacet` takes besides the plugin and the kind. */
|
||||
export type BuildOptions = {
|
||||
/** The host packages' specifiers (`@earendil-works/chord/next`, `react`, `react/jsx-runtime`, `@app/core`): external. */
|
||||
readonly hostPackages: readonly string[];
|
||||
/** Where the script runs: Node (a server, a worker, a TUI) or a browser (a page). */
|
||||
readonly platform: "node" | "browser";
|
||||
/** The discovered plugins: a file of one of them may be imported (a copy). */
|
||||
readonly plugins?: readonly Plugin[];
|
||||
/** Where the app serves a browser build's assets: the URL prefix their imports evaluate to (`/` by default). */
|
||||
readonly publicPath?: string;
|
||||
};
|
||||
|
||||
/** A file a build read, with the hash of the bytes it handed esbuild (the watcher's inputs). */
|
||||
export type BuildInput = { readonly file: string; readonly hash: string };
|
||||
|
||||
/** A built facet: its script and what it used, or the build's errors. Plain data. */
|
||||
export type BuiltFacet =
|
||||
| {
|
||||
readonly kind: "script";
|
||||
readonly plugin: string;
|
||||
readonly facet: string;
|
||||
/** The wrapped CommonJS script, minified, with an inline source map whose sources are relative to the plugin. */
|
||||
readonly source: string;
|
||||
/** A hash of `source`: the same on every machine and checkout for the same files. */
|
||||
readonly digest: string;
|
||||
/** Every plugin file it read (its own and copied ones; not packages), absolute paths, with their hashes. */
|
||||
readonly inputs: readonly BuildInput[];
|
||||
/** The files of other plugins inlined into it, absolute paths. */
|
||||
readonly copied: readonly string[];
|
||||
/** A browser build's stylesheet, when it imports CSS. */
|
||||
readonly css?: { readonly name: string; readonly text: string };
|
||||
/** A browser build's assets, by content-hashed file name. */
|
||||
readonly assets: readonly { readonly name: string; readonly bytes: Uint8Array }[];
|
||||
}
|
||||
| { readonly kind: "failed"; readonly plugin: string; readonly facet: string; readonly errors: readonly string[] };
|
||||
|
||||
/** The one specifier of this component a script imports (`pluginFile`): external always, answered per facet. */
|
||||
export const PLUGINS_SPECIFIER = "@earendil-works/chord/next/plugins";
|
||||
|
||||
/** What a free `__dirname` and `__filename` become in a build: names no script has, found again by `freeGlobals`. */
|
||||
const GLOBAL_MARKERS = { __dirname: "__chord_plugin_dirname", __filename: "__chord_plugin_filename" };
|
||||
|
||||
/**
|
||||
* Builds `plugin`'s facet of `kind` (one esbuild build from cold): a script wrapped as `(function (require, module,
|
||||
* exports) { … })`, or the errors that refuse it (an import the rules of §2 do not admit, a syntax error, `import.meta`,
|
||||
* `__dirname`, `__filename`, a literal dynamic `import("x")`, a top-level `await`), each naming its file and line. A
|
||||
* computed `import(x)` becomes a call of the script's `require`.
|
||||
*/
|
||||
export async function buildFacet(
|
||||
plugin: Plugin,
|
||||
kind: string,
|
||||
options: BuildOptions,
|
||||
context: Context,
|
||||
): Promise<BuiltFacet> {
|
||||
const failed = (errors: readonly string[]): BuiltFacet => ({
|
||||
kind: "failed",
|
||||
plugin: plugin.name,
|
||||
facet: kind,
|
||||
errors,
|
||||
});
|
||||
const entry = plugin.facets[kind];
|
||||
if (entry === undefined) return failed([`plugin "${plugin.name}" has no ${kind} facet`]);
|
||||
context.abortSignal?.throwIfAborted();
|
||||
const rules = new ImportRules(plugin, options);
|
||||
const browser = options.platform === "browser";
|
||||
let result: Awaited<ReturnType<typeof build>>;
|
||||
try {
|
||||
result = await build({
|
||||
entryPoints: [entry],
|
||||
absWorkingDir: plugin.dir,
|
||||
// Nothing is written: the directory only makes the source map's paths relative to the plugin.
|
||||
outdir: plugin.dir,
|
||||
entryNames: "[name]-[hash]",
|
||||
assetNames: "[name]-[hash]",
|
||||
publicPath: options.publicPath ?? "/",
|
||||
write: false,
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
platform: options.platform,
|
||||
target: browser ? "es2022" : "node22",
|
||||
minify: true,
|
||||
legalComments: "none",
|
||||
sourcemap: "inline",
|
||||
// Positions only: a stack is mapped to files and lines, never shown their text.
|
||||
sourcesContent: false,
|
||||
// Ignore any tsconfig.json: TypeScript is stripped, JSX compiled with the automatic React runtime.
|
||||
tsconfigRaw: "{}",
|
||||
jsx: "automatic",
|
||||
banner: { js: "(function (require, module, exports) {" },
|
||||
footer: { js: "})" },
|
||||
// `import.meta` is refused here; a free `__dirname` or `__filename` becomes a marker found in the output below
|
||||
// (esbuild reports no `import.meta` it substituted itself).
|
||||
define: GLOBAL_MARKERS,
|
||||
logOverride: { "empty-import-meta": "error" },
|
||||
// A computed `import(x)` (a literal one is refused below) goes through the facet's `require`, which names the
|
||||
// facet when it does not answer; `vm.Script` has no loader for a real one.
|
||||
supported: { "dynamic-import": false },
|
||||
logLevel: "silent",
|
||||
plugins: [rules.plugin],
|
||||
});
|
||||
} catch (error) {
|
||||
return failed(
|
||||
((error as BuildFailure).errors ?? [{ text: String(error) }]).map((each) => describe(plugin, each)),
|
||||
);
|
||||
}
|
||||
// Never written: esbuild hands the outputs back.
|
||||
const files = result.outputFiles!;
|
||||
const script = files.find((file) => file.path.endsWith(".js"))!;
|
||||
const css = files.find((file) => file.path.endsWith(".css"));
|
||||
const source = script.text;
|
||||
const globals = freeGlobals(source);
|
||||
if (globals.length > 0) return failed(globals);
|
||||
return {
|
||||
kind: "script",
|
||||
plugin: plugin.name,
|
||||
facet: kind,
|
||||
source,
|
||||
digest: createHash("sha256").update(source).digest("hex"),
|
||||
inputs: rules.inputs(),
|
||||
copied: rules.copied(),
|
||||
...(css === undefined ? {} : { css: { name: basename(css.path), text: css.text } }),
|
||||
assets: files
|
||||
.filter((file) => file !== script && file !== css)
|
||||
.map((file) => ({ name: relative(plugin.dir, file.path), bytes: file.contents })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Where a build error points for what a plugin script cannot have. */
|
||||
const PLUGIN_FILE_HINT = `a file of the plugin is read through pluginFile(path) from "${PLUGINS_SPECIFIER}"`;
|
||||
|
||||
/** The error of a script using `import.meta`, `__dirname` or `__filename`. */
|
||||
const NO_MODULE_IDENTITY = `import.meta.url, __dirname and __filename are not available in a plugin script (${PLUGIN_FILE_HINT})`;
|
||||
|
||||
/**
|
||||
* The errors for every free `__dirname` and `__filename` in the built `source` (their markers), each at its file, line
|
||||
* and column through the script's source map.
|
||||
*/
|
||||
function freeGlobals(source: string): string[] {
|
||||
// Most scripts have none: no map parsed for them.
|
||||
if (!source.includes("__chord_plugin_")) return [];
|
||||
const map = inlineSourceMap(source);
|
||||
if (map === undefined) return [];
|
||||
const errors: string[] = [];
|
||||
for (const [line, text] of source.split("\n").entries()) {
|
||||
for (const match of text.matchAll(/__chord_plugin_(?:dirname|filename)/g)) {
|
||||
const entry = map.findEntry(line, match.index);
|
||||
if (!("originalSource" in entry)) continue;
|
||||
errors.push(
|
||||
`${entry.originalSource}:${entry.originalLine + 1}:${entry.originalColumn + 1}: ${NO_MODULE_IDENTITY}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Where a resolved file lies, from the plugin being built: its own source, another plugin's (a copy), a package. */
|
||||
type Place =
|
||||
| { readonly kind: "own" }
|
||||
| { readonly kind: "copied"; readonly plugin: Plugin }
|
||||
| { readonly kind: "package" }
|
||||
| { readonly kind: "outside" };
|
||||
|
||||
/** What only a browser build imports: stylesheets and wasm assets. */
|
||||
const BROWSER_ONLY = new Set([".css", ".wasm"]);
|
||||
|
||||
/** Marks esbuild's resolution the rules ask for themselves, so they do not judge it again. */
|
||||
const RESOLVING = Symbol("chord.plugins.resolving");
|
||||
|
||||
/** How the source files a build reads are loaded, by extension (packages load as esbuild decides). */
|
||||
const LOADERS: Readonly<Record<string, Loader>> = {
|
||||
".ts": "ts",
|
||||
".mts": "ts",
|
||||
".cts": "ts",
|
||||
".tsx": "tsx",
|
||||
".js": "js",
|
||||
".mjs": "js",
|
||||
".cjs": "js",
|
||||
".jsx": "jsx",
|
||||
".json": "json",
|
||||
".css": "css",
|
||||
".wasm": "file",
|
||||
};
|
||||
|
||||
/** What an asset import's resolution carries to its load (the `file` loader whatever its extension). */
|
||||
const ASSET = Symbol("chord.plugins.asset");
|
||||
|
||||
/**
|
||||
* The import rules of one build (§2's table) as an esbuild plugin, and what the build read: every file of a plugin
|
||||
* hashed as it is handed to esbuild (the exact bytes built, not a later read racing a save), and the copied files.
|
||||
*/
|
||||
class ImportRules {
|
||||
readonly plugin: EsbuildPlugin;
|
||||
readonly #own: Plugin;
|
||||
readonly #others: readonly Plugin[];
|
||||
readonly #options: BuildOptions;
|
||||
readonly #hostPackages: ReadonlySet<string>;
|
||||
readonly #inputs = new Map<string, string>();
|
||||
readonly #copied = new Set<string>();
|
||||
|
||||
constructor(own: Plugin, options: BuildOptions) {
|
||||
this.#own = own;
|
||||
this.#others = (options.plugins ?? []).filter((each) => each.dir !== own.dir);
|
||||
this.#options = options;
|
||||
this.#hostPackages = new Set(options.hostPackages);
|
||||
this.plugin = {
|
||||
name: "chord-plugin-imports",
|
||||
setup: (builder) => {
|
||||
builder.onResolve({ filter: /.*/ }, async (args) => {
|
||||
// The plugin's files are resolved here, the entry too: esbuild then gives them no Node module type
|
||||
// (`"type": "module"`), whose interop would read a whole host package for a default import instead
|
||||
// of honouring the `__esModule` an ESM namespace is wrapped with (§3).
|
||||
if (args.kind === "entry-point") return { path: resolve(own.dir, args.path) };
|
||||
if (args.pluginData === RESOLVING) return undefined;
|
||||
const resolved = await this.#resolve(args.path, args.importer, args.kind);
|
||||
if (resolved.kind === "error") return { errors: [{ text: resolved.text }] };
|
||||
if (resolved.kind === "external") return { path: args.path, external: true };
|
||||
if (resolved.kind === "default") return undefined;
|
||||
const found = await builder.resolve(resolved.path, {
|
||||
kind: args.kind,
|
||||
importer: args.importer,
|
||||
resolveDir: args.resolveDir,
|
||||
pluginData: RESOLVING,
|
||||
});
|
||||
if (found.errors.length > 0) return { errors: found.errors };
|
||||
if (options.platform === "node" && BROWSER_ONLY.has(extname(found.path))) {
|
||||
return { errors: [{ text: `"${args.path}": stylesheets and assets are for browser builds` }] };
|
||||
}
|
||||
const checked = this.#checkFile(found.path, args.importer, args.path);
|
||||
if (checked !== undefined) return { errors: [{ text: checked }] };
|
||||
return { path: found.path, pluginData: resolved.asset ? ASSET : undefined };
|
||||
});
|
||||
builder.onLoad({ filter: /.*/ }, async (args) => {
|
||||
if (args.namespace !== "file") return undefined;
|
||||
const asset = args.pluginData === ASSET;
|
||||
const loader = asset ? "file" : LOADERS[extname(args.path)];
|
||||
if (loader === undefined || this.#place(args.path).kind === "package") return undefined;
|
||||
const contents = await readFile(args.path);
|
||||
this.#inputs.set(args.path, createHash("sha256").update(contents).digest("hex"));
|
||||
return { contents, loader, resolveDir: dirname(args.path) };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Every plugin file read, sorted by path. */
|
||||
inputs(): BuildInput[] {
|
||||
return [...this.#inputs].map(([file, hash]) => ({ file, hash })).sort((a, b) => (a.file < b.file ? -1 : 1));
|
||||
}
|
||||
|
||||
/** The other plugins' files inlined, sorted. */
|
||||
copied(): string[] {
|
||||
return [...this.#copied].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* What an import of `specifier` from `importer` becomes: external, refused, esbuild's own resolution (a package of
|
||||
* the plugin's `dependencies`, or anything a package imports), or a path esbuild resolves and the rules check.
|
||||
*/
|
||||
async #resolve(
|
||||
specifier: string,
|
||||
importer: string,
|
||||
kind: string,
|
||||
): Promise<
|
||||
| { readonly kind: "external" }
|
||||
| { readonly kind: "default" }
|
||||
| { readonly kind: "path"; readonly path: string; readonly asset: boolean }
|
||||
| { readonly kind: "error"; readonly text: string }
|
||||
> {
|
||||
const browser = this.#options.platform === "browser";
|
||||
if (kind === "dynamic-import") {
|
||||
return {
|
||||
kind: "error",
|
||||
text: `import("${specifier}"): a plugin script has no dynamic import (${PLUGIN_FILE_HINT})`,
|
||||
};
|
||||
}
|
||||
if (specifier === PLUGINS_SPECIFIER || this.#hostPackages.has(specifier)) return { kind: "external" };
|
||||
if (isBuiltin(specifier)) {
|
||||
if (!browser) return { kind: "external" };
|
||||
return { kind: "error", text: `"${specifier}": a Node builtin in a browser build` };
|
||||
}
|
||||
if (specifier.endsWith("?url")) {
|
||||
if (browser) return { kind: "path", path: specifier.slice(0, -"?url".length), asset: true };
|
||||
return { kind: "error", text: `"${specifier}": assets by URL are for browser builds (${PLUGIN_FILE_HINT})` };
|
||||
}
|
||||
const from = this.#place(importer);
|
||||
if (from.kind === "package") return { kind: "default" };
|
||||
if (specifier.startsWith(".") || isAbsolute(specifier)) return { kind: "path", path: specifier, asset: false };
|
||||
if (from.kind === "copied") {
|
||||
return { kind: "error", text: `"${specifier}": ${copiedRule(from.plugin)}` };
|
||||
}
|
||||
const name = packageName(specifier);
|
||||
if (this.#own.external.includes(name)) {
|
||||
if (!browser) return { kind: "external" };
|
||||
return { kind: "error", text: `"${specifier}": chord.external packages are for Node builds` };
|
||||
}
|
||||
if (this.#own.dependencies.includes(name)) return { kind: "default" };
|
||||
return {
|
||||
kind: "error",
|
||||
text: `"${specifier}": neither a host package nor in the dependencies of plugin "${this.#own.name}"`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the file `path` (resolved from a relative import in `importer`) may not be inlined: outside the plugin and
|
||||
* every other discovered plugin, or, from a copied file, outside that file's own plugin; `undefined` when it may (a
|
||||
* file of another plugin is recorded as copied).
|
||||
*/
|
||||
#checkFile(path: string, importer: string, specifier: string): string | undefined {
|
||||
const from = this.#place(importer);
|
||||
const to = this.#place(path);
|
||||
if (to.kind === "own" && from.kind === "own") return undefined;
|
||||
if (to.kind === "copied" && (from.kind === "own" || (from.kind === "copied" && from.plugin === to.plugin))) {
|
||||
this.#copied.add(path);
|
||||
return undefined;
|
||||
}
|
||||
if (from.kind === "copied") return `"${specifier}": ${copiedRule(from.plugin)}`;
|
||||
return `"${specifier}": outside the plugin (base code is reached through a host package, packages through dependencies)`;
|
||||
}
|
||||
|
||||
/** Where `path` lies. */
|
||||
#place(path: string): Place {
|
||||
if (path.split(sep).includes("node_modules")) return { kind: "package" };
|
||||
if (inside(this.#own.dir, path)) return { kind: "own" };
|
||||
const other = this.#others.find((each) => inside(each.dir, path));
|
||||
return other === undefined ? { kind: "outside" } : { kind: "copied", plugin: other };
|
||||
}
|
||||
}
|
||||
|
||||
/** The rule a copied file broke. */
|
||||
function copiedRule(plugin: Plugin): string {
|
||||
return `a file copied from plugin "${plugin.name}" may import only host packages and files of its own plugin`;
|
||||
}
|
||||
|
||||
/** Whether `path` lies inside directory `dir` (both absolute). */
|
||||
export function inside(dir: string, path: string): boolean {
|
||||
const rest = relative(dir, path);
|
||||
return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
|
||||
}
|
||||
|
||||
/** The package a bare specifier names: `@scope/name` or `name`, without a subpath. */
|
||||
export function packageName(specifier: string): string {
|
||||
const parts = specifier.split("/");
|
||||
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]!;
|
||||
}
|
||||
|
||||
/** One build error as `file:line:column: text`, its file relative to the plugin, with the hint where it applies. */
|
||||
function describe(plugin: Plugin, message: Message): string {
|
||||
let text = message.text;
|
||||
if (message.id === "empty-import-meta") {
|
||||
text = NO_MODULE_IDENTITY;
|
||||
} else if (text.startsWith("Top-level await")) {
|
||||
text = "a plugin script has no top-level await (a facet declares synchronously)";
|
||||
}
|
||||
const location = message.location;
|
||||
if (location === null || location === undefined) return text;
|
||||
const file = isAbsolute(location.file) ? relative(plugin.dir, location.file) : location.file;
|
||||
return `${file}:${location.line}:${location.column + 1}: ${text}`;
|
||||
}
|
||||
124
packages/chord/src/next-node/plugins/discover.ts
Normal file
124
packages/chord/src/next-node/plugins/discover.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// Discovery (slice 7c §1): the plugins under the app's roots, found by listing each root one level deep and reading only
|
||||
// each directory's `package.json` (its `name`, its `dependencies`, its `chord.external`); its facet files are the ones
|
||||
// named after the app's facet kinds. Nothing parses or runs plugin code.
|
||||
|
||||
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import { isPluginName } from "../../next/host/staging.ts";
|
||||
|
||||
/** A plugin on disk: its name (its `package.json` name), its real directory, its facet files and npm packages. */
|
||||
export type Plugin = {
|
||||
readonly name: string;
|
||||
/** Its directory's real path (symlinks followed). */
|
||||
readonly dir: string;
|
||||
/** Its facet files by kind, absolute paths; none for a contracts-only plugin. */
|
||||
readonly facets: Readonly<Record<string, string>>;
|
||||
/** The packages in its `dependencies`: bundled into the facets that import them. */
|
||||
readonly dependencies: readonly string[];
|
||||
/** The packages in its `chord.external`: kept external in Node builds, loaded from its `node_modules`. */
|
||||
readonly external: readonly string[];
|
||||
};
|
||||
|
||||
/** A directory under a root that is not a plugin, or a plugin left out, and why. */
|
||||
export type DiscoveryProblem = { readonly path: string; readonly error: string };
|
||||
|
||||
/** What discovery found: the plugins by name, and the directories it reported. */
|
||||
export type Discovery = { readonly plugins: readonly Plugin[]; readonly problems: readonly DiscoveryProblem[] };
|
||||
|
||||
/** What discovery takes besides the roots: the app's facet kinds (`["server", "worker", "web", "tui"]`). */
|
||||
export type DiscoveryOptions = { readonly kinds: readonly string[] };
|
||||
|
||||
/**
|
||||
* Lists every root one level deep: a directory with a valid `package.json` is a plugin (a missing root is empty). A
|
||||
* directory without a `package.json`, with an invalid one or an invalid name, with two files of one facet kind, or whose
|
||||
* name an earlier root (or an earlier directory of the same root) has already, is reported. Plugins sorted by name.
|
||||
*/
|
||||
export async function discoverPlugins(
|
||||
roots: readonly string[],
|
||||
options: DiscoveryOptions,
|
||||
context: Context,
|
||||
): Promise<Discovery> {
|
||||
const plugins = new Map<string, Plugin>();
|
||||
const problems: DiscoveryProblem[] = [];
|
||||
for (const root of roots) {
|
||||
for (const dir of await directoriesOf(root)) {
|
||||
context.abortSignal?.throwIfAborted();
|
||||
const found = await pluginAt(dir, options.kinds);
|
||||
if (found.kind === "problem") {
|
||||
problems.push({ path: dir, error: found.error });
|
||||
continue;
|
||||
}
|
||||
const other = plugins.get(found.plugin.name);
|
||||
if (other !== undefined) {
|
||||
problems.push({ path: dir, error: `plugin "${found.plugin.name}" is in ${other.dir} already` });
|
||||
continue;
|
||||
}
|
||||
plugins.set(found.plugin.name, found.plugin);
|
||||
}
|
||||
}
|
||||
const sorted = [...plugins.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
return { plugins: sorted, problems };
|
||||
}
|
||||
|
||||
/** The directories directly under `root`, by real path, in name order; none when `root` does not exist. */
|
||||
async function directoriesOf(root: string): Promise<string[]> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = (await readdir(root)).sort();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
const dirs: string[] = [];
|
||||
for (const name of names) {
|
||||
// Not plugins: hidden directories and a root's own packages.
|
||||
if (name.startsWith(".") || name === "node_modules") continue;
|
||||
const path = join(root, name);
|
||||
// `stat` follows a symlink: a linked plugin directory is listed by its real path.
|
||||
const info = await stat(path).catch(() => undefined);
|
||||
if (info?.isDirectory() === true) dirs.push(await realpath(path));
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/** The facet files' extensions, in no order of preference: two files of one kind are reported. */
|
||||
export const FACET_EXTENSIONS: readonly string[] = [".ts", ".tsx", ".js", ".mjs"];
|
||||
|
||||
/** The plugin in `dir` (its real path), or why it is none. */
|
||||
async function pluginAt(
|
||||
dir: string,
|
||||
kinds: readonly string[],
|
||||
): Promise<
|
||||
{ readonly kind: "plugin"; readonly plugin: Plugin } | { readonly kind: "problem"; readonly error: string }
|
||||
> {
|
||||
let manifest: unknown;
|
||||
try {
|
||||
manifest = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
|
||||
} catch (error) {
|
||||
const missing = (error as NodeJS.ErrnoException).code === "ENOENT";
|
||||
return { kind: "problem", error: missing ? "no package.json" : `package.json: ${(error as Error).message}` };
|
||||
}
|
||||
// A trust boundary: a plugin's own `package.json`.
|
||||
const { name, dependencies, chord } = (manifest ?? {}) as {
|
||||
readonly name?: unknown;
|
||||
readonly dependencies?: unknown;
|
||||
readonly chord?: { readonly external?: unknown };
|
||||
};
|
||||
if (typeof name !== "string" || !isPluginName(name)) {
|
||||
return { kind: "problem", error: `package.json: ${JSON.stringify(name)} is not a valid plugin name` };
|
||||
}
|
||||
const external = chord?.external ?? [];
|
||||
if (!Array.isArray(external) || !external.every((each) => typeof each === "string")) {
|
||||
return { kind: "problem", error: "package.json: chord.external is not a list of package names" };
|
||||
}
|
||||
const files = new Set(await readdir(dir));
|
||||
const facets: Record<string, string> = {};
|
||||
for (const kind of kinds) {
|
||||
const named = FACET_EXTENSIONS.map((extension) => `${kind}${extension}`).filter((file) => files.has(file));
|
||||
if (named.length > 1) return { kind: "problem", error: `two ${kind} facets: ${named.join(", ")}` };
|
||||
if (named.length === 1) facets[kind] = join(dir, named[0]!);
|
||||
}
|
||||
const packages = typeof dependencies === "object" && dependencies !== null ? Object.keys(dependencies) : [];
|
||||
return { kind: "plugin", plugin: { name, dir, facets, dependencies: packages, external } };
|
||||
}
|
||||
116
packages/chord/src/next-node/plugins/evaluate.ts
Normal file
116
packages/chord/src/next-node/plugins/evaluate.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// Evaluating (slice 7c §3): a built script run with `vm.Script` in this context under a stable filename per (plugin,
|
||||
// kind, digest), given the `require` that answers its external imports: the host packages (one instance per process),
|
||||
// this component's `pluginFile` bound to the plugin's directory, and `chord.external` packages and Node builtins from
|
||||
// the plugin's own `node_modules`. No module map and no cache: an old facet's closures are collected once nothing holds
|
||||
// them.
|
||||
|
||||
import { createRequire, isBuiltin } from "node:module";
|
||||
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { Script } from "node:vm";
|
||||
import type { Principal } from "../../next/data/index.ts";
|
||||
import type { Facet } from "../../next/host/index.ts";
|
||||
import { type BuiltFacet, PLUGINS_SPECIFIER, packageName } from "./build.ts";
|
||||
import type { Plugin } from "./discover.ts";
|
||||
import { registerScript } from "./stack.ts";
|
||||
|
||||
/**
|
||||
* The host packages by specifier, each what Node's `require` would give: a CommonJS package's exports, an ESM
|
||||
* package's namespace (`import * as`), wrapped once so default and named imports both work.
|
||||
*/
|
||||
export type HostPackages = Readonly<Record<string, unknown>>;
|
||||
|
||||
/** What `evaluateFacet` takes besides the script: the host packages and the plugin it was built from. */
|
||||
export type EvaluateOptions = { readonly hostPackages: HostPackages; readonly plugin: Plugin };
|
||||
|
||||
/** A built script, as evaluation takes it. */
|
||||
export type BuiltScript = Extract<BuiltFacet, { readonly kind: "script" }>;
|
||||
|
||||
/**
|
||||
* Evaluates `built` and returns its facet (its default export). Throws what the script throws while it is evaluated,
|
||||
* an import its `require` does not answer, or a declaration error when its default export is not a facet.
|
||||
*/
|
||||
export function evaluateFacet<P extends Principal = Principal>(built: BuiltScript, options: EvaluateOptions): Facet<P> {
|
||||
const { plugin, hostPackages } = options;
|
||||
const where = `plugin "${plugin.name}" (${built.facet})`;
|
||||
const file = plugin.facets[built.facet] ?? `${built.facet}.js`;
|
||||
const filename = `/chord-plugins/${plugin.name}/${basename(file)}?${built.digest}`;
|
||||
// The script is one function expression: its value when run.
|
||||
const wrapper = new Script(built.source, { filename }).runInThisContext() as (
|
||||
require: (specifier: string) => unknown,
|
||||
module: { exports: unknown },
|
||||
exports: unknown,
|
||||
) => void;
|
||||
const module = { exports: {} as unknown };
|
||||
let facet: unknown;
|
||||
try {
|
||||
wrapper(requireFor(plugin, hostPackages, where), module, module.exports);
|
||||
// A trust boundary: plugin code; its default export is checked to be a facet (a getter of it may throw too).
|
||||
facet = (module.exports as { readonly default?: unknown } | null)?.default;
|
||||
if (typeof facet !== "object" || facet === null || typeof (facet as { setup?: unknown }).setup !== "function") {
|
||||
throw new Error(`${where}: its default export is not a facet (defineFacet)`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Its stack maps while the error lives (an import-time throw names the plugin's line).
|
||||
if (typeof error === "object" && error !== null) registerScript(filename, built.source, plugin.dir, error);
|
||||
throw error;
|
||||
}
|
||||
registerScript(filename, built.source, plugin.dir, facet);
|
||||
return facet as Facet<P>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inside a facet script, the absolute path of `path`, a file of the facet's own plugin (`..` out of its directory
|
||||
* refused); evaluation gives each facet its own. Imported anywhere else, it throws.
|
||||
*/
|
||||
export function pluginFile(path: string): string {
|
||||
throw new Error(`pluginFile("${path}"): only a plugin's facet script, evaluated by the plugins component, has one`);
|
||||
}
|
||||
|
||||
/** The `require` a facet script of `plugin` takes (`where` names the facet in its errors). */
|
||||
function requireFor(plugin: Plugin, hostPackages: HostPackages, where: string): (specifier: string) => unknown {
|
||||
let fromPlugin: NodeJS.Require | undefined;
|
||||
let plugins: { readonly pluginFile: (path: string) => string } | undefined;
|
||||
return (specifier) => {
|
||||
if (Object.hasOwn(hostPackages, specifier)) return interop(hostPackages[specifier]);
|
||||
if (specifier === PLUGINS_SPECIFIER) {
|
||||
plugins ??= { pluginFile: (path) => fileOf(plugin, path) };
|
||||
return plugins;
|
||||
}
|
||||
if (isBuiltin(specifier) || plugin.external.includes(packageName(specifier))) {
|
||||
fromPlugin ??= createRequire(join(plugin.dir, "package.json"));
|
||||
return fromPlugin(specifier);
|
||||
}
|
||||
throw new Error(
|
||||
`${where}: "${specifier}" is not a host package, a Node builtin or one of its chord.external packages`,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** The absolute path of `path` inside `plugin`'s directory; one out of it is refused. */
|
||||
function fileOf(plugin: Plugin, path: string): string {
|
||||
const file = resolve(plugin.dir, path);
|
||||
const rest = relative(plugin.dir, file);
|
||||
if (rest.startsWith("..") || isAbsolute(rest)) {
|
||||
throw new Error(`pluginFile("${path}"): outside the directory of plugin "${plugin.name}"`);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/** ESM namespaces wrapped for a CommonJS script, by namespace: one wrapper per namespace, held as long as it is. */
|
||||
const wrapped = new WeakMap<object, object>();
|
||||
|
||||
/**
|
||||
* A host package as a CommonJS script imports it: an ESM namespace wrapped once with `__esModule` (so esbuild's interop
|
||||
* reads its `default` for a default import, its names for named ones), any other value as it is.
|
||||
*/
|
||||
function interop(value: unknown): unknown {
|
||||
if (typeof value !== "object" || value === null || Object.prototype.toString.call(value) !== "[object Module]") {
|
||||
return value;
|
||||
}
|
||||
let made = wrapped.get(value);
|
||||
if (made === undefined) {
|
||||
made = { ...value, __esModule: true };
|
||||
wrapped.set(value, made);
|
||||
}
|
||||
return made;
|
||||
}
|
||||
25
packages/chord/src/next-node/plugins/index.ts
Normal file
25
packages/chord/src/next-node/plugins/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Chord v2's plugins component (`@earendil-works/chord/next/plugins` once v2 has its entry point; slice 7c): plugin
|
||||
// directories found, built into scripts, evaluated, loaded and reloaded per plugin, watched and rolled out. Node-only
|
||||
// (`fs`, esbuild, `vm`); esbuild is an optional peer dependency, imported only here.
|
||||
|
||||
export { type BuildInput, type BuildOptions, type BuiltFacet, buildFacet } from "./build.ts";
|
||||
export {
|
||||
type Discovery,
|
||||
type DiscoveryOptions,
|
||||
type DiscoveryProblem,
|
||||
discoverPlugins,
|
||||
type Plugin,
|
||||
} from "./discover.ts";
|
||||
export { type BuiltScript, type EvaluateOptions, evaluateFacet, type HostPackages, pluginFile } from "./evaluate.ts";
|
||||
export { type InstallOptions, installDependencies } from "./install.ts";
|
||||
export {
|
||||
type Built,
|
||||
createPlugins,
|
||||
type Plugins,
|
||||
type PluginsError,
|
||||
type PluginsOptions,
|
||||
type RolloutResult,
|
||||
type RolloutStep,
|
||||
} from "./plugins.ts";
|
||||
export { mapStack } from "./stack.ts";
|
||||
export type { PluginsState } from "./watcher.ts";
|
||||
47
packages/chord/src/next-node/plugins/install.ts
Normal file
47
packages/chord/src/next-node/plugins/install.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Installing a plugin's npm dependencies (slice 7c §5, decision "Slice 7c's points" (3)): as coding-agent installs a
|
||||
// package's dependencies, `npm install` in the plugin's real directory without dev dependencies, host peers or
|
||||
// lifecycle scripts; the command overridable.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Plugin } from "./discover.ts";
|
||||
|
||||
/** What `installDependencies` takes besides the plugin: the npm command (`["npm"]` by default, e.g. `["pnpm"]`). */
|
||||
export type InstallOptions = { readonly npmCommand?: readonly string[] };
|
||||
|
||||
/** The install's arguments: production dependencies only, no host peers, no scripts, no audit or funding notes. */
|
||||
const INSTALL_ARGS = ["install", "--omit=dev", "--legacy-peer-deps", "--ignore-scripts", "--no-audit", "--no-fund"];
|
||||
|
||||
/**
|
||||
* Installs `plugin`'s `dependencies` into its own `node_modules` (`npm install --omit=dev --legacy-peer-deps
|
||||
* --ignore-scripts --no-audit --no-fund` in its real directory). Rejects with the command's output when it fails, and
|
||||
* when `context` aborts (the command is killed).
|
||||
*/
|
||||
export async function installDependencies(
|
||||
plugin: Plugin,
|
||||
context: Context,
|
||||
options: InstallOptions = {},
|
||||
): Promise<void> {
|
||||
const [command, ...prefix] = options.npmCommand ?? ["npm"];
|
||||
if (command === undefined) throw new Error("installDependencies: npmCommand is empty");
|
||||
context.abortSignal?.throwIfAborted();
|
||||
const child = spawn(command, [...prefix, ...INSTALL_ARGS], {
|
||||
cwd: plugin.dir,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
signal: context.abortSignal,
|
||||
});
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
const code = await new Promise<number | null>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", resolve);
|
||||
});
|
||||
if (code !== 0) {
|
||||
throw new Error(`plugin "${plugin.name}": ${command} install failed (exit ${code}): ${output.trim()}`);
|
||||
}
|
||||
}
|
||||
407
packages/chord/src/next-node/plugins/plugins.ts
Normal file
407
packages/chord/src/next-node/plugins/plugins.ts
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
// The plugins component (slice 7c §4, §5; decision "Slice 7c's points"): discovery, builds, evaluation and the watcher
|
||||
// together, and per host the facet loaded for every plugin, so a reload passes the host its complete plugin set
|
||||
// (unchanged plugins as the same facet objects, removed ones left out) and never restarts an unchanged plugin; per
|
||||
// plugin, a failed build or evaluation keeps the plugin's loaded facet (left out at startup) and is reported with the
|
||||
// host's report. `rollout` runs the app's steps in order and clears the marks of plugins applied on every step.
|
||||
|
||||
import { setFlagsFromString } from "node:v8";
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import { isChordError, type Principal } from "../../next/data/index.ts";
|
||||
import type {
|
||||
CommittedReload,
|
||||
Facet,
|
||||
Host,
|
||||
LoadReport,
|
||||
PluginFacet,
|
||||
PluginFailure,
|
||||
ReloadReport,
|
||||
} from "../../next/host/index.ts";
|
||||
import { Batch, type BatchError } from "../../next/state/internal.ts";
|
||||
import type { Watchable } from "../../watch.ts";
|
||||
import { type BuiltFacet, buildFacet } from "./build.ts";
|
||||
import { type Discovery, discoverPlugins, type Plugin } from "./discover.ts";
|
||||
import { evaluateFacet, type HostPackages } from "./evaluate.ts";
|
||||
import { type InstallOptions, installDependencies } from "./install.ts";
|
||||
import { type MarkSnapshot, type PluginsState, PluginWatcher } from "./watcher.ts";
|
||||
|
||||
/** What `createPlugins` takes. */
|
||||
export type PluginsOptions = {
|
||||
/** The directories plugins are listed in, in order: the first root's plugin wins a duplicate name. */
|
||||
readonly roots: readonly string[];
|
||||
/** The app's facet kinds (`["server", "worker", "web", "tui"]`): one file per kind in a plugin. */
|
||||
readonly kinds: readonly string[];
|
||||
/** The kinds built for a browser (`["web"]`); the others are built for Node. */
|
||||
readonly browserKinds?: readonly string[];
|
||||
/** The host packages by specifier: external in every build, given to every evaluation on Node. */
|
||||
readonly hostPackages: HostPackages;
|
||||
/** The app's base code: a change sets `base` (the app restarts its hosts; Chord restarts nothing). */
|
||||
readonly base?: readonly string[];
|
||||
/** Where the app serves browser builds' assets: the URL prefix their imports evaluate to. */
|
||||
readonly publicPath?: string;
|
||||
/** How `installDependencies` runs npm. */
|
||||
readonly install?: InstallOptions;
|
||||
/** The watcher cannot watch (reported once), and the marks' watch listeners that threw. */
|
||||
readonly onError: (error: PluginsError) => void;
|
||||
};
|
||||
|
||||
/** What the component reports. */
|
||||
export type PluginsError = BatchError | { readonly kind: "watch_failed"; readonly error: unknown };
|
||||
|
||||
/**
|
||||
* A build of some plugins, plain data (a clone of it reloads the same): every facet of each, the plugins as its
|
||||
* discovery saw them by name (what a reload evaluates with), and the plugins no longer discovered.
|
||||
*/
|
||||
export type Built = {
|
||||
readonly facets: readonly BuiltFacet[];
|
||||
readonly plugins: Readonly<Record<string, Plugin>>;
|
||||
readonly removed: readonly string[];
|
||||
};
|
||||
|
||||
/** What a rollout step does: reloads its host (or hosts) with the facets of its kinds from a build. */
|
||||
export type RolloutStep = {
|
||||
readonly name: string;
|
||||
readonly kinds: readonly string[];
|
||||
reload(built: Built, context: Context): Promise<ReloadReport>;
|
||||
};
|
||||
|
||||
/** What a rollout did: each step's report in order, and the step it stopped at (rejected), if it stopped. */
|
||||
export type RolloutResult = {
|
||||
readonly steps: readonly { readonly name: string; readonly report: ReloadReport }[];
|
||||
readonly stopped: { readonly step: string; readonly error: unknown } | undefined;
|
||||
};
|
||||
|
||||
/** The plugins component. */
|
||||
export interface Plugins {
|
||||
/** The marks: `{ dirty, install, set, base }`, a JSON watchable. */
|
||||
readonly state: Watchable<PluginsState>;
|
||||
/** The plugins of the last discovery. */
|
||||
readonly discovery: Discovery;
|
||||
/**
|
||||
* Builds every facet of `names` (and of plugins discovered since the last build; discovery runs again first when
|
||||
* the plugin set changed): plain data, reused by later loads and reloads.
|
||||
*/
|
||||
build(names: readonly string[], context: Context): Promise<Built>;
|
||||
/**
|
||||
* Builds (if needed) and evaluates the `kind` facet of every discovered plugin and loads them with `core` (the app's
|
||||
* own facets): a plugin whose build or evaluation fails is left out and reported with the host's report.
|
||||
*/
|
||||
load<P extends Principal>(
|
||||
host: Host<P>,
|
||||
options: { readonly kind: string; readonly core: readonly PluginFacet<P>[] },
|
||||
context: Context,
|
||||
): Promise<LoadReport>;
|
||||
/**
|
||||
* Reloads `host` (loaded by `load`) with the facets of its kind in `built`, after its earlier reloads: the complete
|
||||
* set (the plugins it loaded and those `built` brings), its core facets and unchanged plugins as the same objects,
|
||||
* new scripts evaluated, a plugin whose build or evaluation failed passed as its loaded facet (an added one left out,
|
||||
* both given to the host's closure rule), the plugins `built` removed left out. The host's report, those failures
|
||||
* included; what the host committed is the loaded set also when the reload rejects `aborted` after its commit.
|
||||
*/
|
||||
reload<P extends Principal>(host: Host<P>, built: Built, context: Context): Promise<ReloadReport>;
|
||||
/** An in-process rollout step named `name`: `reload(host, built)`, for the kind `host` was loaded with. */
|
||||
step<P extends Principal>(name: string, host: Host<P>): RolloutStep;
|
||||
/**
|
||||
* Runs `steps` in order with `built` and stops at one that rejects (a host closed, an abort); failed plugins do not
|
||||
* stop it. Clears the dirty marks of the built plugins applied on every step (not failed on any) that did not
|
||||
* change since the build.
|
||||
*/
|
||||
rollout(steps: readonly RolloutStep[], built: Built, context: Context): Promise<RolloutResult>;
|
||||
/** Installs plugin `name`'s dependencies (`installDependencies`): its install mark clears, and it is dirty. */
|
||||
installDependencies(name: string, context: Context): Promise<void>;
|
||||
/** Stops the watcher. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the plugins component: turns V8's compilation cache off (once per process: each distinct evaluated script
|
||||
* would stay cached) and starts the watcher. Discovery runs at the first `build` or `load`.
|
||||
*/
|
||||
export function createPlugins(options: PluginsOptions, _context: Context): Plugins {
|
||||
if (!cacheOff) {
|
||||
setFlagsFromString("--no-compilation-cache");
|
||||
cacheOff = true;
|
||||
}
|
||||
return new PluginsComponent(options);
|
||||
}
|
||||
|
||||
/** Whether this process turned V8's compilation cache off already. */
|
||||
let cacheOff = false;
|
||||
|
||||
/** A plugin's facet as a host loaded it, with its script's digest. */
|
||||
type LoadedFacet = { readonly facet: Facet<Principal>; readonly digest: string };
|
||||
|
||||
/**
|
||||
* A host's plugins as the component loaded them: its kind, its core facets, each plugin's loaded facet, and its
|
||||
* reloads, one after another (each computes its complete set once the previous one adopted its result).
|
||||
*/
|
||||
type HostPlugins = {
|
||||
readonly kind: string;
|
||||
readonly core: readonly PluginFacet<Principal>[];
|
||||
readonly loaded: Map<string, LoadedFacet>;
|
||||
reloading: Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adopts as `state`'s loaded facets the set `next` a reload committed: a plugin the host kept old (`failed`) stays loaded
|
||||
* as it was, an added one it left out is not loaded.
|
||||
*/
|
||||
function adopt(state: HostPlugins, next: Map<string, LoadedFacet>, failed: readonly string[]): void {
|
||||
for (const plugin of failed) {
|
||||
const loaded = state.loaded.get(plugin);
|
||||
if (loaded === undefined) next.delete(plugin);
|
||||
else next.set(plugin, loaded);
|
||||
}
|
||||
state.loaded.clear();
|
||||
for (const [name, each] of next) state.loaded.set(name, each);
|
||||
}
|
||||
|
||||
/** What the commit of a reload that rejected applied: an `aborted` error after its commit says (`details.committed`). */
|
||||
function committedOf(error: unknown): CommittedReload | undefined {
|
||||
if (!isChordError(error, "aborted")) return undefined;
|
||||
const details = error.details as { readonly committed?: CommittedReload } | null | undefined;
|
||||
// The kernel's own error: its `committed` is a `CommittedReload`.
|
||||
return typeof details === "object" && details !== null ? details.committed : undefined;
|
||||
}
|
||||
|
||||
/** A plugin's facet passed to a host, or why it was not: a build or evaluation failure. */
|
||||
type Prepared =
|
||||
| { readonly kind: "facet"; readonly facet: Facet<Principal>; readonly digest: string }
|
||||
| { readonly kind: "failed"; readonly error: unknown };
|
||||
|
||||
class PluginsComponent implements Plugins {
|
||||
readonly #options: PluginsOptions;
|
||||
readonly #watcher: PluginWatcher;
|
||||
#discovery: Discovery = { plugins: [], problems: [] };
|
||||
#discovered = false;
|
||||
/** The latest build of every (plugin, kind) built so far, by `plugin\u0000kind`. */
|
||||
readonly #built = new Map<string, BuiltFacet>();
|
||||
/** Each loaded host's plugins, held as long as the host is. */
|
||||
readonly #hosts = new WeakMap<object, HostPlugins>();
|
||||
/** The marks each build started from, held as long as its result is (a rollout given a copy clears none). */
|
||||
readonly #snapshots = new WeakMap<Built, MarkSnapshot>();
|
||||
|
||||
constructor(options: PluginsOptions) {
|
||||
this.#options = options;
|
||||
const batch = new Batch((error) => options.onError(error), undefined);
|
||||
this.#watcher = new PluginWatcher(
|
||||
{
|
||||
roots: options.roots,
|
||||
base: options.base ?? [],
|
||||
kinds: options.kinds,
|
||||
failed: (error) => options.onError({ kind: "watch_failed", error }),
|
||||
},
|
||||
batch,
|
||||
);
|
||||
}
|
||||
|
||||
get state(): Watchable<PluginsState> {
|
||||
// Its value is a `PluginsState`.
|
||||
return this.#watcher as unknown as Watchable<PluginsState>;
|
||||
}
|
||||
|
||||
get discovery(): Discovery {
|
||||
return this.#discovery;
|
||||
}
|
||||
|
||||
async build(names: readonly string[], context: Context): Promise<Built> {
|
||||
const before = new Set(this.#discovery.plugins.map(({ name }) => name));
|
||||
if (!this.#discovered || this.#watcher.needsDiscovery) await this.#discover(context);
|
||||
const now = this.#discovery.plugins;
|
||||
const removed = [...before].filter((name) => !now.some((plugin) => plugin.name === name));
|
||||
const wanted = now.filter(({ name }) => names.includes(name) || !before.has(name));
|
||||
const snapshot = this.#watcher.snapshot([...wanted.map(({ name }) => name), ...removed]);
|
||||
const facets = await Promise.all(
|
||||
wanted.flatMap((plugin) => Object.keys(plugin.facets).map((kind) => this.#buildOne(plugin, kind, context))),
|
||||
);
|
||||
const plugins = Object.fromEntries(wanted.map((plugin) => [plugin.name, plugin]));
|
||||
const built: Built = { facets, plugins, removed };
|
||||
this.#snapshots.set(built, snapshot);
|
||||
return built;
|
||||
}
|
||||
|
||||
async load<P extends Principal>(
|
||||
host: Host<P>,
|
||||
options: { readonly kind: string; readonly core: readonly PluginFacet<P>[] },
|
||||
context: Context,
|
||||
): Promise<LoadReport> {
|
||||
const { kind } = options;
|
||||
if (!this.#discovered) await this.#discover(context);
|
||||
const plugins = this.#discovery.plugins.filter((plugin) => plugin.facets[kind] !== undefined);
|
||||
const prepared = await Promise.all(
|
||||
plugins.map(async (plugin) => {
|
||||
const key = `${plugin.name}\u0000${kind}`;
|
||||
const built = this.#built.get(key) ?? (await this.#buildOne(plugin, kind, context));
|
||||
return [plugin.name, this.#prepare(plugin, built)] as const;
|
||||
}),
|
||||
);
|
||||
const failed: PluginFailure[] = [];
|
||||
const facets: PluginFacet<P>[] = [...options.core];
|
||||
for (const [plugin, each] of prepared) {
|
||||
if (each.kind === "failed") failed.push({ plugin, error: each.error });
|
||||
// Evaluated for this host's principal type: the facets are the app's (`Facet<P>` is invariant in `P`).
|
||||
else facets.push({ name: plugin, facet: each.facet as unknown as Facet<P> });
|
||||
}
|
||||
const report = await host.load(facets, context);
|
||||
const left = new Set(report.failed.map(({ plugin }) => plugin));
|
||||
const loaded = new Map<string, LoadedFacet>();
|
||||
for (const [plugin, each] of prepared) {
|
||||
if (each.kind === "facet" && !left.has(plugin)) loaded.set(plugin, each);
|
||||
}
|
||||
// The host's own facets, for its later reloads.
|
||||
const core = options.core as unknown as readonly PluginFacet<Principal>[];
|
||||
this.#hosts.set(host, { kind, core, loaded, reloading: Promise.resolve() });
|
||||
return { failed: [...failed, ...report.failed] };
|
||||
}
|
||||
|
||||
reload<P extends Principal>(host: Host<P>, built: Built, context: Context): Promise<ReloadReport> {
|
||||
const state = this.#hosts.get(host);
|
||||
if (state === undefined) throw new Error("plugins.reload: the host was not loaded by this component");
|
||||
// One after another per host: a reload computing its set from a loaded map an earlier one is about to change
|
||||
// would pass that plugin's old facet and roll it back.
|
||||
const reload = state.reloading.then(() => this.#reload(host, state, built, context));
|
||||
// The tail settles to nothing: a report kept as its value would keep a rejected facet's closure (its error).
|
||||
state.reloading = reload.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return reload;
|
||||
}
|
||||
|
||||
/**
|
||||
* One reload of `host` with `built`: the plugins it loaded and those `built` brings, without the ones `built`
|
||||
* removed or rebuilt without a facet of the host's kind (its own discovery's, never a later one's).
|
||||
*/
|
||||
async #reload<P extends Principal>(
|
||||
host: Host<P>,
|
||||
state: HostPlugins,
|
||||
built: Built,
|
||||
context: Context,
|
||||
): Promise<ReloadReport> {
|
||||
const { kind } = state;
|
||||
const fresh = new Map<string, BuiltFacet>();
|
||||
for (const facet of built.facets) if (facet.facet === kind) fresh.set(facet.plugin, facet);
|
||||
const names = [...new Set([...state.loaded.keys(), ...fresh.keys()])].sort();
|
||||
const failed: PluginFailure[] = [];
|
||||
const next = new Map<string, LoadedFacet>();
|
||||
for (const name of names) {
|
||||
// Own properties only: a plugin may be named like one of `Object.prototype`'s (`constructor`).
|
||||
const plugin = Object.hasOwn(built.plugins, name) ? built.plugins[name] : undefined;
|
||||
// Removed by this build's discovery, or rebuilt without a facet of this kind: left out.
|
||||
if (built.removed.includes(name) || (plugin !== undefined && plugin.facets[kind] === undefined)) continue;
|
||||
const loaded = state.loaded.get(name);
|
||||
const script = fresh.get(name);
|
||||
if (
|
||||
script === undefined ||
|
||||
plugin === undefined ||
|
||||
(script.kind === "script" && script.digest === loaded?.digest)
|
||||
) {
|
||||
if (loaded !== undefined) next.set(name, loaded);
|
||||
continue;
|
||||
}
|
||||
const prepared = this.#prepare(plugin, script);
|
||||
if (prepared.kind === "facet") {
|
||||
next.set(name, prepared);
|
||||
continue;
|
||||
}
|
||||
failed.push({ plugin: name, error: prepared.error });
|
||||
if (loaded !== undefined) next.set(name, loaded);
|
||||
}
|
||||
const facets = [...state.core, ...[...next].map(([name, { facet }]) => ({ name, facet }))];
|
||||
// The host takes facets of its own principal type: the component loaded them for it. The plugins whose build or
|
||||
// evaluation failed are the host's to report, and its closure rule's to consider.
|
||||
let report: ReloadReport;
|
||||
try {
|
||||
report = await host.reload(facets as unknown as readonly PluginFacet<P>[], context, { failed });
|
||||
} catch (error) {
|
||||
// Aborted after its commit: the commit stands, and is what the host runs now.
|
||||
const committed = committedOf(error);
|
||||
if (committed !== undefined) adopt(state, next, committed.failed);
|
||||
throw error;
|
||||
}
|
||||
adopt(
|
||||
state,
|
||||
next,
|
||||
report.failed.map(({ plugin }) => plugin),
|
||||
);
|
||||
return report;
|
||||
}
|
||||
|
||||
step<P extends Principal>(name: string, host: Host<P>): RolloutStep {
|
||||
const state = this.#hosts.get(host);
|
||||
if (state === undefined) throw new Error("plugins.step: the host was not loaded by this component");
|
||||
return { name, kinds: [state.kind], reload: (built, context) => this.reload(host, built, context) };
|
||||
}
|
||||
|
||||
async rollout(steps: readonly RolloutStep[], built: Built, context: Context): Promise<RolloutResult> {
|
||||
const done: { readonly name: string; readonly report: ReloadReport }[] = [];
|
||||
const failed = new Set<string>();
|
||||
for (const step of steps) {
|
||||
let report: ReloadReport;
|
||||
try {
|
||||
report = await step.reload(built, context);
|
||||
} catch (error) {
|
||||
return { steps: done, stopped: { step: step.name, error } };
|
||||
}
|
||||
for (const { plugin } of report.failed) failed.add(plugin);
|
||||
done.push({ name: step.name, report });
|
||||
}
|
||||
const snapshot = this.#snapshots.get(built);
|
||||
if (snapshot !== undefined) {
|
||||
this.#watcher.applied(
|
||||
[...snapshot.plugins.keys()].filter((name) => !failed.has(name)),
|
||||
snapshot,
|
||||
);
|
||||
}
|
||||
return { steps: done, stopped: undefined };
|
||||
}
|
||||
|
||||
async installDependencies(name: string, context: Context): Promise<void> {
|
||||
if (!this.#discovered) await this.#discover(context);
|
||||
const plugin = this.#discovery.plugins.find((each) => each.name === name);
|
||||
if (plugin === undefined) throw new Error(`plugins.installDependencies: no plugin "${name}"`);
|
||||
await installDependencies(plugin, context, this.#options.install);
|
||||
this.#watcher.installed(name);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#watcher.close();
|
||||
}
|
||||
|
||||
/** Discovers the plugins again; the changes of the plugin set and of `package.json`s seen until now clear. */
|
||||
async #discover(context: Context): Promise<void> {
|
||||
const seen = this.#watcher.discoverySnapshot();
|
||||
this.#discovery = await discoverPlugins(this.#options.roots, { kinds: this.#options.kinds }, context);
|
||||
this.#discovered = true;
|
||||
this.#watcher.know(this.#discovery, seen);
|
||||
}
|
||||
|
||||
/** Builds `plugin`'s facet of `kind` and keeps it as the latest; the watcher learns what it read. */
|
||||
async #buildOne(plugin: Plugin, kind: string, context: Context): Promise<BuiltFacet> {
|
||||
const { hostPackages, browserKinds = [], publicPath } = this.#options;
|
||||
const platform = browserKinds.includes(kind) ? "browser" : "node";
|
||||
const options = {
|
||||
hostPackages: Object.keys(hostPackages),
|
||||
platform,
|
||||
plugins: this.#discovery.plugins,
|
||||
...(publicPath === undefined ? {} : { publicPath }),
|
||||
} as const;
|
||||
const built = await buildFacet(plugin, kind, options, context);
|
||||
this.#built.set(`${plugin.name}\u0000${kind}`, built);
|
||||
this.#watcher.used([built]);
|
||||
return built;
|
||||
}
|
||||
|
||||
/** `built` evaluated for `plugin`, or why not: its build failed, or its evaluation threw. */
|
||||
#prepare(plugin: Plugin, built: BuiltFacet): Prepared {
|
||||
if (built.kind === "failed") {
|
||||
const message = `plugin "${plugin.name}": its ${built.facet} facet failed to build:\n${built.errors.join("\n")}`;
|
||||
return { kind: "failed", error: new Error(message) };
|
||||
}
|
||||
try {
|
||||
const facet = evaluateFacet(built, { hostPackages: this.#options.hostPackages, plugin });
|
||||
return { kind: "facet", facet, digest: built.digest };
|
||||
} catch (error) {
|
||||
return { kind: "failed", error };
|
||||
}
|
||||
}
|
||||
}
|
||||
67
packages/chord/src/next-node/plugins/stack.ts
Normal file
67
packages/chord/src/next-node/plugins/stack.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// Stack traces of plugin scripts (slice 7c §3, decision Q1 (b)): `vm.Script` maps no positions through a source map, so
|
||||
// every evaluated script registers its map under its filename while something of its evaluation lives (its facet, or
|
||||
// the error it threw while evaluated), and `mapStack` rewrites an error's stack through them, for the app's reporters.
|
||||
// Nothing global: no `Error.prepareStackTrace`, no process-wide source-map switch. The registry is a module-level `Map`
|
||||
// keyed by filename, not a `WeakMap` keyed by an owner: `mapStack` starts from a stack's text, which names only the
|
||||
// filename. Its entries go with the last evaluation of their script (a `FinalizationRegistry` counts them down).
|
||||
|
||||
import { SourceMap } from "node:module";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
/** A registered script: how its positions map, its plugin's directory, and how many of its evaluations live. */
|
||||
type Registered = { readonly map: SourceMap; readonly dir: string; live: number };
|
||||
|
||||
/** The evaluated scripts by filename, while an evaluation of each lives. */
|
||||
const scripts = new Map<string, Registered>();
|
||||
|
||||
/** Counts down an evaluation that was collected; its script's entry goes with the last one. */
|
||||
const collected = new FinalizationRegistry<string>((filename) => {
|
||||
const script = scripts.get(filename);
|
||||
if (script !== undefined && --script.live === 0) scripts.delete(filename);
|
||||
});
|
||||
|
||||
/**
|
||||
* Registers the script `source` evaluated under `filename` for plugin directory `dir` while `owner` (its facet, or the
|
||||
* error its evaluation threw) lives: its inline source map, whose sources are relative to the plugin's directory. Two
|
||||
* evaluations of one script (two hosts) share the entry.
|
||||
*/
|
||||
export function registerScript(filename: string, source: string, dir: string, owner: object): void {
|
||||
let script = scripts.get(filename);
|
||||
if (script === undefined) {
|
||||
const map = inlineSourceMap(source);
|
||||
if (map === undefined) return;
|
||||
script = { map, dir, live: 0 };
|
||||
scripts.set(filename, script);
|
||||
}
|
||||
script.live++;
|
||||
collected.register(owner, filename);
|
||||
}
|
||||
|
||||
/** The inline source map esbuild appended to a built script, if it has one. */
|
||||
export function inlineSourceMap(source: string): SourceMap | undefined {
|
||||
const inline = SOURCE_MAP.exec(source);
|
||||
return inline === null ? undefined : new SourceMap(JSON.parse(Buffer.from(inline[1]!, "base64").toString("utf8")));
|
||||
}
|
||||
|
||||
/** The inline source map esbuild appends to a script. */
|
||||
const SOURCE_MAP = /\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)\s*$/;
|
||||
|
||||
/**
|
||||
* `error`'s stack with every position in a registered plugin script mapped through its source map to the plugin's file
|
||||
* and line (`notes/worker.ts:12:3`: the plugin's directory name and the file's path inside it); other lines as they
|
||||
* are. A value that is no error with a stack gives its string.
|
||||
*/
|
||||
export function mapStack(error: unknown): string {
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
if (stack === undefined) return String(error);
|
||||
return stack.replace(POSITION, (whole, filename: string, line: string, column: string) => {
|
||||
const script = scripts.get(filename);
|
||||
const entry = script?.map.findEntry(Number(line) - 1, Number(column) - 1);
|
||||
if (script === undefined || entry === undefined || !("originalSource" in entry)) return whole;
|
||||
const file = join(basename(script.dir), entry.originalSource).split("\\").join("/");
|
||||
return `${file}:${entry.originalLine + 1}:${entry.originalColumn + 1}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** A position in a stack line: `filename:line:column`, the filename being anything up to it (spaces included). */
|
||||
const POSITION = /([^\s()]+):(\d+):(\d+)/g;
|
||||
300
packages/chord/src/next-node/plugins/watcher.ts
Normal file
300
packages/chord/src/next-node/plugins/watcher.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
// The watcher and its marks (slice 7c §5): `fs.watch` on every directory of the plugin roots and the app's base code,
|
||||
// `node_modules` and `.git` never watched (a directory appearing later gets its watch), marks what changed and never
|
||||
// applies it. A changed file dirties every plugin whose facets used it (a copied contract every user) or whose directory
|
||||
// contains it; a changed `package.json` asks for an install (and for its metadata to be read again); a plugin directory
|
||||
// or facet file added or removed asks for discovery again; a file of base code asks the app to restart its hosts. The
|
||||
// marks are a JSON watchable; each mark records the generation of its latest change (generations only increase), so a
|
||||
// change during a build keeps its mark, however late an older build's rollout clears.
|
||||
|
||||
import { type Dirent, type FSWatcher, lstatSync, readdirSync, realpathSync, watch } from "node:fs";
|
||||
import { basename, dirname, join, resolve, sep } from "node:path";
|
||||
import { BACKGROUND_CONTEXT, type Context } from "../../context/index.ts";
|
||||
import type { JsonValue } from "../../json.ts";
|
||||
import { type Batch, Source } from "../../next/state/internal.ts";
|
||||
import { type BuiltFacet, inside } from "./build.ts";
|
||||
import { type Discovery, FACET_EXTENSIONS } from "./discover.ts";
|
||||
|
||||
/** The marks: plugins to rebuild, plugins to install, whether to discover again, whether base code changed. */
|
||||
export type PluginsState = {
|
||||
readonly dirty: readonly string[];
|
||||
readonly install: readonly string[];
|
||||
readonly set: boolean;
|
||||
readonly base: boolean;
|
||||
};
|
||||
|
||||
/** The generation of each dirty plugin's latest change when a build started: what its rollout may clear. */
|
||||
export type MarkSnapshot = { readonly plugins: ReadonlyMap<string, number> };
|
||||
|
||||
/** The generations of the latest change of the plugin set and of a `package.json` when a discovery started. */
|
||||
export type DiscoverySnapshot = { readonly set: number; readonly manifest: number };
|
||||
|
||||
/** What the watcher watches and how it reports: the roots, the base code, the facet kinds, a failure to watch. */
|
||||
export type WatcherOptions = {
|
||||
readonly roots: readonly string[];
|
||||
readonly base: readonly string[];
|
||||
readonly kinds: readonly string[];
|
||||
/** The watcher cannot watch (the system's limit): reported once, after which the app builds on demand. */
|
||||
readonly failed: (error: unknown) => void;
|
||||
};
|
||||
|
||||
/** Directories never watched, and changes under them never marked. */
|
||||
const IGNORED = new Set(["node_modules", ".git"]);
|
||||
|
||||
/**
|
||||
* The watcher and the marks (`state`), a JSON watchable published at most once per microtask turn with changes. It
|
||||
* marks for the plugins of the last discovery (`know`) and the files their last builds read (`used`).
|
||||
*/
|
||||
export class PluginWatcher extends Source {
|
||||
readonly #options: WatcherOptions;
|
||||
readonly #roots: readonly string[];
|
||||
readonly #baseDirs: readonly string[];
|
||||
/** The trees it watches: the roots, the base code, and plugin directories linked from outside the roots. */
|
||||
readonly #trees = new Set<string>();
|
||||
/** Its watches, one per directory of its trees. */
|
||||
readonly #watchers = new Map<string, FSWatcher>();
|
||||
/** A failure to watch was reported (once). */
|
||||
#reported = false;
|
||||
/** The generation the next change gets: every mark records a newer one than any before it. */
|
||||
#generation = 1;
|
||||
/** Each dirty plugin's latest change, by generation. */
|
||||
readonly #dirty = new Map<string, number>();
|
||||
readonly #install = new Set<string>();
|
||||
/** The latest change of the plugin set since the last discovery (0: none). */
|
||||
#set = 0;
|
||||
/** The latest change of a known plugin's `package.json` since the last discovery (0: none). */
|
||||
#manifest = 0;
|
||||
#baseChanged = false;
|
||||
#discovery: Discovery = { plugins: [], problems: [] };
|
||||
/** The files each built facet read (its inputs and copies), by plugin and kind. */
|
||||
readonly #used = new Map<string, { readonly plugin: string; readonly files: ReadonlySet<string> }>();
|
||||
#value: JsonValue | undefined;
|
||||
#scheduled = false;
|
||||
|
||||
constructor(options: WatcherOptions, batch: Batch) {
|
||||
super(batch);
|
||||
this.#options = options;
|
||||
// Events name paths under the directories as watched: by their real paths, as discovery names plugins.
|
||||
this.#roots = options.roots.map(real);
|
||||
this.#baseDirs = options.base.map(real);
|
||||
for (const dir of [...this.#roots, ...this.#baseDirs]) this.#watchTree(dir);
|
||||
}
|
||||
|
||||
get value(): JsonValue {
|
||||
this.#value ??= {
|
||||
dirty: [...this.#dirty.keys()].sort(),
|
||||
install: [...this.#install].sort(),
|
||||
set: this.#set > 0,
|
||||
base: this.#baseChanged,
|
||||
};
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
deliver(context: Context): void {
|
||||
const value = this.value;
|
||||
this.publish(value, [["r", value]], false, context);
|
||||
}
|
||||
|
||||
/** Whether the next build discovers again: the plugin set or a plugin's `package.json` changed. */
|
||||
get needsDiscovery(): boolean {
|
||||
return this.#set > 0 || this.#manifest > 0;
|
||||
}
|
||||
|
||||
/** What a discovery that starts now has seen. */
|
||||
discoverySnapshot(): DiscoverySnapshot {
|
||||
return { set: this.#set, manifest: this.#manifest };
|
||||
}
|
||||
|
||||
/** The plugins discovery found: marks are for them (a removed one's install clears); the changes it had seen clear. */
|
||||
know(discovery: Discovery, seen: DiscoverySnapshot): void {
|
||||
this.#discovery = discovery;
|
||||
if (this.#set === seen.set) this.#set = 0;
|
||||
if (this.#manifest === seen.manifest) this.#manifest = 0;
|
||||
// A removed plugin has nothing to install (its directory went, its package.json with it).
|
||||
for (const name of this.#install)
|
||||
if (!discovery.plugins.some((plugin) => plugin.name === name)) this.#install.delete(name);
|
||||
// A plugin directory linked from outside the roots: a tree of its own (a symlink is not followed).
|
||||
const linked = discovery.plugins.filter(({ dir }) => !this.#roots.some((root) => inside(root, dir)));
|
||||
for (const { dir } of linked) this.#watchTree(dir);
|
||||
for (const tree of this.#trees) {
|
||||
const kept =
|
||||
this.#roots.includes(tree) || this.#baseDirs.includes(tree) || linked.some((each) => each.dir === tree);
|
||||
if (!kept) this.#unwatchTree(tree);
|
||||
}
|
||||
this.#changed();
|
||||
}
|
||||
|
||||
/** What `built` facets read: a later change of one of these files dirties their plugins. */
|
||||
used(built: readonly BuiltFacet[]): void {
|
||||
for (const facet of built) {
|
||||
if (facet.kind !== "script") continue;
|
||||
const files = new Set([...facet.inputs.map(({ file }) => file), ...facet.copied]);
|
||||
this.#used.set(`${facet.plugin}\u0000${facet.facet}`, { plugin: facet.plugin, files });
|
||||
}
|
||||
}
|
||||
|
||||
/** The marks a build of `names` starts from: each one's latest change. */
|
||||
snapshot(names: Iterable<string>): MarkSnapshot {
|
||||
const plugins = new Map<string, number>();
|
||||
for (const name of names) plugins.set(name, this.#dirty.get(name) ?? 0);
|
||||
return { plugins };
|
||||
}
|
||||
|
||||
/** `names` were applied everywhere: their marks clear unless a change came after `snapshot`. */
|
||||
applied(names: Iterable<string>, snapshot: MarkSnapshot): void {
|
||||
for (const name of names) {
|
||||
const latest = this.#dirty.get(name);
|
||||
if (latest !== undefined && latest <= (snapshot.plugins.get(name) ?? 0)) this.#dirty.delete(name);
|
||||
}
|
||||
this.#changed();
|
||||
}
|
||||
|
||||
/** Plugin `name`'s dependencies were installed: its install mark clears and it is dirty (its packages changed). */
|
||||
installed(name: string): void {
|
||||
this.#install.delete(name);
|
||||
this.#dirty.set(name, this.#generation++);
|
||||
this.#changed();
|
||||
}
|
||||
|
||||
/** Marks what a change of the file or directory at `path` (absolute) asks for. */
|
||||
mark(path: string): void {
|
||||
if (path.split(sep).some((part) => IGNORED.has(part))) return;
|
||||
if (this.#baseDirs.some((dir) => inside(dir, path))) {
|
||||
this.#baseChanged = true;
|
||||
} else {
|
||||
this.#markPlugin(path, this.#generation++);
|
||||
}
|
||||
this.#changed();
|
||||
}
|
||||
|
||||
/** Stops watching. */
|
||||
close(): void {
|
||||
for (const watcher of this.#watchers.values()) watcher.close();
|
||||
this.#watchers.clear();
|
||||
this.#trees.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* A change under a root, at `generation`: in a plugin's directory, it dirties that plugin (and asks for an install
|
||||
* and a new reading of its metadata for its `package.json`, for discovery for a facet file at its top); elsewhere
|
||||
* under a root (a directory added or removed, a directory that is no plugin yet), discovery; every plugin whose
|
||||
* facets read the file is dirty too.
|
||||
*/
|
||||
#markPlugin(path: string, generation: number): void {
|
||||
const plugin = this.#discovery.plugins.find((each) => each.dir === path || inside(each.dir, path));
|
||||
if (plugin === undefined) {
|
||||
if (this.#roots.some((root) => inside(root, path))) this.#set = generation;
|
||||
} else if (path === join(plugin.dir, "package.json")) {
|
||||
this.#install.add(plugin.name);
|
||||
this.#manifest = generation;
|
||||
} else {
|
||||
if (path === plugin.dir || (dirname(path) === plugin.dir && this.#isFacetFile(basename(path)))) {
|
||||
this.#set = generation;
|
||||
}
|
||||
this.#dirty.set(plugin.name, generation);
|
||||
}
|
||||
for (const { plugin: user, files } of this.#used.values()) if (files.has(path)) this.#dirty.set(user, generation);
|
||||
}
|
||||
|
||||
/** Whether `file` (at a plugin's top) is named as a facet file of one of the app's kinds. */
|
||||
#isFacetFile(file: string): boolean {
|
||||
return this.#options.kinds.some((kind) => FACET_EXTENSIONS.some((extension) => file === `${kind}${extension}`));
|
||||
}
|
||||
|
||||
/** Watches the tree at `dir` (once): it and every directory under it but ignored ones; a missing one is skipped. */
|
||||
#watchTree(dir: string): void {
|
||||
if (this.#trees.has(dir)) return;
|
||||
this.#trees.add(dir);
|
||||
this.#watchDirs(dir);
|
||||
}
|
||||
|
||||
/** Stops watching the tree at `dir`. */
|
||||
#unwatchTree(dir: string): void {
|
||||
this.#trees.delete(dir);
|
||||
this.#unwatchDirs(dir);
|
||||
}
|
||||
|
||||
/** Watches `dir` and, walking down, every directory under it but ignored ones (each not watched yet). */
|
||||
#watchDirs(dir: string): void {
|
||||
if (this.#watchers.has(dir) || this.#reported) return;
|
||||
let watcher: FSWatcher;
|
||||
try {
|
||||
watcher = watch(dir, (_event, file) => {
|
||||
if (file !== null) this.#changedIn(join(dir, file));
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") this.#failed(error);
|
||||
return;
|
||||
}
|
||||
watcher.on("error", (error) => this.#failed(error));
|
||||
this.#watchers.set(dir, watcher);
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Removed meanwhile: its parent's event says so.
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !IGNORED.has(entry.name)) this.#watchDirs(join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
/** An event for `path` in a watched directory: a new directory gets its watches, a removed one loses them; marked. */
|
||||
#changedIn(path: string): void {
|
||||
const name = basename(path);
|
||||
if (!IGNORED.has(name)) {
|
||||
if (isDirectory(path)) this.#watchDirs(path);
|
||||
else if (this.#watchers.has(path)) this.#unwatchDirs(path);
|
||||
}
|
||||
this.mark(path);
|
||||
}
|
||||
|
||||
/** Stops watching `dir` and the directories under it (it was removed, or its tree is not watched any more). */
|
||||
#unwatchDirs(dir: string): void {
|
||||
for (const [watched, watcher] of this.#watchers) {
|
||||
if (watched !== dir && !inside(dir, watched)) continue;
|
||||
watcher.close();
|
||||
this.#watchers.delete(watched);
|
||||
}
|
||||
}
|
||||
|
||||
/** Watching failed: reported once; every watch stops (the app builds on demand). */
|
||||
#failed(error: unknown): void {
|
||||
this.close();
|
||||
if (this.#reported) return;
|
||||
this.#reported = true;
|
||||
this.#options.failed(error);
|
||||
}
|
||||
|
||||
/** The marks changed: one publication per microtask turn while someone watches or derives from them. */
|
||||
#changed(): void {
|
||||
this.#value = undefined;
|
||||
if (this.#scheduled || (!this.watched && this.dependents.size === 0)) return;
|
||||
this.#scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this.#scheduled = false;
|
||||
this.batch.within(() => this.changed(), BACKGROUND_CONTEXT);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `path` is a directory, never following a link (as the scan of a tree does not); one that cannot be read (gone,
|
||||
* a link loop, no access) is none: a watch callback must not throw.
|
||||
*/
|
||||
function isDirectory(path: string): boolean {
|
||||
try {
|
||||
return lstatSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** `dir` absolute and by its real path; as given (absolute) when it does not exist. */
|
||||
function real(dir: string): string {
|
||||
try {
|
||||
return realpathSync(dir);
|
||||
} catch {
|
||||
return resolve(dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -247,7 +247,21 @@ server.accept(webSocketLink(ws), { principal, limits: { part: 16 * MiB, decoded:
|
|||
resolves when each has its first outcome (decision (a)): live, failed by its own cause, parked with no blocker that
|
||||
is an object here still without its first outcome (a keyed pin still loading counts as here; a wait on another host
|
||||
or on a failed dependency ends the first pass), or ended (`close` settles a waiting `load`); later retries never
|
||||
hold it. A setup that throws rejects it with that error and closes the host as `close` does, its links included.
|
||||
hold it. Per plugin (decision "Per plugin at startup and reload"): a plugin whose setup throws or is refused at
|
||||
declaration (an invalid or repeated name, a type or registry an earlier plugin serves, a remote type here without
|
||||
principal or address, `checkAccepts`) is left out; `load` resolves with `{ failed: [{ plugin, error }] }` and never
|
||||
closes the host; its consumers wait `failed { waiting }`.
|
||||
- `host.reload([{ name, facet }], ctx)` (slice 7b, per plugin since 7c): the complete new plugin set; staging runs the
|
||||
new code per plugin (unchanged and failed plugins keep their loaded entries, their setups not run again). A plugin
|
||||
whose new code fails (its setup, a factory against the recording `self`, a declaration error, a name another plugin
|
||||
serves) keeps its old code (an added one is left out) and staging runs again without it; then a changed plugin whose
|
||||
recorded requests would not bind because of such a plugin (its old code serving the name, or nobody serving it)
|
||||
keeps its old code too (the closure rule), to a fixpoint; a request is checked as a bind checks it, the provider's
|
||||
host address included. `reload(facets, ctx, { failed })` names plugins the caller passes as their loaded facet
|
||||
because their new code failed before (a build, an evaluation: the plugins component): the same for the closure rule.
|
||||
The report lists them all in `failed`; the rest commits. `ctx`'s abort before the commit rejects `aborted` and
|
||||
changes nothing; after it, it ends only the wait for the report: `aborted` with `details.committed`
|
||||
(`CommittedReload`: `added`, `changed`, `removed`, `failed` by plugin name), the commit standing.
|
||||
- `host.hold(Type, params, ctx, { readyTimeout? })`: a reference at once (`Held<C>`: its `variant` is `undefined` until
|
||||
its first binding), which follows its object until released and never rejects: `loading` before the publication,
|
||||
while the object activates, retries or is parked on dependencies under way, while there is no parent link or it is
|
||||
|
|
|
|||
|
|
@ -43,6 +43,14 @@ export class Destination {
|
|||
return { kind: "path", path: this.#pathTo(address, principal, context) ?? this.#parent, address };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a reference to `Type` with `params` goes here: a local type, or a remote one whose host address is this
|
||||
* host's. A `hostAddress` that throws, throws.
|
||||
*/
|
||||
placedHere(Type: Contract, params: ParamsOf<Contract>): boolean {
|
||||
return Type.kind === "local" || this.#isHere(hostAddressOf(Type, params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a hold arriving on `link` acting as `principal` goes, by the address it names: here when it is this host's
|
||||
* (a relay never captures a hold for serving the same name); over the route of its kind; else over the parent link
|
||||
|
|
|
|||
|
|
@ -56,7 +56,19 @@ import { Binding, type CheckedParams, type Held, type Reference, type Target } f
|
|||
import { RevocationKeys, type RevokedBy } from "./revocation.ts";
|
||||
import { type RootOptions, RootScopes } from "./root.ts";
|
||||
import { Route, type RouteOptions } from "./route.ts";
|
||||
import { type PluginFacet, recordFactory, type Staged, type StagingHost, stagePlugins } from "./staging.ts";
|
||||
import {
|
||||
addStaged,
|
||||
emptyStaged,
|
||||
type PluginFacet,
|
||||
type Recorded,
|
||||
type Request,
|
||||
recordFactory,
|
||||
type Staged,
|
||||
type StagedPlugin,
|
||||
type StagedRegistry,
|
||||
type StagingHost,
|
||||
stagePlugin,
|
||||
} from "./staging.ts";
|
||||
import { UnboundIndex, type UnboundRequest } from "./unbound.ts";
|
||||
import { uuidv7 } from "./uuid.ts";
|
||||
|
||||
|
|
@ -201,22 +213,28 @@ export interface Host<P extends Principal = Principal, R extends JsonValue = Jso
|
|||
* registries composed once, the contributions); requests that waited for the publication are placed after that
|
||||
* batch. Then activates every served type with empty params and resolves when each has had its first outcome (the
|
||||
* start barrier, decision (a)): live, failed by its own cause, parked with no blocker here still without its first
|
||||
* outcome, or ended (failures go to `onError`). A setup that throws rejects it and closes the host. Runs once.
|
||||
* outcome, or ended (failures go to `onError`). A plugin whose setup throws or is refused at declaration (its name,
|
||||
* a type or registry another plugin serves, a remote type here without principal or address, `checkAccepts`) is
|
||||
* left out and reported (`failed`); it never closes the host. Runs once.
|
||||
*/
|
||||
load(facets: readonly PluginFacet<P>[], context: Context): Promise<void>;
|
||||
load(facets: readonly PluginFacet<P>[], context: Context): Promise<LoadReport>;
|
||||
/**
|
||||
* Reloads this host's plugins (slice 7b): `facets` is its complete new plugin set; a plugin in it and not loaded
|
||||
* is added, one loaded with another facet value is changed (the same facet object is unchanged: untouched), one
|
||||
* loaded and not in it is removed. Staging runs every setup and every affected object's new factory against a
|
||||
* recording `self`; any throw rejects it and nothing changed. Then it commits in one batch (types, registries,
|
||||
* loaded and not in it is removed. Staging runs every new setup and every affected object's new factory against a
|
||||
* recording `self`, per plugin: a plugin whose new code fails there keeps its old code (an added one is left out),
|
||||
* and so does a changed plugin whose recorded requests would not bind because of it (the closure rule); both are
|
||||
* reported (`failed`); `options.failed` names plugins the caller passes as their loaded facet because their new
|
||||
* code failed before (a build, an evaluation): the same for the closure rule and the report. Then it commits in one
|
||||
* batch (types, registries,
|
||||
* contributions; a publication waking the requests for the names it serves) and every object of a changed or
|
||||
* removed plugin takes its fate: swapped in place, restarted after its drain, retried, or ended (its type
|
||||
* removed). Resolves with the report once every fate started (swaps and retries checked their dependencies; it
|
||||
* does not wait for objects to go live). Reloads run one after another. Throws before `load` resolved; rejects
|
||||
* `released/closed` after `close`; `context`'s abort before the commit rejects `aborted` and changes nothing, after
|
||||
* it only ends the wait.
|
||||
* it only ends the wait (`aborted`, its `details.committed` naming what the commit applied: `CommittedReload`).
|
||||
*/
|
||||
reload(facets: readonly PluginFacet<P>[], context: Context): Promise<ReloadReport>;
|
||||
reload(facets: readonly PluginFacet<P>[], context: Context, options?: ReloadOptions): Promise<ReloadReport>;
|
||||
/**
|
||||
* A reference once its object is live (and hydrated). It acts as the host's principal and goes where its type's host
|
||||
* address puts it: here, over a route, or over the parent link (`Destination`). It waits while the reference is
|
||||
|
|
@ -294,13 +312,38 @@ export interface Host<P extends Principal = Principal, R extends JsonValue = Jso
|
|||
close(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
/** What a reload did (slice 7b §1): its plugins by change, and what every affected object did. */
|
||||
/** A plugin left out at `load`, or whose new code failed at a reload (it kept its old code, if it had any), and why. */
|
||||
export type PluginFailure = { readonly plugin: string; readonly error: unknown };
|
||||
|
||||
/**
|
||||
* What a reload's commit applied, by plugin name: the `details.committed` of the `aborted` error of a reload whose
|
||||
* context aborted after its commit (the commit stands; only the wait for the report ended).
|
||||
*/
|
||||
export type CommittedReload = {
|
||||
readonly added: readonly string[];
|
||||
readonly changed: readonly string[];
|
||||
readonly removed: readonly string[];
|
||||
/** The plugins whose new code failed: they kept their old code (an added one was left out). */
|
||||
readonly failed: readonly string[];
|
||||
};
|
||||
|
||||
/** What `host.reload` takes besides the plugin set: the plugins whose new code failed before it (a build, an evaluation). */
|
||||
export type ReloadOptions = { readonly failed?: readonly PluginFailure[] };
|
||||
|
||||
/** What `load` did (decision "Per plugin at startup and reload"): the plugins it left out. */
|
||||
export type LoadReport = { readonly failed: readonly PluginFailure[] };
|
||||
|
||||
/**
|
||||
* What a reload did (slice 7b §1): its plugins by change (those applied), the plugins whose new code failed, and what
|
||||
* every affected object did.
|
||||
*/
|
||||
export type ReloadReport = {
|
||||
readonly plugins: {
|
||||
readonly added: readonly string[];
|
||||
readonly changed: readonly string[];
|
||||
readonly removed: readonly string[];
|
||||
};
|
||||
readonly failed: readonly PluginFailure[];
|
||||
/** Every live, activating, parked or failed object of a changed or removed plugin, by its label. */
|
||||
readonly objects: readonly ReloadedObject[];
|
||||
};
|
||||
|
|
@ -326,23 +369,38 @@ export type Serving =
|
|||
| { readonly kind: "object"; readonly object: ChordObject }
|
||||
| { readonly kind: "waiting"; readonly cause: FailureCause };
|
||||
|
||||
/** A published plugin: its facet (an unchanged reload passes the same object), its registries and contributions. */
|
||||
/**
|
||||
* A published plugin: its facet (an unchanged reload passes the same object), what its setup staged (a reload that
|
||||
* keeps it uses it again, its setup not run) and its contributions in the registries' tables.
|
||||
*/
|
||||
type LoadedPlugin<P extends Principal> = {
|
||||
readonly facet: Facet<P>;
|
||||
readonly registries: readonly string[];
|
||||
readonly staged: StagedPlugin;
|
||||
readonly contributions: readonly RegistryContribution[];
|
||||
};
|
||||
|
||||
/** What a reload staged: the staged set, the plugins by change, and each affected object's fate. */
|
||||
/**
|
||||
* What a reload staged: the staged set, the facets of the plugins it applies, the plugins by change, those that failed,
|
||||
* and each affected object's fate.
|
||||
*/
|
||||
type ReloadPlan<P extends Principal> = {
|
||||
readonly staged: Staged;
|
||||
readonly facets: ReadonlyMap<string, Facet<P>>;
|
||||
readonly added: readonly string[];
|
||||
readonly changed: readonly string[];
|
||||
readonly removed: readonly string[];
|
||||
readonly failed: readonly PluginFailure[];
|
||||
readonly fates: readonly PlannedFate[];
|
||||
};
|
||||
|
||||
/** One staging pass of a reload: its plan, or the plugins it found failing (staging runs again without their new code). */
|
||||
type StagingPass<P extends Principal> =
|
||||
| { readonly kind: "staged"; readonly plan: ReloadPlan<P>; readonly recorded: readonly RecordedFactory[] }
|
||||
| { readonly kind: "failed"; readonly failed: ReadonlyMap<string, unknown> };
|
||||
|
||||
/** A recorded new factory of an object staging planned a fate for: whose new code it is, and what it declared. */
|
||||
type RecordedFactory = { readonly plugin: string; readonly recorded: Recorded };
|
||||
|
||||
/** An affected object's fate, as staging decided it. */
|
||||
type PlannedFate =
|
||||
| { readonly kind: "end"; readonly object: ChordObject }
|
||||
|
|
@ -389,7 +447,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
|
|||
/** The published plugins by name: their facet (an unchanged one is the same object), registries and contributions. */
|
||||
readonly #plugins = new Map<string, LoadedPlugin<P>>();
|
||||
/** The reloads, one after another (slice 7b §1): the last one's end, which the next one waits for. */
|
||||
#reloading: Promise<unknown> = Promise.resolve();
|
||||
#reloading: Promise<void> = Promise.resolve();
|
||||
/** By identity, in the order they went live (the others where they were created). */
|
||||
readonly #objects = new Map<string, ChordObject>();
|
||||
/** Teardowns still running, by identity: the next instance of an identity waits for its previous ones. */
|
||||
|
|
@ -443,26 +501,31 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
|
|||
this.store?.attach({ failed: (error) => this.#storageFailed(error), batch: this.batch });
|
||||
}
|
||||
|
||||
async load(facets: readonly PluginFacet<P>[], context: Context): Promise<void> {
|
||||
async load(facets: readonly PluginFacet<P>[], context: Context): Promise<LoadReport> {
|
||||
this.batch.assertOutside("load");
|
||||
this.#assertOpen();
|
||||
if (this.#state !== "new") throw new Error("load runs once per host");
|
||||
this.#state = "loading";
|
||||
try {
|
||||
const staged = stagePlugins(facets, this.#stagingHost());
|
||||
this.#types = staged.types;
|
||||
// The publication: the types and registries served, the facets' contributions and the first composition of
|
||||
// every served registry, in one batch, before any object activates.
|
||||
this.batch.commit(() => {
|
||||
this.#publish(staged, staged.contributions.keys(), facetsByName(facets));
|
||||
for (const record of this.#registries.values()) record.composeServed();
|
||||
}, context);
|
||||
} catch (error) {
|
||||
// The host closes as `close` would: its links too. Nothing was activated, so no close can fail.
|
||||
this.#closing ??= this.#close();
|
||||
await this.#closing.catch(() => {});
|
||||
throw error;
|
||||
const staged = emptyStaged();
|
||||
const loaded = new Map<string, Facet<P>>();
|
||||
const failed: PluginFailure[] = [];
|
||||
// A name given twice is refused the second time whether or not the first staged.
|
||||
const given = new Set<string>();
|
||||
// In the given order: of two plugins serving one name, the later is left out.
|
||||
for (const plugin of facets) {
|
||||
const twice = given.has(plugin.name);
|
||||
given.add(plugin.name);
|
||||
const error = twice ? loadedTwice(plugin.name) : this.#stageInto(staged, plugin);
|
||||
if (error === undefined) loaded.set(plugin.name, plugin.facet);
|
||||
else failed.push({ plugin: plugin.name, error });
|
||||
}
|
||||
this.#types = staged.types;
|
||||
// The publication: the types and registries served, the facets' contributions and the first composition of
|
||||
// every served registry, in one batch, before any object activates.
|
||||
this.batch.commit(() => {
|
||||
this.#publish(staged, loaded);
|
||||
for (const record of this.#registries.values()) record.composeServed();
|
||||
}, context);
|
||||
// After the batch: what waited for the publication is placed (the gate lasted only for the setups).
|
||||
this.#published = true;
|
||||
this.#publication.resolve();
|
||||
|
|
@ -472,48 +535,133 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
|
|||
.map((served) => this.#object(served, EMPTY_PARAMS, undefined, context));
|
||||
await Promise.all(objects.map(({ firstOutcome }) => firstOutcome));
|
||||
if (this.#state === "loading") this.#state = "loaded";
|
||||
return { failed };
|
||||
}
|
||||
|
||||
reload(facets: readonly PluginFacet<P>[], context: Context): Promise<ReloadReport> {
|
||||
/**
|
||||
* Whether a request for `Type` with `params` binds to `served` as a real bind checks it (`bind`): its kind, version
|
||||
* and fingerprint, and the provider's own host address; one whose `hostAddress` throws does not.
|
||||
*/
|
||||
#wouldBind(served: Served, Type: Contract, params: ParamsOf<Contract>): boolean {
|
||||
if (unboundCause(served, Type.kind, Type.name, Type.version, Type.fingerprint) !== undefined) return false;
|
||||
try {
|
||||
return this.#misplaced(Type, served, params) === undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a request for `Type` with `params` goes here; a `hostAddress` that throws: not here (it fails at bind). */
|
||||
#placedHere(Type: Contract, params: ParamsOf<Contract>): boolean {
|
||||
try {
|
||||
return this.#destination.placedHere(Type, params);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stages `plugin`'s new code into `staged`: the error that refuses it (its setup, a declaration, a name served twice). */
|
||||
#stageInto(staged: Staged, plugin: PluginFacet<P>): unknown {
|
||||
try {
|
||||
return addStaged(staged, stagePlugin(plugin, this.#stagingHost()));
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
reload(facets: readonly PluginFacet<P>[], context: Context, options: ReloadOptions = {}): Promise<ReloadReport> {
|
||||
this.batch.assertOutside("reload");
|
||||
if (this.#state === "closed") return Promise.reject(this.#closedError());
|
||||
if (this.#state !== "loaded") throw new Error("reload runs after load");
|
||||
const reload = this.#reloading.then(() => this.#reload(facets, context));
|
||||
this.#reloading = reload.catch(() => {});
|
||||
const reload = this.#reloading.then(() => this.#reload(facets, options.failed ?? [], context));
|
||||
// The tail settles to nothing: a report kept as its value would keep a rejected facet's closure (its error).
|
||||
this.#reloading = reload.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return reload;
|
||||
}
|
||||
|
||||
/** One reload, after the previous one: staging, the commit, the fates, the report. */
|
||||
async #reload(facets: readonly PluginFacet<P>[], context: Context): Promise<ReloadReport> {
|
||||
async #reload(
|
||||
facets: readonly PluginFacet<P>[],
|
||||
before: readonly PluginFailure[],
|
||||
context: Context,
|
||||
): Promise<ReloadReport> {
|
||||
this.#assertOpen();
|
||||
if (context.abortSignal?.aborted) throw reloadAborted();
|
||||
const plan = this.#stageReload(facets, context);
|
||||
// Aborted while it staged (a setup may abort it synchronously): rejected as a staging error, nothing changed.
|
||||
const plan = this.#stageReload(facets, before, context);
|
||||
// Aborted while it staged (a setup may abort it synchronously): nothing changed.
|
||||
if (context.abortSignal?.aborted) throw reloadAborted();
|
||||
const started = this.#commit(plan, context);
|
||||
const objects = await this.#report(started, context);
|
||||
const { added, changed, removed } = plan;
|
||||
return { plugins: { added, changed, removed }, objects };
|
||||
const { added, changed, removed, failed } = plan;
|
||||
const plugins = { added, changed, removed };
|
||||
const committed: CommittedReload = { ...plugins, failed: failed.map(({ plugin }) => plugin) };
|
||||
const objects = await this.#report(started, committed, context);
|
||||
return { plugins, failed, objects };
|
||||
}
|
||||
|
||||
/**
|
||||
* Staging (slice 7b §2), synchronous (no request or arriving hold meets it half done): every setup runs into a
|
||||
* staged table (`stagePlugins`), every object of a changed or removed plugin whose type is still served has its new
|
||||
* factory recorded, which decides its fate. A throw rejects the reload with nothing changed.
|
||||
* Staging (slice 7b §2), synchronous (no request or arriving hold meets it half done), per plugin (decision "Per
|
||||
* plugin at startup and reload"): a pass stages the new code (`#stagePass`); a plugin whose new code fails there
|
||||
* (or failed `before`, passed as its loaded facet) keeps its old code (an added one is left out) and staging runs
|
||||
* again without it; then a changed plugin whose recorded requests would not bind because of such a plugin keeps its
|
||||
* old code too (`#closure`), until nothing more fails. Bounded by the number of plugins with new code.
|
||||
*/
|
||||
#stageReload(facets: readonly PluginFacet<P>[], context: Context): ReloadPlan<P> {
|
||||
const staged = stagePlugins(facets, this.#stagingHost());
|
||||
const byName = facetsByName(facets);
|
||||
const added: string[] = [];
|
||||
const changed: string[] = [];
|
||||
const removed = [...this.#plugins.keys()].filter((name) => !byName.has(name));
|
||||
for (const [name, facet] of byName) {
|
||||
const loaded = this.#plugins.get(name);
|
||||
if (loaded === undefined) added.push(name);
|
||||
else if (loaded.facet !== facet) changed.push(name);
|
||||
#stageReload(facets: readonly PluginFacet<P>[], before: readonly PluginFailure[], context: Context): ReloadPlan<P> {
|
||||
const failed = new Map(before.map(({ plugin, error }) => [plugin, error]));
|
||||
const given = new Map<string, Facet<P>>();
|
||||
// A name given twice: the second is refused, the first staged.
|
||||
const twice: PluginFailure[] = [];
|
||||
for (const { name, facet } of facets) {
|
||||
if (given.has(name)) twice.push({ plugin: name, error: loadedTwice(name) });
|
||||
else given.set(name, facet);
|
||||
}
|
||||
const reloaded = new Set([...changed, ...removed]);
|
||||
for (;;) {
|
||||
const pass = this.#stagePass(given, failed, context);
|
||||
if (pass.kind === "failed") {
|
||||
for (const [name, error] of pass.failed) failed.set(name, error);
|
||||
continue;
|
||||
}
|
||||
const kept = this.#closure(pass.plan, pass.recorded, failed);
|
||||
if (kept.size > 0) {
|
||||
for (const [name, error] of kept) failed.set(name, error);
|
||||
continue;
|
||||
}
|
||||
const failures = [...failed].map(([plugin, error]) => ({ plugin, error }));
|
||||
return { ...pass.plan, failed: [...twice, ...failures] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One staging pass: the loaded entries of the plugins kept as they are (unchanged, or failed: their setups not run
|
||||
* again) first, then every other plugin's new code in the given order (one refused by a name the table has already
|
||||
* fails), then the new factory of every object of a changed or removed plugin whose type is still served (a throw
|
||||
* fails the plugin serving it). Its plan, or the plugins that failed.
|
||||
*/
|
||||
#stagePass(
|
||||
given: ReadonlyMap<string, Facet<P>>,
|
||||
failed: ReadonlyMap<string, unknown>,
|
||||
context: Context,
|
||||
): StagingPass<P> {
|
||||
const staged = emptyStaged();
|
||||
const failing = new Map<string, unknown>();
|
||||
const fresh: PluginFacet<P>[] = [];
|
||||
for (const [name, facet] of given) {
|
||||
const loaded = this.#plugins.get(name);
|
||||
if (loaded !== undefined && (loaded.facet === facet || failed.has(name))) addStaged(staged, loaded.staged);
|
||||
else if (!failed.has(name)) fresh.push({ name, facet });
|
||||
}
|
||||
for (const plugin of fresh) {
|
||||
const error = this.#stageInto(staged, plugin);
|
||||
if (error !== undefined) failing.set(plugin.name, error);
|
||||
}
|
||||
if (failing.size > 0) return { kind: "failed", failed: failing };
|
||||
const applied = new Set(fresh.map(({ name }) => name));
|
||||
const removed = [...this.#plugins.keys()].filter((name) => !given.has(name));
|
||||
const reloaded = new Set([...applied, ...removed]);
|
||||
const fates: PlannedFate[] = [];
|
||||
const recorded: RecordedFactory[] = [];
|
||||
for (const object of this.#objects.values()) {
|
||||
if (!reloaded.has(object.served.plugin)) continue;
|
||||
const served = staged.types.get(object.served.contract.name);
|
||||
|
|
@ -521,9 +669,70 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
|
|||
fates.push({ kind: "end", object });
|
||||
continue;
|
||||
}
|
||||
fates.push({ kind: object.reloadFate(recordFactory(object, served, context)), object, served });
|
||||
try {
|
||||
const declared = recordFactory(object, served, context);
|
||||
recorded.push({ plugin: served.plugin, recorded: declared });
|
||||
fates.push({ kind: object.reloadFate(declared), object, served });
|
||||
} catch (error) {
|
||||
if (!failing.has(served.plugin)) failing.set(served.plugin, error);
|
||||
}
|
||||
}
|
||||
return { staged, facets: byName, added, changed, removed, fates };
|
||||
if (failing.size > 0) return { kind: "failed", failed: failing };
|
||||
const facets = new Map(fresh.map(({ name, facet }) => [name, facet]));
|
||||
const added = fresh.filter(({ name }) => !this.#plugins.has(name)).map(({ name }) => name);
|
||||
const changed = fresh.filter(({ name }) => this.#plugins.has(name)).map(({ name }) => name);
|
||||
const plan = { staged, facets, added, changed, removed, failed: [], fates };
|
||||
return { kind: "staged", plan, recorded };
|
||||
}
|
||||
|
||||
/**
|
||||
* The closure rule: the changed plugins (new code applied) of `plan` with a recorded request that would not bind in
|
||||
* the staged table because of a plugin that failed in this reload: its name served there by such a plugin's old code,
|
||||
* or not served at all while some plugin failed (what a failed setup would have served is unknown). Requests to
|
||||
* another host are not checked (a rollout's rules). Each such plugin with the error it keeps its old code for.
|
||||
*/
|
||||
#closure(
|
||||
plan: ReloadPlan<P>,
|
||||
recorded: readonly RecordedFactory[],
|
||||
failed: ReadonlyMap<string, unknown>,
|
||||
): Map<string, unknown> {
|
||||
const kept = new Map<string, unknown>();
|
||||
if (failed.size === 0) return kept;
|
||||
for (const { plugin, recorded: declared } of recorded) {
|
||||
if (kept.has(plugin) || !plan.facets.has(plugin)) continue;
|
||||
for (const request of declared.requests) {
|
||||
const unbound = this.#unboundRequest(plan.staged, request, failed);
|
||||
if (unbound === undefined) continue;
|
||||
kept.set(plugin, new Error(`plugin "${plugin}": ${unbound}`));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `request` (of a new factory) would not bind in `staged` because of a plugin in `failed`; `undefined`
|
||||
* when it binds, is placed on another host, or does not bind for another reason (it waits as any request does).
|
||||
*/
|
||||
#unboundRequest(staged: Staged, request: Request, failed: ReadonlyMap<string, unknown>): string | undefined {
|
||||
const name = request.kind === "type" ? request.contract.name : request.registry.name;
|
||||
const provider = request.kind === "type" ? staged.types.get(name)?.plugin : staged.registries.get(name)?.plugin;
|
||||
if (provider !== undefined && !failed.has(provider)) return undefined;
|
||||
if (request.kind === "type") {
|
||||
const { contract, params } = request;
|
||||
// A remote type the object reaches on another host: placed by the rollout's rules, not this host's.
|
||||
if (!this.#placedHere(contract, params)) return undefined;
|
||||
const served = staged.types.get(name);
|
||||
if (served !== undefined && this.#wouldBind(served, contract, params)) return undefined;
|
||||
return provider === undefined
|
||||
? `needs ${name} v${contract.version}, which no plugin serves now (a plugin failed)`
|
||||
: `needs ${name} v${contract.version}, which plugin "${provider}" serves on its old code`;
|
||||
}
|
||||
const served = staged.registries.get(name);
|
||||
if (served !== undefined && registryServes(served, request.registry.version)) return undefined;
|
||||
return provider === undefined
|
||||
? `needs registry ${name} v${request.registry.version}, which no plugin serves now (a plugin failed)`
|
||||
: `needs registry ${name} v${request.registry.version}, which plugin "${provider}" serves on its old code`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -534,26 +743,25 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
|
|||
* requests waiting for the names the reload serves.
|
||||
*/
|
||||
#commit(plan: ReloadPlan<P>, context: Context): readonly StartedFate[] {
|
||||
const { staged, added, changed, removed } = plan;
|
||||
const published = new Set([...changed, ...added]);
|
||||
const types = new Map<string, Served>();
|
||||
// An unchanged plugin's types stay as they are: its objects are untouched.
|
||||
for (const [name, served] of staged.types) {
|
||||
types.set(name, published.has(served.plugin) ? served : this.#types.get(name)!);
|
||||
}
|
||||
const { staged, changed, removed } = plan;
|
||||
const published = new Set(plan.facets.keys());
|
||||
// A kept plugin's types are its loaded ones, as they are: its objects are untouched.
|
||||
const types = staged.types;
|
||||
this.batch.commit(() => {
|
||||
for (const name of [...changed, ...removed]) {
|
||||
const loaded = this.#plugins.get(name)!;
|
||||
for (const registry of loaded.registries) {
|
||||
for (const registry of loaded.staged.registries.keys()) {
|
||||
if (staged.registries.get(registry)?.plugin !== name) this.registryRecord(registry).unserve();
|
||||
}
|
||||
for (const contribution of loaded.contributions) contribution.record.remove(contribution);
|
||||
this.#plugins.delete(name);
|
||||
}
|
||||
this.#types = types;
|
||||
this.#publish(staged, published, plan.facets);
|
||||
this.#publish(staged, plan.facets);
|
||||
for (const name of published) {
|
||||
for (const registry of this.#plugins.get(name)!.registries) this.registryRecord(registry).composeServed();
|
||||
for (const registry of staged.plugins.get(name)!.registries.keys()) {
|
||||
this.registryRecord(registry).composeServed();
|
||||
}
|
||||
}
|
||||
}, context);
|
||||
for (const name of this.#references.keys()) if (!types.has(name)) this.#references.delete(name);
|
||||
|
|
@ -588,9 +796,14 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
|
|||
|
||||
/**
|
||||
* The report, once every swap and retry had its first dependency check (a parked one reports why); restarts are
|
||||
* reported at once (their check follows the drain). `context`'s abort ends only this wait (`aborted`).
|
||||
* reported at once (their check follows the drain). `context`'s abort ends only this wait (`aborted`, its `details`
|
||||
* naming what the commit applied: `committed`).
|
||||
*/
|
||||
async #report(started: readonly StartedFate[], context: Context): Promise<ReloadedObject[]> {
|
||||
async #report(
|
||||
started: readonly StartedFate[],
|
||||
committed: CommittedReload,
|
||||
context: Context,
|
||||
): Promise<ReloadedObject[]> {
|
||||
const checks = started.map(async (fate): Promise<ReloadedObject> => {
|
||||
if (fate.kind === "ended" || fate.kind === "restarted") return { object: fate.object, kind: fate.kind };
|
||||
const cause = await fate.attempt.checked;
|
||||
|
|
@ -601,7 +814,11 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
|
|||
try {
|
||||
return await awaitWithContext(Promise.all(checks), context);
|
||||
} catch {
|
||||
throw reloadAborted();
|
||||
const { added, changed, removed, failed } = committed;
|
||||
const details = {
|
||||
committed: { added: [...added], changed: [...changed], removed: [...removed], failed: [...failed] },
|
||||
};
|
||||
throw new ChordError({ code: "aborted", message: "reload: aborted after its commit", details });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1185,18 +1402,16 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
|
|||
}
|
||||
|
||||
/**
|
||||
* Publishes staged plugins `names` of `staged`, inside the publication's batch: their types served (`types`), the
|
||||
* registries they serve served (and composed once), their facet contributions entered into the tables.
|
||||
* Publishes the staged plugins of `facets` (by name), inside the publication's batch: the registries they serve
|
||||
* served, their facet contributions entered into the tables (the types are the staged table's, set by the caller).
|
||||
*/
|
||||
#publish(staged: Staged, names: Iterable<string>, facets: ReadonlyMap<string, Facet<P>>): void {
|
||||
for (const name of names) {
|
||||
const registries: string[] = [];
|
||||
for (const [registry, { plugin, registry: token, provider }] of staged.registries) {
|
||||
if (plugin !== name) continue;
|
||||
#publish(staged: Staged, facets: ReadonlyMap<string, Facet<P>>): void {
|
||||
for (const [name, facet] of facets) {
|
||||
const plugin = staged.plugins.get(name)!;
|
||||
for (const [registry, { registry: token, provider }] of plugin.registries) {
|
||||
this.registryRecord(registry).serve(token.version, provider);
|
||||
registries.push(registry);
|
||||
}
|
||||
const contributions = (staged.contributions.get(name) ?? []).map(({ registry, value }) =>
|
||||
const contributions = plugin.contributions.map(({ registry, value }) =>
|
||||
contributionOf(
|
||||
this.registryRecord(registry.name),
|
||||
{ plugin: name, type: undefined },
|
||||
|
|
@ -1205,7 +1420,7 @@ export class ChordHost<P extends Principal = Principal, R extends JsonValue = Js
|
|||
),
|
||||
);
|
||||
for (const contribution of contributions) contribution.record.add(contribution);
|
||||
this.#plugins.set(name, { facet: facets.get(name)!, registries, contributions });
|
||||
this.#plugins.set(name, { facet, staged: plugin, contributions });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1264,12 +1479,18 @@ async function untilUsable(value: RegistryValueSource, usable: () => boolean, co
|
|||
/** What the report calls a fate. */
|
||||
const STARTED = { swap: "swapped", restart: "restarted", retry: "retried" } as const;
|
||||
|
||||
/** The `aborted` error of a reload whose context aborted: before its commit nothing changed; after it, only its wait. */
|
||||
/** The `aborted` error of a reload whose context aborted before its commit: nothing changed. */
|
||||
function reloadAborted(): ChordError {
|
||||
return new ChordError({ code: "aborted", message: "reload: aborted" });
|
||||
}
|
||||
|
||||
/** The facets of a plugin set by plugin name. */
|
||||
function facetsByName<P extends Principal>(facets: readonly PluginFacet<P>[]): Map<string, Facet<P>> {
|
||||
return new Map(facets.map(({ name, facet }) => [name, facet]));
|
||||
/** The error of a plugin name given twice: the second is refused. */
|
||||
function loadedTwice(name: string): Error {
|
||||
return new Error(`plugin "${name}" is loaded twice`);
|
||||
}
|
||||
|
||||
/** Whether registry provider `served` serves readers of `version`: its own, or an older one its `accepts` maps back. */
|
||||
function registryServes(served: StagedRegistry, version: number): boolean {
|
||||
const provided = served.registry.version;
|
||||
return provided === version || (provided > version && served.provider.accepts?.[version]?.value !== undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,19 @@ export type { Ctx } from "./ctx.ts";
|
|||
export { defineFacet, type Facet, type FacetHost, type FacetSelf, type ServeOptions } from "./facet.ts";
|
||||
export type { FollowAttach, FollowAttachment, FollowObserver } from "./follow.ts";
|
||||
export {
|
||||
type CommittedReload,
|
||||
createHost,
|
||||
type GetOptions,
|
||||
type Host,
|
||||
type HostError,
|
||||
type HostOptions,
|
||||
type LinkOptions,
|
||||
type LoadReport,
|
||||
type PluginFailure,
|
||||
type Position,
|
||||
type ReloadedObject,
|
||||
type ReloadOptions,
|
||||
type ReloadReport,
|
||||
} from "./host.ts";
|
||||
export type { Native, NativeCloseReason, NativeOptions } from "./native.ts";
|
||||
export type { Presence } from "./presence.ts";
|
||||
|
|
@ -24,3 +30,4 @@ export type { RootOptions } from "./root.ts";
|
|||
export type { RouteOptions } from "./route.ts";
|
||||
export type { OnHandler, OnLiveHandler, OnNativeHandler, RoutineDeclarations } from "./routines.ts";
|
||||
export { sleep, untilAborted } from "./sleep.ts";
|
||||
export type { PluginFacet } from "./staging.ts";
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
// Staging (slice 7b §2): a plugin set's setups run into a staged table beside the live one (the types it serves with
|
||||
// their providers, the registries it serves, its facet contributions by plugin) with `load`'s checks; and an object's
|
||||
// new factory run against a recording `self`, which keeps what the factory declares (natives, documents, tables, jobs,
|
||||
// contributions, requests) and activates, binds and opens nothing, so a reload decides each object's fate before its
|
||||
// commit. `load` stages its facets the same way and publishes them.
|
||||
// Staging (slice 7b §2; per plugin, decision "Per plugin at startup and reload"): each plugin's setup runs into a table of
|
||||
// its own (the types it serves with their providers, the registries it serves, its facet contributions) with `load`'s
|
||||
// checks, merged into the staged table beside the live one, where a type or registry another plugin serves already
|
||||
// fails the plugin; and an object's new factory run against a recording `self`, which keeps what the factory declares
|
||||
// (natives, documents, tables, jobs, contributions, requests with their contract versions) and activates, binds and
|
||||
// opens nothing, so a reload decides each object's fate, and which plugins keep their old code, before its commit.
|
||||
// `load` stages its facets the same way and publishes them.
|
||||
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Watchable } from "../../watch.ts";
|
||||
import type { Contract } from "../contracts/index.ts";
|
||||
import type { Contract, ParamsOf } from "../contracts/index.ts";
|
||||
import { contractData } from "../contracts/internal.ts";
|
||||
import type { Principal } from "../data/index.ts";
|
||||
import { bindProvider, checkParams } from "../data/internal.ts";
|
||||
|
|
@ -33,14 +35,22 @@ export type StagedRegistry = {
|
|||
readonly provider: RegistryProvider<unknown, unknown>;
|
||||
};
|
||||
|
||||
/** A plugin set's setups, run and checked: what it serves and contributes, nothing published yet. */
|
||||
export type Staged = {
|
||||
/** One plugin's setup, run and checked: what it serves and contributes, nothing published yet. */
|
||||
export type StagedPlugin = {
|
||||
readonly name: string;
|
||||
/** The served types by name, each with its plugin, factory, provider (its `accepts` checked) and `linger`. */
|
||||
readonly types: Map<string, Served>;
|
||||
readonly types: ReadonlyMap<string, Served>;
|
||||
/** The served registries by name. */
|
||||
readonly registries: ReadonlyMap<string, StagedRegistry>;
|
||||
/** The facet contributions, in the setup's order. */
|
||||
readonly contributions: readonly FacetContribution[];
|
||||
};
|
||||
|
||||
/** A plugin set's staged plugins, merged: what they serve, by name, and each plugin's own table. */
|
||||
export type Staged = {
|
||||
readonly types: Map<string, Served>;
|
||||
readonly registries: Map<string, StagedRegistry>;
|
||||
/** The facet contributions by plugin, in each setup's order. */
|
||||
readonly contributions: Map<string, readonly FacetContribution[]>;
|
||||
readonly plugins: Map<string, StagedPlugin>;
|
||||
};
|
||||
|
||||
/** What staging needs of its host: whom it acts as, where it lives, and its presence lists for setups. */
|
||||
|
|
@ -53,42 +63,75 @@ export type StagingHost = {
|
|||
/** An npm package name, scoped or not. */
|
||||
const PLUGIN_NAME = /^(?:@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/;
|
||||
|
||||
/**
|
||||
* Runs every facet's setup and checks what they declared: plugin names (npm names, each once), a type or registry
|
||||
* served twice, a remote type on a host without a principal or address, every provider's `accepts` (`checkAccepts`)
|
||||
* and every registry provider's shape. Throws the first error (naming its plugin where the check does not).
|
||||
*/
|
||||
export function stagePlugins<P extends Principal>(facets: readonly PluginFacet<P>[], host: StagingHost): Staged {
|
||||
const staged: Staged = { types: new Map(), registries: new Map(), contributions: new Map() };
|
||||
for (const { name, facet } of facets) {
|
||||
if (!PLUGIN_NAME.test(name)) throw new Error(`"${name}" is not a valid plugin name`);
|
||||
if (staged.contributions.has(name)) throw new Error(`plugin "${name}" is loaded twice`);
|
||||
// The host's presence lists are typed for its `P`.
|
||||
const references = host.references as <C extends Contract>(Type: C) => Watchable<readonly Presence<C, P>[]>;
|
||||
let setup: ReturnType<typeof setUp<P>>;
|
||||
try {
|
||||
setup = setUp(name, facet, references);
|
||||
} catch (error) {
|
||||
throw named(error, `plugin "${name}"`);
|
||||
}
|
||||
const { served, contributions } = setup;
|
||||
for (const entry of served) stageEntry(name, entry, staged, host);
|
||||
staged.contributions.set(name, contributions);
|
||||
}
|
||||
return staged;
|
||||
/** Whether `name` is a valid plugin name: an npm package name, scoped or not (the plugins component checks it too). */
|
||||
export function isPluginName(name: string): boolean {
|
||||
return PLUGIN_NAME.test(name);
|
||||
}
|
||||
|
||||
/** Stages one served entry of `plugin`: a registry's provider, or a type as `Served`. */
|
||||
function stageEntry(plugin: string, entry: ServedEntry, staged: Staged, host: StagingHost): void {
|
||||
/** A staged table with nothing in it. */
|
||||
export function emptyStaged(): Staged {
|
||||
return { types: new Map(), registries: new Map(), plugins: new Map() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one plugin's setup and checks what it declared: its name (an npm name), a type or registry it serves twice, a
|
||||
* remote type on a host without a principal or address, every provider's `accepts` (`checkAccepts`) and every registry
|
||||
* provider's shape. Throws the first error, naming the plugin.
|
||||
*/
|
||||
export function stagePlugin<P extends Principal>({ name, facet }: PluginFacet<P>, host: StagingHost): StagedPlugin {
|
||||
if (!isPluginName(name)) throw new Error(`"${name}" is not a valid plugin name`);
|
||||
// The host's presence lists are typed for its `P`.
|
||||
const references = host.references as <C extends Contract>(Type: C) => Watchable<readonly Presence<C, P>[]>;
|
||||
const types = new Map<string, Served>();
|
||||
const registries = new Map<string, StagedRegistry>();
|
||||
try {
|
||||
const { served, contributions } = setUp(name, facet, references);
|
||||
for (const entry of served) stageEntry(name, entry, types, registries, host);
|
||||
return { name, types, registries, contributions };
|
||||
} catch (error) {
|
||||
throw named(error, `plugin "${name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges `plugin` into `staged`; a type or registry another plugin serves there already refuses it (its error
|
||||
* returned, nothing merged).
|
||||
*/
|
||||
export function addStaged(staged: Staged, plugin: StagedPlugin): Error | undefined {
|
||||
for (const name of plugin.types.keys()) {
|
||||
const other = staged.types.get(name);
|
||||
if (other !== undefined)
|
||||
return new Error(`plugin "${plugin.name}": ${name} is served twice (plugin "${other.plugin}")`);
|
||||
}
|
||||
for (const name of plugin.registries.keys()) {
|
||||
const other = staged.registries.get(name);
|
||||
if (other !== undefined) {
|
||||
return new Error(`plugin "${plugin.name}": registry ${name} is served twice (plugin "${other.plugin}")`);
|
||||
}
|
||||
}
|
||||
for (const [name, served] of plugin.types) staged.types.set(name, served);
|
||||
for (const [name, registry] of plugin.registries) staged.registries.set(name, registry);
|
||||
staged.plugins.set(plugin.name, plugin);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Stages one served entry of `plugin` into its tables: a registry's provider, or a type as `Served`. */
|
||||
function stageEntry(
|
||||
plugin: string,
|
||||
entry: ServedEntry,
|
||||
types: Map<string, Served>,
|
||||
registries: Map<string, StagedRegistry>,
|
||||
host: StagingHost,
|
||||
): void {
|
||||
if (entry.kind === "registry") {
|
||||
const { registry, provider } = entry;
|
||||
if (staged.registries.has(registry.name)) throw new Error(`registry ${registry.name} is served twice`);
|
||||
if (registries.has(registry.name)) throw new Error(`registry ${registry.name} is served twice`);
|
||||
checkProvider(registry.name, provider);
|
||||
staged.registries.set(registry.name, { plugin, registry, provider });
|
||||
registries.set(registry.name, { plugin, registry, provider });
|
||||
return;
|
||||
}
|
||||
const { contract, factory, options } = entry;
|
||||
if (staged.types.has(contract.name)) throw new Error(`${contract.name} is served twice`);
|
||||
if (types.has(contract.name)) throw new Error(`${contract.name} is served twice`);
|
||||
// A client serves only local types (§3): a remote object is owned by a principal of the host serving it.
|
||||
if (contract.kind === "remote" && host.principal === undefined) {
|
||||
throw new Error(`${contract.name}: a host without a principal serves only local types`);
|
||||
|
|
@ -115,7 +158,7 @@ function stageEntry(plugin: string, entry: ServedEntry, staged: Staged, host: St
|
|||
contract: contract as RemoteContract,
|
||||
factory: factory as Extract<Served, { kind: "remote" }>["factory"],
|
||||
};
|
||||
staged.types.set(contract.name, served);
|
||||
types.set(contract.name, served);
|
||||
}
|
||||
|
||||
/** What a recorded factory declared: what a reload's fate and checks need, nothing activated. */
|
||||
|
|
@ -126,10 +169,15 @@ export type Recorded = {
|
|||
readonly tables: readonly TableDefinition[];
|
||||
readonly jobs: readonly JobDefinition<unknown, unknown, unknown>[];
|
||||
readonly contributions: readonly Registry<unknown, unknown>[];
|
||||
/** Its requests: the types it gets (`self.get`) and the registries it reads (`self.registry`), by name. */
|
||||
readonly requests: readonly string[];
|
||||
/** Its requests: the types it gets (`self.get`, with their checked params) and the registries it reads (`self.registry`). */
|
||||
readonly requests: readonly Request[];
|
||||
};
|
||||
|
||||
/** A recorded request, with the contract version it was made at: a type and its params, or a registry. */
|
||||
export type Request =
|
||||
| { readonly kind: "type"; readonly contract: Contract; readonly params: ParamsOf<Contract> }
|
||||
| { readonly kind: "registry"; readonly registry: Registry<unknown, unknown> };
|
||||
|
||||
/**
|
||||
* Runs `served`'s factory for `object` (its params and principal) against a recording `self` and returns what it
|
||||
* declared. Every declaration check an attempt makes runs, without effects (declared twice; persisted data in a type
|
||||
|
|
@ -180,7 +228,7 @@ class RecordingSelf {
|
|||
readonly #tables = new Map<string, TableDefinition>();
|
||||
readonly #jobs = new Map<string, JobDefinition<unknown, unknown, unknown>>();
|
||||
readonly #contributions = new Map<string, Registry<unknown, unknown>>();
|
||||
readonly #requests: string[] = [];
|
||||
readonly #requests: Request[] = [];
|
||||
|
||||
constructor(object: ChordObject, served: Served, context: Context) {
|
||||
this.#object = object;
|
||||
|
|
@ -228,14 +276,14 @@ class RecordingSelf {
|
|||
|
||||
get(Type: Contract, params: unknown): never {
|
||||
this.#assertDeclaring("get");
|
||||
checkParams(Type, params, this.#context);
|
||||
this.#requests.push(Type.name);
|
||||
const checked = checkParams(Type, params, this.#context);
|
||||
this.#requests.push({ kind: "type", contract: Type, params: checked.params });
|
||||
return INERT;
|
||||
}
|
||||
|
||||
registry(R: Registry<unknown, unknown>): RegistryValueSource {
|
||||
this.#assertDeclaring("registry");
|
||||
this.#requests.push(R.name);
|
||||
this.#requests.push({ kind: "registry", registry: R });
|
||||
// The host's own value (an attempt declares the same one): `retryOn` checks its kind.
|
||||
return this.#object.host.registryRecord(R.name).value(R.version);
|
||||
}
|
||||
|
|
|
|||
7
packages/chord/test/next-node/fixtures/esm-host.ts
Normal file
7
packages/chord/test/next-node/fixtures/esm-host.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Fixture (slice 7c): an ESM host package, given to evaluation as its namespace.
|
||||
|
||||
/** The default export a default import reads. */
|
||||
export default "esm default";
|
||||
|
||||
/** A named export a named import reads. */
|
||||
export const named = "esm named";
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
// Fixture plugin (slice 7c): contracts only, no facet; the other fixtures import (copy) it.
|
||||
|
||||
import { hostKind, int, local, method, remote, str } from "@earendil-works/chord/next";
|
||||
|
||||
/** The host kind of the fixtures' worker. */
|
||||
export const Worker = hostKind("test.worker");
|
||||
|
||||
/** Notes on the worker: a label, a count and `add`. */
|
||||
export const Notes = remote("test.notes", {
|
||||
version: 1,
|
||||
params: {},
|
||||
hostAddress: () => Worker(),
|
||||
variants: { main: { state: { label: str(), count: int() }, methods: { add: method(undefined, int()) } } },
|
||||
});
|
||||
|
||||
/** The notes' label on the server. */
|
||||
export const Index = local("test.index", { version: 1, params: {}, members: { label: str() } });
|
||||
|
||||
/** A file of a plugin read through `pluginFile`. */
|
||||
export const Files = local("test.files", { version: 1, params: {}, members: { text: str() } });
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@test/contracts",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
a file of the plugin
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@test/files",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// Fixture plugin (slice 7c): a facet reading a file of its own plugin through `pluginFile`.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { constant, defineFacet } from "@earendil-works/chord/next";
|
||||
import { pluginFile } from "@earendil-works/chord/next/plugins";
|
||||
import { Files } from "../contracts/contract.ts";
|
||||
|
||||
export default defineFacet((host) =>
|
||||
host.serve(Files, () => ({ text: constant(readFileSync(pluginFile("assets/data.txt"), "utf8").trim()) })),
|
||||
);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// Fixture plugin (slice 7c): the label both facets show; tests edit it.
|
||||
|
||||
export const label = "v1";
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@test/notes",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// Fixture plugin (slice 7c): the server facet, serving `Index` with the same label.
|
||||
|
||||
import { constant, defineFacet } from "@earendil-works/chord/next";
|
||||
import { Index } from "../contracts/contract.ts";
|
||||
import { label } from "./label.ts";
|
||||
|
||||
export default defineFacet((host) => host.serve(Index, () => ({ label: constant(label) })));
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
// Fixture plugin (slice 7c): the worker facet, serving `Notes` with a count document.
|
||||
|
||||
import { constant, defineFacet, defineState, int, obj } from "@earendil-works/chord/next";
|
||||
import { Notes } from "../contracts/contract.ts";
|
||||
import { label } from "./label.ts";
|
||||
|
||||
const Count = defineState("count", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) });
|
||||
|
||||
export default defineFacet((host) =>
|
||||
host.serve(Notes, (_params, _principal, self) => {
|
||||
const doc = self.state(Count);
|
||||
return {
|
||||
variant: "main",
|
||||
members: {
|
||||
label: constant(label),
|
||||
count: self.derive([doc], ([value]) => value.n),
|
||||
add: async (ctx) => {
|
||||
await doc.change((draft) => {
|
||||
draft.n += 1;
|
||||
}, ctx);
|
||||
return doc.value.n;
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
256
packages/chord/test/next-node/plugins/build.test.ts
Normal file
256
packages/chord/test/next-node/plugins/build.test.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
// Tests of building (slice 7c §2): every row of the import table, failures as results, source maps relative to the
|
||||
// plugin and digests stable across two checkout paths, inputs hashed as esbuild read them.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, realpath } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import {
|
||||
type BuildOptions,
|
||||
type BuiltFacet,
|
||||
buildFacet,
|
||||
discoverPlugins,
|
||||
type Plugin,
|
||||
} from "../../../src/next-node/plugins/index.ts";
|
||||
import { KINDS, tempRoot, writeFiles, writePackage, writePlugin } from "./support.ts";
|
||||
|
||||
const NODE: BuildOptions = { hostPackages: ["@earendil-works/chord/next"], platform: "node" };
|
||||
const BROWSER: BuildOptions = {
|
||||
hostPackages: ["@earendil-works/chord/next", "react/jsx-runtime"],
|
||||
platform: "browser",
|
||||
publicPath: "/assets/",
|
||||
};
|
||||
|
||||
/** The plugins under `root`, and the one named `name`. */
|
||||
async function discovered(root: string, name: string): Promise<{ plugins: readonly Plugin[]; plugin: Plugin }> {
|
||||
const { plugins } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
return { plugins, plugin: plugins.find((each) => each.name === name)! };
|
||||
}
|
||||
|
||||
/** `built`'s script, failing the test with its errors when it failed. */
|
||||
function script(built: BuiltFacet): Extract<BuiltFacet, { kind: "script" }> {
|
||||
if (built.kind === "failed") throw new Error(`build failed: ${built.errors.join("\n")}`);
|
||||
return built;
|
||||
}
|
||||
|
||||
/** The errors of a build that must fail. */
|
||||
function errors(built: BuiltFacet): readonly string[] {
|
||||
if (built.kind !== "failed") throw new Error("the build succeeded");
|
||||
return built.errors;
|
||||
}
|
||||
|
||||
/** The sources of `built`'s inline source map. */
|
||||
function sourcesOf(built: Extract<BuiltFacet, { kind: "script" }>): string[] {
|
||||
const inline = /sourceMappingURL=data:application\/json;base64,(\S+)/.exec(built.source)![1]!;
|
||||
return JSON.parse(Buffer.from(inline, "base64").toString()).sources;
|
||||
}
|
||||
|
||||
describe("building a facet: the import table", () => {
|
||||
it("inlines the plugin's own files and another plugin's file (copied); keeps host packages external", async () => {
|
||||
const root = await tempRoot(["contracts", "notes"]);
|
||||
const { plugins, plugin } = await discovered(root, "@test/notes");
|
||||
const built = script(await buildFacet(plugin, "worker", { ...NODE, plugins }, context));
|
||||
const real = await realpath(root);
|
||||
expect(built).toMatchObject({ plugin: "@test/notes", facet: "worker", assets: [] });
|
||||
expect(built.source.startsWith("(function (require, module, exports) {")).toBe(true);
|
||||
expect(built.source).toContain('require("@earendil-works/chord/next")');
|
||||
expect(built.copied).toEqual([join(real, "contracts", "contract.ts")]);
|
||||
expect(built.inputs.map(({ file }) => file)).toEqual([
|
||||
join(real, "contracts", "contract.ts"),
|
||||
join(real, "notes", "label.ts"),
|
||||
join(real, "notes", "worker.ts"),
|
||||
]);
|
||||
// Relative to the plugin (a file whose code minified away maps nothing and is left out).
|
||||
expect(sourcesOf(built)).toEqual(expect.arrayContaining(["worker.ts", "../contracts/contract.ts"]));
|
||||
for (const source of sourcesOf(built)) expect(source.startsWith("/")).toBe(false);
|
||||
// Positions only: no copy of the sources' text in the script (Opus N3).
|
||||
const inline = /sourceMappingURL=data:application\/json;base64,(\S+)/.exec(built.source)![1]!;
|
||||
expect(JSON.parse(Buffer.from(inline, "base64").toString())).not.toHaveProperty("sourcesContent");
|
||||
});
|
||||
|
||||
it("hashes each input as esbuild read it; the digest is the source's hash", async () => {
|
||||
const root = await tempRoot(["contracts", "notes"]);
|
||||
const { plugins, plugin } = await discovered(root, "@test/notes");
|
||||
const built = script(await buildFacet(plugin, "worker", { ...NODE, plugins }, context));
|
||||
for (const { file, hash } of built.inputs) {
|
||||
expect(hash).toBe(
|
||||
createHash("sha256")
|
||||
.update(await readFile(file))
|
||||
.digest("hex"),
|
||||
);
|
||||
}
|
||||
expect(built.digest).toBe(createHash("sha256").update(built.source).digest("hex"));
|
||||
});
|
||||
|
||||
it("gives the same script and digest from two checkout paths (relative source maps)", async () => {
|
||||
const first = await tempRoot(["contracts", "notes"]);
|
||||
const second = await tempRoot(["contracts", "notes"]);
|
||||
const builds = await Promise.all(
|
||||
[first, second].map(async (root) => {
|
||||
const { plugins, plugin } = await discovered(root, "@test/notes");
|
||||
return script(await buildFacet(plugin, "worker", { ...NODE, plugins }, context));
|
||||
}),
|
||||
);
|
||||
expect(builds[0]!.digest).toBe(builds[1]!.digest);
|
||||
expect(builds[0]!.source).toBe(builds[1]!.source);
|
||||
});
|
||||
|
||||
it("inlines a package of its dependencies; keeps chord.external packages and Node builtins external on Node", async () => {
|
||||
const root = await tempRoot();
|
||||
const dir = await writePlugin(
|
||||
root,
|
||||
"deps",
|
||||
"@test/deps",
|
||||
{
|
||||
"worker.ts": [
|
||||
'import { pad } from "pad-dep";',
|
||||
'import native from "native-dep";',
|
||||
'import { join } from "node:path";',
|
||||
'import { readFileSync } from "fs";',
|
||||
'import { pluginFile } from "@earendil-works/chord/next/plugins";',
|
||||
"export default { setup: () => [pad, native, join, readFileSync, pluginFile] };",
|
||||
].join("\n"),
|
||||
},
|
||||
{ dependencies: { "pad-dep": "1.0.0" }, chord: { external: ["native-dep"] } },
|
||||
);
|
||||
await writePackage(dir, "pad-dep", { "index.js": 'exports.pad = (s) => "pad:" + s;' });
|
||||
await writePackage(dir, "native-dep", { "index.js": "module.exports = 1;" });
|
||||
const { plugin } = await discovered(root, "@test/deps");
|
||||
const built = script(await buildFacet(plugin, "worker", NODE, context));
|
||||
expect(built.source).toContain('"pad:"');
|
||||
for (const external of ["native-dep", "node:path", "fs", "@earendil-works/chord/next/plugins"]) {
|
||||
expect(built.source).toContain(`require("${external}")`);
|
||||
}
|
||||
// Packages are no inputs: the watcher ignores node_modules.
|
||||
expect(built.inputs.map(({ file }) => file)).toEqual([join(await realpath(dir), "worker.ts")]);
|
||||
});
|
||||
|
||||
it("refuses a chord.external package and a Node builtin in a browser build", async () => {
|
||||
const root = await tempRoot();
|
||||
const dir = await writePlugin(
|
||||
root,
|
||||
"web",
|
||||
"@test/web",
|
||||
{
|
||||
"web.ts":
|
||||
'import native from "native-dep";\nimport { join } from "node:path";\nexport default { setup: () => [native, join] };',
|
||||
},
|
||||
{ chord: { external: ["native-dep"] } },
|
||||
);
|
||||
await writePackage(dir, "native-dep", { "index.js": "module.exports = 1;" });
|
||||
const { plugin } = await discovered(root, "@test/web");
|
||||
expect(errors(await buildFacet(plugin, "web", BROWSER, context))).toEqual([
|
||||
'web.ts:1:20: "native-dep": chord.external packages are for Node builds',
|
||||
'web.ts:2:22: "node:path": a Node builtin in a browser build',
|
||||
]);
|
||||
});
|
||||
|
||||
it("collects a browser build's CSS into one stylesheet and its assets into content-hashed files", async () => {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "web", "@test/web", {
|
||||
"web.tsx": [
|
||||
'import "./notes.css";',
|
||||
'import iconUrl from "./assets/icon.svg?url";',
|
||||
"const Icon = () => <img src={iconUrl} />;",
|
||||
"export default { setup: () => Icon };",
|
||||
].join("\n"),
|
||||
"notes.css": ".notes { color: red; }",
|
||||
"assets/icon.svg": "<svg></svg>",
|
||||
});
|
||||
const { plugin } = await discovered(root, "@test/web");
|
||||
const built = script(await buildFacet(plugin, "web", BROWSER, context));
|
||||
expect(built.css?.name).toMatch(/^web-[A-Z0-9]+\.css$/);
|
||||
expect(built.css?.text).toContain(".notes{color:red}");
|
||||
expect(built.assets.map(({ name }) => name)).toEqual([expect.stringMatching(/^icon-[A-Z0-9]+\.svg$/)]);
|
||||
expect(new TextDecoder().decode(built.assets[0]!.bytes)).toBe("<svg></svg>");
|
||||
expect(built.source).toContain(`"/assets/${built.assets[0]!.name}"`);
|
||||
expect(built.source).toContain('require("react/jsx-runtime")');
|
||||
});
|
||||
|
||||
it("refuses CSS and assets in a Node build", async () => {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "n", "@test/n", {
|
||||
"worker.ts": 'import "./a.css";\nimport url from "./icon.svg?url";\nexport default { setup: () => url };',
|
||||
"a.css": "a{}",
|
||||
"icon.svg": "<svg/>",
|
||||
});
|
||||
const { plugin } = await discovered(root, "@test/n");
|
||||
const found = errors(await buildFacet(plugin, "worker", NODE, context));
|
||||
expect(found).toEqual([
|
||||
'worker.ts:1:8: "./a.css": stylesheets and assets are for browser builds',
|
||||
expect.stringMatching(/^worker\.ts:2:17: "\.\/icon\.svg\?url": assets by URL are for browser builds/),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses import.meta.url, __dirname, __filename, a dynamic import() and top-level await, pointing to pluginFile", async () => {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "bad", "@test/bad", {
|
||||
"worker.ts": "export const a = import.meta.url;\nexport default { setup: () => [__dirname, __filename] };",
|
||||
"server.ts": 'export default { setup: () => import("./other.ts") };',
|
||||
"web.ts": "await Promise.resolve();\nexport default { setup: () => {} };",
|
||||
"other.ts": "export const x = 1;",
|
||||
});
|
||||
const { plugin } = await discovered(root, "@test/bad");
|
||||
const meta = errors(await buildFacet(plugin, "worker", NODE, context));
|
||||
expect(meta.length).toBeGreaterThan(0);
|
||||
for (const error of meta)
|
||||
expect(error).toMatch(/^worker\.ts:\d+:\d+: import\.meta\.url, __dirname and __filename .*pluginFile/);
|
||||
// Without `import.meta` in the file: each free `__dirname` and `__filename` at its own line (example 37).
|
||||
await writeFiles(join(root, "bad"), {
|
||||
"worker.ts": "// The globals.\nconsole.log(__dirname);\nexport default { setup: () => __filename };",
|
||||
});
|
||||
expect(errors(await buildFacet(plugin, "worker", NODE, context))).toEqual([
|
||||
expect.stringMatching(/^worker\.ts:2:13: import\.meta\.url, __dirname and __filename .*pluginFile/),
|
||||
expect.stringMatching(/^worker\.ts:3:\d+: import\.meta\.url, __dirname and __filename .*pluginFile/),
|
||||
]);
|
||||
expect(errors(await buildFacet(plugin, "server", NODE, context))).toEqual([
|
||||
expect.stringMatching(
|
||||
/^server\.ts:1:\d+: import\("\.\/other\.ts"\): a plugin script has no dynamic import .*pluginFile/,
|
||||
),
|
||||
]);
|
||||
expect(errors(await buildFacet(plugin, "web", NODE, context))).toEqual([
|
||||
"web.ts:1:1: a plugin script has no top-level await (a facet declares synchronously)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a path outside the plugin, a package not in its dependencies, and a package in a copied file", async () => {
|
||||
const root = await tempRoot(["contracts"]);
|
||||
await writePlugin(root, "out", "@test/out", {
|
||||
"worker.ts":
|
||||
'import { base } from "../base.ts";\nimport x from "left-out";\nexport default { setup: () => [base, x] };',
|
||||
});
|
||||
await writeFiles(root, { "base.ts": "export const base = 1;" });
|
||||
await writePlugin(root, "copier", "@test/copier", {
|
||||
"worker.ts": 'import { y } from "../shared/shared.ts";\nexport default { setup: () => y };',
|
||||
});
|
||||
await writePlugin(root, "shared", "@test/shared", {
|
||||
"shared.ts": 'import y from "some-package";\nexport { y };',
|
||||
});
|
||||
const { plugins } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
const out = plugins.find(({ name }) => name === "@test/out")!;
|
||||
expect(errors(await buildFacet(out, "worker", { ...NODE, plugins }, context))).toEqual([
|
||||
'worker.ts:1:22: "../base.ts": outside the plugin (base code is reached through a host package, packages through dependencies)',
|
||||
'worker.ts:2:15: "left-out": neither a host package nor in the dependencies of plugin "@test/out"',
|
||||
]);
|
||||
const copier = plugins.find(({ name }) => name === "@test/copier")!;
|
||||
expect(errors(await buildFacet(copier, "worker", { ...NODE, plugins }, context))).toEqual([
|
||||
'../shared/shared.ts:1:15: "some-package": a file copied from plugin "@test/shared" may import only host packages and files of its own plugin',
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a syntax error and a missing facet as failed results, never a throw", async () => {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "syntax", "@test/syntax", { "worker.ts": "export default {" });
|
||||
const { plugin } = await discovered(root, "@test/syntax");
|
||||
expect(errors(await buildFacet(plugin, "worker", NODE, context))).toEqual([
|
||||
expect.stringMatching(/^worker\.ts:1:\d+: Expected /),
|
||||
]);
|
||||
expect(await buildFacet(plugin, "server", NODE, context)).toEqual({
|
||||
kind: "failed",
|
||||
plugin: "@test/syntax",
|
||||
facet: "server",
|
||||
errors: ['plugin "@test/syntax" has no server facet'],
|
||||
});
|
||||
});
|
||||
});
|
||||
95
packages/chord/test/next-node/plugins/discover.test.ts
Normal file
95
packages/chord/test/next-node/plugins/discover.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// Tests of discovery (slice 7c §1): names from package.json, the first root winning a duplicate, directories reported
|
||||
// (no or an invalid package.json, an invalid name, two files of one kind), contracts-only plugins, symlinks.
|
||||
|
||||
import { mkdir, realpath, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { discoverPlugins } from "../../../src/next-node/plugins/index.ts";
|
||||
import { KINDS, tempRoot, writeFiles, writePlugin } from "./support.ts";
|
||||
|
||||
describe("discovery", () => {
|
||||
it("names a plugin by its package.json, lists its facet files by kind, its dependencies and externals", async () => {
|
||||
const root = await tempRoot(["contracts", "notes"]);
|
||||
const dir = await writePlugin(
|
||||
root,
|
||||
"deps-dir",
|
||||
"@test/deps",
|
||||
{ "worker.ts": "", "store.ts": "" },
|
||||
{
|
||||
dependencies: { marked: "1.0.0" },
|
||||
chord: { external: ["better-sqlite3"] },
|
||||
},
|
||||
);
|
||||
const { plugins, problems } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
expect(problems).toEqual([]);
|
||||
const real = await realpath(root);
|
||||
expect(plugins).toEqual([
|
||||
{ name: "@test/contracts", dir: join(real, "contracts"), facets: {}, dependencies: [], external: [] },
|
||||
{
|
||||
name: "@test/deps",
|
||||
dir: await realpath(dir),
|
||||
facets: { worker: join(real, "deps-dir", "worker.ts") },
|
||||
dependencies: ["marked"],
|
||||
external: ["better-sqlite3"],
|
||||
},
|
||||
{
|
||||
name: "@test/notes",
|
||||
dir: join(real, "notes"),
|
||||
facets: { server: join(real, "notes", "server.ts"), worker: join(real, "notes", "worker.ts") },
|
||||
dependencies: [],
|
||||
external: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a directory without package.json, with an invalid one, an invalid name or two files of a kind", async () => {
|
||||
const root = await tempRoot();
|
||||
await mkdir(join(root, "bare"));
|
||||
await writeFiles(join(root, "broken"), { "package.json": "{ not json" });
|
||||
await writePlugin(root, "invalid", "Not A Name", {});
|
||||
await writePlugin(root, "two", "@test/two", { "worker.ts": "", "worker.js": "" });
|
||||
await writePlugin(root, "good", "@test/good", { "web.tsx": "" });
|
||||
// Not plugins and not reported: hidden directories and a root's own packages.
|
||||
await mkdir(join(root, ".git"));
|
||||
await mkdir(join(root, "node_modules"));
|
||||
const { plugins, problems } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
expect(plugins.map(({ name }) => name)).toEqual(["@test/good"]);
|
||||
const real = await realpath(root);
|
||||
expect(problems).toEqual([
|
||||
{ path: join(real, "bare"), error: "no package.json" },
|
||||
{ path: join(real, "broken"), error: expect.stringMatching(/^package\.json: /) },
|
||||
{ path: join(real, "invalid"), error: 'package.json: "Not A Name" is not a valid plugin name' },
|
||||
{ path: join(real, "two"), error: "two worker facets: worker.ts, worker.js" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the first root's plugin of a duplicate name and reports the other; a missing root is empty", async () => {
|
||||
const first = await tempRoot();
|
||||
const second = await tempRoot();
|
||||
await writePlugin(first, "a", "@test/same", { "worker.ts": "" });
|
||||
await writePlugin(second, "b", "@test/same", { "server.ts": "" });
|
||||
const { plugins, problems } = await discoverPlugins(
|
||||
[first, join(first, "missing"), second],
|
||||
{ kinds: KINDS },
|
||||
context,
|
||||
);
|
||||
expect(plugins.map(({ dir }) => dir)).toEqual([join(await realpath(first), "a")]);
|
||||
expect(problems).toEqual([
|
||||
{
|
||||
path: join(await realpath(second), "b"),
|
||||
error: `plugin "@test/same" is in ${join(await realpath(first), "a")} already`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("follows a symlinked plugin directory to its real path", async () => {
|
||||
const elsewhere = await tempRoot();
|
||||
const root = await tempRoot();
|
||||
const dir = await writePlugin(elsewhere, "linked", "@test/linked", { "worker.ts": "" });
|
||||
await symlink(dir, join(root, "link"));
|
||||
const { plugins } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
expect(plugins.map((each) => [each.name, each.dir])).toEqual([["@test/linked", await realpath(dir)]]);
|
||||
expect(plugins[0]!.facets).toEqual({ worker: join(await realpath(dir), "worker.ts") });
|
||||
});
|
||||
});
|
||||
211
packages/chord/test/next-node/plugins/evaluate.test.ts
Normal file
211
packages/chord/test/next-node/plugins/evaluate.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// Tests of evaluation (slice 7c §3): host packages as the host's instances (`instanceof ChordError` across a plugin),
|
||||
// default and named imports of ESM and CommonJS host packages, chord.external packages and builtins from the plugin,
|
||||
// `pluginFile` and its `..` refusal, an import-time throw, a non-facet export, a refused require, a computed import(),
|
||||
// the filename.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { local, str } from "../../../src/next/contracts/index.ts";
|
||||
import { ChordError, isChordError } from "../../../src/next/data/index.ts";
|
||||
import { createHost } from "../../../src/next/host/index.ts";
|
||||
import {
|
||||
type BuiltScript,
|
||||
buildFacet,
|
||||
discoverPlugins,
|
||||
evaluateFacet,
|
||||
type Plugin,
|
||||
pluginFile,
|
||||
} from "../../../src/next-node/plugins/index.ts";
|
||||
import { expectCollected } from "../../next/support.ts";
|
||||
import * as esmHost from "../fixtures/esm-host.ts";
|
||||
import { HOST_PACKAGES, KINDS, tempRoot, writePackage, writePlugin } from "./support.ts";
|
||||
|
||||
const HOSTS = { ...HOST_PACKAGES, "esm-host": esmHost, "cjs-host": { named: "cjs named" } };
|
||||
|
||||
/** The fixtures' `Files` contract, as the `contracts` fixture declares it. */
|
||||
const Files = local("test.files", { version: 1, params: {}, members: { text: str() } });
|
||||
|
||||
/** Builds `name`'s `kind` facet under `root` for Node and returns the script and the plugin. */
|
||||
async function built(root: string, name: string, kind = "worker"): Promise<{ script: BuiltScript; plugin: Plugin }> {
|
||||
const { plugins } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
const plugin = plugins.find((each) => each.name === name)!;
|
||||
const result = await buildFacet(
|
||||
plugin,
|
||||
kind,
|
||||
{ hostPackages: Object.keys(HOSTS), platform: "node", plugins },
|
||||
context,
|
||||
);
|
||||
if (result.kind === "failed") throw new Error(result.errors.join("\n"));
|
||||
return { script: result, plugin };
|
||||
}
|
||||
|
||||
/** A facet with extra members a test reads. */
|
||||
type Probe = { readonly setup: () => void; readonly [name: string]: unknown };
|
||||
|
||||
describe("evaluating a facet", () => {
|
||||
it("gives host packages as the host's instances: a ChordError crosses the plugin as one", async () => {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "p", "@test/p", {
|
||||
"worker.ts": [
|
||||
'import { ChordError, defineFacet } from "@earendil-works/chord/next";',
|
||||
"const facet = defineFacet(() => {});",
|
||||
'export default { ...facet, made: new ChordError({ code: "unavailable", message: "m" }), is: (e: unknown) => e instanceof ChordError };',
|
||||
].join("\n"),
|
||||
});
|
||||
const { script, plugin } = await built(root, "@test/p");
|
||||
const facet = evaluateFacet(script, { hostPackages: HOSTS, plugin }) as unknown as Probe;
|
||||
expect(facet.made).toBeInstanceOf(ChordError);
|
||||
expect(isChordError(facet.made, "unavailable")).toBe(true);
|
||||
expect((facet.is as (e: unknown) => boolean)(new ChordError({ code: "unavailable", message: "x" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("answers default and named imports of an ESM and a CommonJS host package", async () => {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "p", "@test/p", {
|
||||
"worker.ts": [
|
||||
'import esm, { named as esmNamed } from "esm-host";',
|
||||
'import * as esmAll from "esm-host";',
|
||||
'import cjs, { named as cjsNamed } from "cjs-host";',
|
||||
"export default { setup: () => {}, values: [esm, esmNamed, esmAll.named, cjs, cjsNamed] };",
|
||||
].join("\n"),
|
||||
});
|
||||
const { script, plugin } = await built(root, "@test/p");
|
||||
const facet = evaluateFacet(script, { hostPackages: HOSTS, plugin }) as unknown as Probe;
|
||||
expect(facet.values).toEqual(["esm default", "esm named", "esm named", { named: "cjs named" }, "cjs named"]);
|
||||
});
|
||||
|
||||
it("loads chord.external packages and Node builtins from the plugin's directory", async () => {
|
||||
const root = await tempRoot();
|
||||
const dir = await writePlugin(
|
||||
root,
|
||||
"p",
|
||||
"@test/p",
|
||||
{
|
||||
"worker.ts":
|
||||
'import ext from "ext-dep";\nimport { basename } from "node:path";\nexport default { setup: () => {}, values: [ext, basename("/a/b")] };',
|
||||
},
|
||||
{ chord: { external: ["ext-dep"] } },
|
||||
);
|
||||
await writePackage(dir, "ext-dep", { "index.js": 'module.exports = "from the plugin";' });
|
||||
const { script, plugin } = await built(root, "@test/p");
|
||||
const facet = evaluateFacet(script, { hostPackages: HOSTS, plugin }) as unknown as Probe;
|
||||
expect(facet.values).toEqual(["from the plugin", "b"]);
|
||||
});
|
||||
|
||||
it("gives each facet pluginFile for its own plugin's directory and refuses .. out of it", async () => {
|
||||
const root = await tempRoot(["contracts", "files"]);
|
||||
const { script, plugin } = await built(root, "@test/files");
|
||||
const facet = evaluateFacet(script, { hostPackages: HOSTS, plugin });
|
||||
const host = createHost({ onError: () => {} });
|
||||
await host.load([{ name: "@test/files", facet }], context);
|
||||
expect((await host.get(Files, {}, context)).text.value).toBe("a file of the plugin");
|
||||
await host.close(context);
|
||||
await writePlugin(root, "escape", "@test/escape", {
|
||||
"worker.ts":
|
||||
'import { pluginFile } from "@earendil-works/chord/next/plugins";\nexport default { setup: () => {}, escape: () => pluginFile("../files/assets/data.txt") };',
|
||||
});
|
||||
const escaping = await built(root, "@test/escape");
|
||||
const probe = evaluateFacet(escaping.script, {
|
||||
hostPackages: HOSTS,
|
||||
plugin: escaping.plugin,
|
||||
}) as unknown as Probe;
|
||||
expect(() => (probe.escape as () => string)()).toThrow(
|
||||
'pluginFile("../files/assets/data.txt"): outside the directory of plugin "@test/escape"',
|
||||
);
|
||||
// Outside an evaluated facet it has none.
|
||||
expect(() => pluginFile("x")).toThrow("only a plugin's facet script");
|
||||
});
|
||||
|
||||
it("throws an import-time throw, a non-facet export and a refused require, naming the facet", async () => {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "p", "@test/p", {
|
||||
"worker.ts": 'throw new Error("import time");\nexport default { setup: () => {} };',
|
||||
"server.ts": "export default 42;",
|
||||
});
|
||||
const thrown = await built(root, "@test/p");
|
||||
expect(() => evaluateFacet(thrown.script, { hostPackages: HOSTS, plugin: thrown.plugin })).toThrow("import time");
|
||||
const notFacet = await built(root, "@test/p", "server");
|
||||
expect(() => evaluateFacet(notFacet.script, { hostPackages: HOSTS, plugin: notFacet.plugin })).toThrow(
|
||||
'plugin "@test/p" (server): its default export is not a facet (defineFacet)',
|
||||
);
|
||||
const handmade: BuiltScript = {
|
||||
...thrown.script,
|
||||
source: '(function (require, module, exports) { require("nope"); })',
|
||||
};
|
||||
expect(() => evaluateFacet(handmade, { hostPackages: HOSTS, plugin: thrown.plugin })).toThrow(
|
||||
'plugin "@test/p" (worker): "nope" is not a host package, a Node builtin or one of its chord.external packages',
|
||||
);
|
||||
});
|
||||
|
||||
it("runs a computed import() through the facet's require: an unknown one names the facet (Astra)", async () => {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "dynamic", "@test/dynamic", {
|
||||
"worker.ts": [
|
||||
'import { defineFacet } from "@earendil-works/chord/next";',
|
||||
"const later = (specifier: string) => import(specifier);",
|
||||
"export default { ...defineFacet(() => {}), later };",
|
||||
].join("\n"),
|
||||
});
|
||||
const { script, plugin } = await built(root, "@test/dynamic");
|
||||
// The fixture attaches its computed import to the facet.
|
||||
const { later } = evaluateFacet(script, { hostPackages: HOSTS, plugin }) as unknown as {
|
||||
later: (specifier: string) => Promise<{ readonly named?: string }>;
|
||||
};
|
||||
await expect(later("missing-package")).rejects.toThrow(
|
||||
'plugin "@test/dynamic" (worker): "missing-package" is not a host package',
|
||||
);
|
||||
expect((await later("cjs-host")).named).toBe("cjs named");
|
||||
});
|
||||
|
||||
it("runs a script under a stable filename per plugin, kind and digest", async () => {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "p", "@test/p", {
|
||||
"worker.ts": 'export default { setup: () => {}, fail: () => { throw new Error("x"); } };',
|
||||
});
|
||||
const { script, plugin } = await built(root, "@test/p");
|
||||
const facet = evaluateFacet(script, { hostPackages: HOSTS, plugin }) as unknown as Probe;
|
||||
let stack = "";
|
||||
try {
|
||||
(facet.fail as () => void)();
|
||||
} catch (error) {
|
||||
stack = (error as Error).stack!;
|
||||
}
|
||||
expect(stack).toContain(`/chord-plugins/@test/p/worker.ts?${script.digest}:`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ESM host packages' wrappers", () => {
|
||||
it("wraps a namespace once, and keeps no namespace nobody else holds", async () => {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "p", "@test/p", {
|
||||
"worker.ts": 'import * as ns from "fake-esm";\nexport default { setup: () => {}, ns };',
|
||||
});
|
||||
const { plugins } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
const plugin = plugins[0]!;
|
||||
const result = await buildFacet(plugin, "worker", { hostPackages: ["fake-esm"], platform: "node" }, context);
|
||||
if (result.kind === "failed") throw new Error(result.errors.join("\n"));
|
||||
// What `Object.prototype.toString` calls a module namespace.
|
||||
const evaluate = (namespace: object) =>
|
||||
evaluateFacet(result, { hostPackages: { "fake-esm": namespace }, plugin }) as unknown as Probe;
|
||||
// Wrapping reads each export once: twice evaluated, read once.
|
||||
let reads = 0;
|
||||
const namespace = {
|
||||
[Symbol.toStringTag]: "Module",
|
||||
get named() {
|
||||
reads++;
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
expect([
|
||||
(evaluate(namespace).ns as { named: number }).named,
|
||||
(evaluate(namespace).ns as { named: number }).named,
|
||||
]).toEqual([1, 1]);
|
||||
expect(reads).toBe(1);
|
||||
const ref = (() => {
|
||||
const dropped = { [Symbol.toStringTag]: "Module", named: 2 };
|
||||
expect((evaluate(dropped).ns as { named: number }).named).toBe(2);
|
||||
return new WeakRef(dropped);
|
||||
})();
|
||||
await expectCollected(ref);
|
||||
});
|
||||
});
|
||||
62
packages/chord/test/next-node/plugins/install.test.ts
Normal file
62
packages/chord/test/next-node/plugins/install.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Tests of installing a plugin's dependencies (slice 7c §5): the npm arguments, run in the plugin's real directory,
|
||||
// the command overridable; a failure and an abort reject.
|
||||
|
||||
import { readFile, realpath, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context, withCancel } from "../../../src/context/index.ts";
|
||||
import { discoverPlugins, installDependencies } from "../../../src/next-node/plugins/index.ts";
|
||||
import { rejection } from "../../next/support.ts";
|
||||
import { KINDS, tempRoot, writeFiles, writePlugin } from "./support.ts";
|
||||
|
||||
/** A fake npm (a Node script) that records its arguments and directory, and exits with `code` after `delayMs`. */
|
||||
async function fakeNpm(dir: string, code: number, delayMs = 0): Promise<readonly string[]> {
|
||||
const script = join(dir, "fake-npm.mjs");
|
||||
await writeFiles(dir, {
|
||||
"fake-npm.mjs": [
|
||||
'import { writeFileSync } from "node:fs";',
|
||||
`writeFileSync(${JSON.stringify(join(dir, "record.json"))}, JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() }));`,
|
||||
`setTimeout(() => { if (${code} !== 0) console.error("npm said no"); process.exit(${code}); }, ${delayMs});`,
|
||||
].join("\n"),
|
||||
});
|
||||
return [process.execPath, script];
|
||||
}
|
||||
|
||||
describe("installDependencies", () => {
|
||||
it("runs npm install without dev dependencies, peers, scripts, audit or funding, in the plugin's real directory", async () => {
|
||||
const tools = await tempRoot();
|
||||
const elsewhere = await tempRoot();
|
||||
const root = await tempRoot();
|
||||
const dir = await writePlugin(
|
||||
elsewhere,
|
||||
"deps",
|
||||
"@test/deps",
|
||||
{ "worker.ts": "" },
|
||||
{ dependencies: { a: "1.0.0" } },
|
||||
);
|
||||
await symlink(dir, join(root, "deps"));
|
||||
const { plugins } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
await installDependencies(plugins[0]!, context, { npmCommand: await fakeNpm(tools, 0) });
|
||||
expect(JSON.parse(await readFile(join(tools, "record.json"), "utf8"))).toEqual({
|
||||
args: ["install", "--omit=dev", "--legacy-peer-deps", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||
cwd: await realpath(dir),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects with the command's output when it fails, and when the context aborts", async () => {
|
||||
const tools = await tempRoot();
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "deps", "@test/deps", {});
|
||||
const { plugins } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
const plugin = plugins[0]!;
|
||||
expect(
|
||||
await rejection(installDependencies(plugin, context, { npmCommand: await fakeNpm(tools, 1) })),
|
||||
).toMatchObject({
|
||||
message: expect.stringMatching(/^plugin "@test\/deps": .* install failed \(exit 1\): npm said no$/),
|
||||
});
|
||||
const { context: aborted, cancel } = withCancel(context);
|
||||
const running = installDependencies(plugin, aborted, { npmCommand: await fakeNpm(tools, 0, 5_000) });
|
||||
setTimeout(() => cancel(new Error("stop")), 50);
|
||||
expect(await rejection(running)).toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
});
|
||||
585
packages/chord/test/next-node/plugins/plugins.test.ts
Normal file
585
packages/chord/test/next-node/plugins/plugins.test.ts
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
// Tests of the plugins component (slice 7c §4, §5, §7): per plugin at startup and reload (a failing build, evaluation,
|
||||
// setup; unchanged plugins untouched; added and removed plugins; the closure rule through builds), `rollout` (order,
|
||||
// stopping at a rejecting step, marks), installs through the component, GC over 200 reloads, and end to end a server
|
||||
// and a worker loading plugin directories, an edit rolled out, a broken edit kept old on both hosts.
|
||||
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context, withAbortSignal } from "../../../src/context/index.ts";
|
||||
import { hostKind, int, local, method, remote, str } from "../../../src/next/contracts/index.ts";
|
||||
import { createHost, defineFacet, type Host, type ReloadReport } from "../../../src/next/host/index.ts";
|
||||
import type { ChordHost } from "../../../src/next/host/internal.ts";
|
||||
import { memoryLinkPair } from "../../../src/next/link/index.ts";
|
||||
import { constant } from "../../../src/next/state/index.ts";
|
||||
import { createPlugins, type Plugins, type RolloutStep } from "../../../src/next-node/plugins/index.ts";
|
||||
import {
|
||||
changesOf,
|
||||
delivered,
|
||||
expectCollected,
|
||||
rejection,
|
||||
settledHeap,
|
||||
transported,
|
||||
valuesOf,
|
||||
} from "../../next/support.ts";
|
||||
import { HOST_PACKAGES, KINDS, tempRoot, writeFiles, writePackage, writePlugin } from "./support.ts";
|
||||
|
||||
/** The fixtures' contracts, as the `contracts` fixture declares them. */
|
||||
const Worker = hostKind("test.worker");
|
||||
const Notes = remote("test.notes", {
|
||||
version: 1,
|
||||
params: {},
|
||||
hostAddress: () => Worker(),
|
||||
variants: { main: { state: { label: str(), count: int() }, methods: { add: method(undefined, int()) } } },
|
||||
});
|
||||
const Index = local("test.index", { version: 1, params: {}, members: { label: str() } });
|
||||
const Item = local("test.item", { version: 1, params: {}, members: { label: str() } });
|
||||
|
||||
/** A plugin `dir` named `@test/<dir>` serving `Item` labelled `label` from its worker facet. */
|
||||
async function itemPlugin(root: string, dir: string, label: string): Promise<void> {
|
||||
await writePlugin(root, dir, `@test/${dir}`, {
|
||||
"worker.ts": [
|
||||
'import { constant, defineFacet, local, str } from "@earendil-works/chord/next";',
|
||||
`const Item = local("test.${dir}", { version: 1, params: {}, members: { label: str() } });`,
|
||||
`export default defineFacet((host) => host.serve(Item, () => ({ label: constant(${JSON.stringify(label)}) })));`,
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
/** The component over `root`, its reports collected. */
|
||||
function component(root: string): { plugins: Plugins; reported: unknown[] } {
|
||||
const reported: unknown[] = [];
|
||||
const plugins = createPlugins(
|
||||
{
|
||||
roots: [root],
|
||||
kinds: KINDS,
|
||||
browserKinds: ["web"],
|
||||
hostPackages: HOST_PACKAGES,
|
||||
onError: (e) => reported.push(e),
|
||||
},
|
||||
context,
|
||||
);
|
||||
return { plugins, reported };
|
||||
}
|
||||
|
||||
/** The failures of a report as plugin names. */
|
||||
const failedNames = (report: { readonly failed: readonly { readonly plugin: string }[] }) =>
|
||||
report.failed.map(({ plugin }) => plugin);
|
||||
|
||||
describe("per plugin at startup", () => {
|
||||
it("leaves out a plugin whose build fails, whose evaluation throws or whose setup throws; loads the others and core", async () => {
|
||||
const root = await tempRoot();
|
||||
await itemPlugin(root, "good", "good");
|
||||
await writePlugin(root, "syntax", "@test/syntax", { "worker.ts": "export default {" });
|
||||
await writePlugin(root, "throws", "@test/throws", {
|
||||
"worker.ts": 'throw new Error("at import");\nexport default {};',
|
||||
});
|
||||
await writePlugin(root, "setup", "@test/setup", {
|
||||
"worker.ts":
|
||||
'import { defineFacet } from "@earendil-works/chord/next";\nexport default defineFacet(() => { throw new Error("in setup"); });',
|
||||
});
|
||||
const { plugins } = component(root);
|
||||
const host = createHost({ onError: () => {} });
|
||||
const core = defineFacet((h) => h.serve(Item, () => ({ label: constant("core") })));
|
||||
const report = await plugins.load(host, { kind: "worker", core: [{ name: "@app/core", facet: core }] }, context);
|
||||
expect(report.failed.map(({ plugin, error }) => [plugin, (error as Error).message])).toEqual([
|
||||
[
|
||||
"@test/syntax",
|
||||
expect.stringMatching(/^plugin "@test\/syntax": its worker facet failed to build:\nworker\.ts:1:/),
|
||||
],
|
||||
["@test/throws", "at import"],
|
||||
["@test/setup", 'plugin "@test/setup": in setup'],
|
||||
]);
|
||||
const Good = local("test.good", { version: 1, params: {}, members: { label: str() } });
|
||||
expect((await host.get(Good, {}, context)).label.value).toBe("good");
|
||||
expect((await host.get(Item, {}, context)).label.value).toBe("core");
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("per plugin at reload", () => {
|
||||
/** A host loaded with plugins `a`, `b` and `c` (worker facets), and the component. */
|
||||
async function loaded() {
|
||||
const root = await tempRoot();
|
||||
for (const dir of ["a", "b", "c"]) await itemPlugin(root, dir, `${dir}1`);
|
||||
const { plugins } = component(root);
|
||||
const host = createHost({ onError: () => {} });
|
||||
await plugins.load(host, { kind: "worker", core: [] }, context);
|
||||
const label = async (dir: string) =>
|
||||
(await host.get(local(`test.${dir}`, { version: 1, params: {}, members: { label: str() } }), {}, context))
|
||||
.label.value;
|
||||
return { root, plugins, host, label };
|
||||
}
|
||||
|
||||
it("rebuilds one plugin of three: only it changes; the others are passed as the same facets, untouched", async () => {
|
||||
const { root, plugins, host, label } = await loaded();
|
||||
await itemPlugin(root, "b", "b2");
|
||||
const built = await plugins.build(["@test/b"], context);
|
||||
expect(built.facets.map(({ plugin, facet }) => [plugin, facet])).toEqual([["@test/b", "worker"]]);
|
||||
const report = await plugins.reload(host, built, context);
|
||||
expect(report.plugins).toEqual({ added: [], changed: ["@test/b"], removed: [] });
|
||||
expect(report.objects).toEqual([{ object: "test.b", kind: "swapped" }]);
|
||||
expect([await label("a"), await label("b"), await label("c")]).toEqual(["a1", "b2", "c1"]);
|
||||
// Built again unchanged: the same digest, nothing reloaded.
|
||||
const again = await plugins.reload(host, await plugins.build(["@test/b"], context), context);
|
||||
expect(again.plugins).toEqual({ added: [], changed: [], removed: [] });
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
|
||||
it("keeps the loaded facet of a plugin whose build fails or whose evaluation throws; the others apply", async () => {
|
||||
const { root, plugins, host, label } = await loaded();
|
||||
await writeFiles(join(root, "a"), { "worker.ts": "export default {" });
|
||||
await writeFiles(join(root, "b"), { "worker.ts": 'throw new Error("at import");\nexport default {};' });
|
||||
await itemPlugin(root, "c", "c2");
|
||||
const report = await plugins.reload(
|
||||
host,
|
||||
await plugins.build(["@test/a", "@test/b", "@test/c"], context),
|
||||
context,
|
||||
);
|
||||
expect(report.plugins).toEqual({ added: [], changed: ["@test/c"], removed: [] });
|
||||
expect(failedNames(report)).toEqual(["@test/a", "@test/b"]);
|
||||
expect([await label("a"), await label("b"), await label("c")]).toEqual(["a1", "b1", "c2"]);
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
|
||||
it("adds a plugin discovered since, removes one no longer there; an added plugin that fails is left out", async () => {
|
||||
const { root, plugins, host, label } = await loaded();
|
||||
await itemPlugin(root, "d", "d1");
|
||||
await writePlugin(root, "e", "@test/e", { "worker.ts": "export default {" });
|
||||
await rm(join(root, "c"), { recursive: true });
|
||||
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ set: true }), { timeout: 5_000 });
|
||||
const built = await plugins.build([], context);
|
||||
expect(built.removed).toEqual(["@test/c"]);
|
||||
const report = await plugins.reload(host, built, context);
|
||||
expect(report.plugins).toEqual({ added: ["@test/d"], changed: [], removed: ["@test/c"] });
|
||||
expect(failedNames(report)).toEqual(["@test/e"]);
|
||||
expect(await label("d")).toBe("d1");
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
|
||||
it("keeps a consumer edited together with a provider whose new build fails (the closure rule through builds)", async () => {
|
||||
const root = await tempRoot();
|
||||
const provider = (version: number) =>
|
||||
[
|
||||
'import { constant, defineFacet, local, int } from "@earendil-works/chord/next";',
|
||||
`export const P = local("test.provider", { version: ${version}, params: {}, members: { n: int() } });`,
|
||||
`export default defineFacet((host) => host.serve(P, () => ({ n: constant(${version}) })));`,
|
||||
].join("\n");
|
||||
const consumer = (version: number) =>
|
||||
[
|
||||
'import { defineFacet, local, int, str } from "@earendil-works/chord/next";',
|
||||
`const P = local("test.provider", { version: ${version}, params: {}, members: { n: int() } });`,
|
||||
'const C = local("test.consumer", { version: 1, params: {}, members: { label: str() } });',
|
||||
`export default defineFacet((host) => host.serve(C, (_, self) => ({ label: self.derive([self.get(P, {}).n], ([n]) => "v${version} " + n) })));`,
|
||||
].join("\n");
|
||||
await writePlugin(root, "provider", "@test/provider", { "worker.ts": provider(1) });
|
||||
await writePlugin(root, "consumer", "@test/consumer", { "worker.ts": consumer(1) });
|
||||
const { plugins } = component(root);
|
||||
const host = createHost({ onError: () => {} });
|
||||
await plugins.load(host, { kind: "worker", core: [] }, context);
|
||||
const C = local("test.consumer", { version: 1, params: {}, members: { label: str() } });
|
||||
const shown = await host.get(C, {}, context);
|
||||
await writeFiles(join(root, "provider"), { "worker.ts": `${provider(2)}\nexport const broken = ;` });
|
||||
await writeFiles(join(root, "consumer"), { "worker.ts": consumer(2) });
|
||||
const report = await plugins.reload(
|
||||
host,
|
||||
await plugins.build(["@test/provider", "@test/consumer"], context),
|
||||
context,
|
||||
);
|
||||
expect(failedNames(report)).toEqual(["@test/provider", "@test/consumer"]);
|
||||
expect((report.failed[1]!.error as Error).message).toBe(
|
||||
'plugin "@test/consumer": needs test.provider v2, which plugin "@test/provider" serves on its old code',
|
||||
);
|
||||
expect(shown.label.value).toBe("v1 1");
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollout", () => {
|
||||
/** A step recording its name into `order`, answering `report`, or rejecting with `error`. */
|
||||
const step = (name: string, order: string[], outcome: { report?: ReloadReport; error?: Error }): RolloutStep => ({
|
||||
name,
|
||||
kinds: ["worker"],
|
||||
reload: async () => {
|
||||
order.push(name);
|
||||
if (outcome.error !== undefined) throw outcome.error;
|
||||
return outcome.report!;
|
||||
},
|
||||
});
|
||||
const report = (failed: readonly string[]): ReloadReport => ({
|
||||
plugins: { added: [], changed: [], removed: [] },
|
||||
failed: failed.map((plugin) => ({ plugin, error: new Error("x") })),
|
||||
objects: [],
|
||||
});
|
||||
|
||||
it("runs the steps in order, clears the marks of plugins applied on every step, keeps a plugin failed on any", async () => {
|
||||
const root = await tempRoot();
|
||||
await itemPlugin(root, "a", "a1");
|
||||
await itemPlugin(root, "b", "b1");
|
||||
const { plugins } = component(root);
|
||||
await plugins.build([], context);
|
||||
await writeFile(join(root, "a", "worker.ts"), "// a\n");
|
||||
await writeFile(join(root, "b", "worker.ts"), "// b\n");
|
||||
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/a", "@test/b"] }), {
|
||||
timeout: 5_000,
|
||||
});
|
||||
const built = await plugins.build(["@test/a", "@test/b"], context);
|
||||
const order: string[] = [];
|
||||
const result = await plugins.rollout(
|
||||
[step("server", order, { report: report([]) }), step("worker", order, { report: report(["@test/b"]) })],
|
||||
built,
|
||||
context,
|
||||
);
|
||||
expect([order, result.stopped, result.steps.map(({ name }) => name)]).toEqual([
|
||||
["server", "worker"],
|
||||
undefined,
|
||||
["server", "worker"],
|
||||
]);
|
||||
expect(plugins.state.value).toMatchObject({ dirty: ["@test/b"] });
|
||||
plugins.close();
|
||||
});
|
||||
|
||||
it("stops at a step that rejects, clearing no mark; a change during the build keeps its mark", async () => {
|
||||
const root = await tempRoot();
|
||||
await itemPlugin(root, "a", "a1");
|
||||
const { plugins } = component(root);
|
||||
await plugins.build([], context);
|
||||
await writeFile(join(root, "a", "worker.ts"), "// 1\n");
|
||||
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 });
|
||||
const built = await plugins.build(["@test/a"], context);
|
||||
const order: string[] = [];
|
||||
const stopped = await plugins.rollout(
|
||||
[step("server", order, { error: new Error("closed") }), step("worker", order, { report: report([]) })],
|
||||
built,
|
||||
context,
|
||||
);
|
||||
expect([order, stopped.stopped?.step, (stopped.stopped?.error as Error).message]).toEqual([
|
||||
["server"],
|
||||
"server",
|
||||
"closed",
|
||||
]);
|
||||
expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] });
|
||||
// Saved again after the build: applied, the mark stays (the build did not include the new content).
|
||||
const later = await plugins.build(["@test/a"], context);
|
||||
await writeFile(join(root, "a", "worker.ts"), "// 2\n");
|
||||
await vi.waitFor(async () => {
|
||||
await plugins.rollout([step("server", [], { report: report([]) })], later, context);
|
||||
expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] });
|
||||
});
|
||||
plugins.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("installs through the component", () => {
|
||||
it("clears the plugin's install mark and dirties it", async () => {
|
||||
const root = await tempRoot();
|
||||
await itemPlugin(root, "a", "a1");
|
||||
const tools = await tempRoot();
|
||||
await writeFiles(tools, { "npm.mjs": "process.exit(0);" });
|
||||
const reported: unknown[] = [];
|
||||
const plugins = createPlugins(
|
||||
{
|
||||
roots: [root],
|
||||
kinds: KINDS,
|
||||
hostPackages: HOST_PACKAGES,
|
||||
install: { npmCommand: [process.execPath, join(tools, "npm.mjs")] },
|
||||
onError: (error) => reported.push(error),
|
||||
},
|
||||
context,
|
||||
);
|
||||
await plugins.build([], context);
|
||||
await writeFile(
|
||||
join(root, "a", "package.json"),
|
||||
JSON.stringify({ name: "@test/a", type: "module", dependencies: {} }),
|
||||
);
|
||||
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ install: ["@test/a"] }), { timeout: 5_000 });
|
||||
await plugins.installDependencies("@test/a", context);
|
||||
expect(plugins.state.value).toMatchObject({ install: [], dirty: ["@test/a"] });
|
||||
expect(reported).toEqual([]);
|
||||
plugins.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GC", () => {
|
||||
it("collects old facets over 200 reloads; the heap stays flat after warm-up", async () => {
|
||||
const root = await tempRoot();
|
||||
const made: WeakRef<object>[] = [];
|
||||
const probe = { made: (closure: object) => void made.push(new WeakRef(closure)) };
|
||||
const source = (label: string) =>
|
||||
[
|
||||
'import { constant, defineFacet, local, str } from "@earendil-works/chord/next";',
|
||||
'import { made } from "@test/probe";',
|
||||
'const Item = local("test.gc", { version: 1, params: {}, members: { label: str() } });',
|
||||
// A closure per evaluation, which only this evaluation's code holds.
|
||||
`const state = { label: ${JSON.stringify(label)}, big: new Array(10_000).fill(${JSON.stringify(label)}) };`,
|
||||
"made(state);",
|
||||
"export default defineFacet((host) => host.serve(Item, () => ({ label: constant(state.label) })));",
|
||||
].join("\n");
|
||||
await writePlugin(root, "gc", "@test/gc", { "worker.ts": source("a") });
|
||||
const plugins = createPlugins(
|
||||
{ roots: [root], kinds: KINDS, hostPackages: { ...HOST_PACKAGES, "@test/probe": probe }, onError: () => {} },
|
||||
context,
|
||||
);
|
||||
const host = createHost({ onError: () => {} });
|
||||
await plugins.load(host, { kind: "worker", core: [] }, context);
|
||||
const first = await plugins.build(["@test/gc"], context);
|
||||
await writeFiles(join(root, "gc"), { "worker.ts": source("b") });
|
||||
const second = await plugins.build(["@test/gc"], context);
|
||||
const reload = async (count: number) => {
|
||||
for (let i = 0; i < count; i++) await plugins.reload(host, i % 2 === 0 ? second : first, context);
|
||||
};
|
||||
await reload(20);
|
||||
const warm = await settledHeap();
|
||||
await reload(200);
|
||||
const after = await settledHeap();
|
||||
expect(after - warm).toBeLessThan(2 * 1024 * 1024);
|
||||
await expectCollected(...made.slice(0, made.length - 2));
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("end to end: a server and a worker", () => {
|
||||
it("loads plugin directories, rolls an edit out (server, then worker), and keeps a broken edit old on both", async () => {
|
||||
type P = { readonly kind: "server" } | { readonly kind: "worker" };
|
||||
const root = await tempRoot(["contracts", "notes"]);
|
||||
const { plugins } = component(root);
|
||||
const [up, down] = memoryLinkPair();
|
||||
const worker = createHost<P>({ address: Worker(), principal: { kind: "worker" }, onError: () => {} });
|
||||
worker.connect(up, { principal: { kind: "server" } }, context);
|
||||
const server = createHost<P>({ principal: { kind: "server" }, onError: () => {} });
|
||||
server.route(
|
||||
Worker,
|
||||
{ authorize: () => true, resolve: async () => ({ link: down, principal: { kind: "worker" } }) },
|
||||
context,
|
||||
);
|
||||
await plugins.load(worker, { kind: "worker", core: [] }, context);
|
||||
await plugins.load(server, { kind: "server", core: [] }, context);
|
||||
const notes = await server.get(Notes, {}, context);
|
||||
const index = await server.get(Index, {}, context);
|
||||
expect([notes.label.value, index.label.value]).toEqual(["v1", "v1"]);
|
||||
await notes.add(context);
|
||||
const frames = changesOf(await notes.label.watch(context));
|
||||
const steps = [plugins.step("server", server as Host<P>), plugins.step("worker", worker as Host<P>)];
|
||||
|
||||
await writeFile(join(root, "notes", "label.ts"), 'export const label = "v2";\n');
|
||||
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/notes"] }), { timeout: 5_000 });
|
||||
const rolled = await plugins.rollout(steps, await plugins.build(plugins.state.value.dirty, context), context);
|
||||
expect(rolled.steps.map(({ name, report }) => [name, report.plugins.changed, report.objects])).toEqual([
|
||||
["server", ["@test/notes"], [{ object: "test.index", kind: "swapped" }]],
|
||||
["worker", ["@test/notes"], [{ object: "test.notes", kind: "swapped" }]],
|
||||
]);
|
||||
await transported();
|
||||
await delivered();
|
||||
expect(valuesOf(frames)).toEqual(["v2"]);
|
||||
expect([notes.label.value, notes.count.value, index.label.value]).toEqual(["v2", 1, "v2"]);
|
||||
expect(plugins.state.value).toMatchObject({ dirty: [] });
|
||||
|
||||
await writeFile(join(root, "notes", "label.ts"), "export const label = ;\n");
|
||||
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/notes"] }), { timeout: 5_000 });
|
||||
const broken = await plugins.rollout(steps, await plugins.build(plugins.state.value.dirty, context), context);
|
||||
expect(broken.steps.map(({ name, report }) => [name, failedNames(report)])).toEqual([
|
||||
["server", ["@test/notes"]],
|
||||
["worker", ["@test/notes"]],
|
||||
]);
|
||||
await transported();
|
||||
expect([notes.label.value, index.label.value, notes.state.value]).toEqual(["v2", "v2", { kind: "live" }]);
|
||||
expect(plugins.state.value).toMatchObject({ dirty: ["@test/notes"] });
|
||||
await server.close(context);
|
||||
await worker.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fix round 1", () => {
|
||||
it("serializes reloads of one host: two started together keep both changes (Astra, Sol, Opus S1)", async () => {
|
||||
const root = await tempRoot();
|
||||
await itemPlugin(root, "a", "a1");
|
||||
await itemPlugin(root, "b", "b1");
|
||||
const { plugins } = component(root);
|
||||
const host = createHost({ onError: () => {} });
|
||||
await plugins.load(host, { kind: "worker", core: [] }, context);
|
||||
await itemPlugin(root, "a", "a2");
|
||||
const builtA = await plugins.build(["@test/a"], context);
|
||||
await itemPlugin(root, "b", "b2");
|
||||
const builtB = await plugins.build(["@test/b"], context);
|
||||
const reports = await Promise.all([plugins.reload(host, builtA, context), plugins.reload(host, builtB, context)]);
|
||||
expect(reports.map(({ plugins: { changed } }) => changed)).toEqual([["@test/a"], ["@test/b"]]);
|
||||
const label = async (dir: string) =>
|
||||
(await host.get(local(`test.${dir}`, { version: 1, params: {}, members: { label: str() } }), {}, context))
|
||||
.label.value;
|
||||
expect([await label("a"), await label("b")]).toEqual(["a2", "b2"]);
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
|
||||
it("applies the removals of the build it was given, never a later discovery's (Astra)", async () => {
|
||||
const root = await tempRoot();
|
||||
await itemPlugin(root, "a", "a1");
|
||||
await itemPlugin(root, "b", "b1");
|
||||
const { plugins } = component(root);
|
||||
const host = createHost({ onError: () => {} });
|
||||
await plugins.load(host, { kind: "worker", core: [] }, context);
|
||||
await itemPlugin(root, "a", "a2");
|
||||
const first = await plugins.build(["@test/a"], context);
|
||||
await rm(join(root, "b"), { recursive: true });
|
||||
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ set: true }), { timeout: 5_000 });
|
||||
const later = await plugins.build([], context);
|
||||
expect([first.removed, later.removed]).toEqual([[], ["@test/b"]]);
|
||||
const report = await plugins.reload(host, first, context);
|
||||
expect(report.plugins).toEqual({ added: [], changed: ["@test/a"], removed: [] });
|
||||
expect((await plugins.reload(host, later, context)).plugins.removed).toEqual(["@test/b"]);
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
|
||||
it("adopts what a reload committed when its wait aborts after the commit: a later reload keeps it (Astra)", async () => {
|
||||
const root = await tempRoot();
|
||||
await itemPlugin(root, "a", "a1");
|
||||
await itemPlugin(root, "b", "b1");
|
||||
const { plugins } = component(root);
|
||||
const host = createHost({ onError: () => {} });
|
||||
await plugins.load(host, { kind: "worker", core: [] }, context);
|
||||
const Item = local("test.a", { version: 1, params: {}, members: { label: str() } });
|
||||
const item = await host.get(Item, {}, context);
|
||||
await itemPlugin(root, "a", "a2");
|
||||
const first = await plugins.build(["@test/a"], context);
|
||||
// The abort lands right after the reload's commit (its publication batch), while it waits for its report.
|
||||
const { batch } = host as ChordHost;
|
||||
const commit = batch.commit.bind(batch);
|
||||
const controller = new AbortController();
|
||||
const aborting = withAbortSignal(controller.signal, context);
|
||||
const hook = vi.spyOn(batch, "commit").mockImplementation((adopt, ctx) => {
|
||||
commit(adopt, ctx);
|
||||
if (ctx === aborting) controller.abort();
|
||||
});
|
||||
expect(await rejection(plugins.reload(host, first, aborting))).toMatchObject({
|
||||
code: "aborted",
|
||||
details: { committed: { added: [], changed: ["@test/a"], removed: [], failed: [] } },
|
||||
});
|
||||
hook.mockRestore();
|
||||
expect(item.label.value).toBe("a2");
|
||||
await itemPlugin(root, "b", "b2");
|
||||
await plugins.reload(host, await plugins.build(["@test/b"], context), context);
|
||||
expect(item.label.value).toBe("a2");
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
|
||||
it("leaves a plugin out of a host whose kind its rebuilt facets no longer have (Opus recheck R1)", async () => {
|
||||
const root = await tempRoot();
|
||||
await itemPlugin(root, "a", "a1");
|
||||
const { plugins } = component(root);
|
||||
const host = createHost({ onError: () => {} });
|
||||
await plugins.load(host, { kind: "worker", core: [] }, context);
|
||||
// The plugin keeps a server facet only.
|
||||
await rm(join(root, "a", "worker.ts"));
|
||||
await writeFiles(join(root, "a"), {
|
||||
"server.ts":
|
||||
'import { defineFacet } from "@earendil-works/chord/next";\nexport default defineFacet(() => {});',
|
||||
});
|
||||
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ set: true }), { timeout: 5_000 });
|
||||
const built = await plugins.build(["@test/a"], context);
|
||||
expect(built.removed).toEqual([]);
|
||||
expect((await plugins.reload(host, built, context)).plugins).toEqual({
|
||||
added: [],
|
||||
changed: [],
|
||||
removed: ["@test/a"],
|
||||
});
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
|
||||
it("reloads a structured clone of a build as the build itself: it is plain data (Astra)", async () => {
|
||||
const root = await tempRoot();
|
||||
await itemPlugin(root, "a", "a1");
|
||||
const { plugins } = component(root);
|
||||
const host = createHost({ onError: () => {} });
|
||||
await plugins.load(host, { kind: "worker", core: [] }, context);
|
||||
const item = await host.get(local("test.a", { version: 1, params: {}, members: { label: str() } }), {}, context);
|
||||
await itemPlugin(root, "a", "a2");
|
||||
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 });
|
||||
const built = await plugins.build(["@test/a"], context);
|
||||
expect((await plugins.reload(host, structuredClone(built), context)).plugins.changed).toEqual(["@test/a"]);
|
||||
expect(item.label.value).toBe("a2");
|
||||
// The marks clear only through the build object itself (a copy carries no snapshot).
|
||||
const result = await plugins.rollout([plugins.step("worker", host)], structuredClone(built), context);
|
||||
expect(result.stopped).toBeUndefined();
|
||||
expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] });
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
|
||||
it("reads a changed package.json again at the next build: a new dependency is accepted (Astra, Sol)", async () => {
|
||||
const root = await tempRoot();
|
||||
const tools = await tempRoot();
|
||||
await writeFiles(tools, { "npm.mjs": "process.exit(0);" });
|
||||
const dir = await writePlugin(root, "a", "@test/a", {
|
||||
"worker.ts": 'import { label } from "./helper.ts";\nexport default { setup: () => {}, label };',
|
||||
"helper.ts": 'export const label = "old";',
|
||||
});
|
||||
const plugins = createPlugins(
|
||||
{
|
||||
roots: [root],
|
||||
kinds: KINDS,
|
||||
hostPackages: HOST_PACKAGES,
|
||||
install: { npmCommand: [process.execPath, join(tools, "npm.mjs")] },
|
||||
onError: () => {},
|
||||
},
|
||||
context,
|
||||
);
|
||||
expect((await plugins.build([], context)).facets.map(({ kind }) => kind)).toEqual(["script"]);
|
||||
await writeFiles(dir, {
|
||||
"package.json": JSON.stringify({ name: "@test/a", type: "module", dependencies: { "new-dep": "1.0.0" } }),
|
||||
});
|
||||
await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ install: ["@test/a"] }), { timeout: 5_000 });
|
||||
await writePackage(dir, "new-dep", { "index.js": 'exports.label = "new";' });
|
||||
await writeFiles(dir, { "helper.ts": 'export { label } from "new-dep";' });
|
||||
await plugins.installDependencies("@test/a", context);
|
||||
expect(plugins.state.value).toMatchObject({ set: false });
|
||||
const built = await plugins.build(["@test/a"], context);
|
||||
expect(built.facets).toEqual([expect.objectContaining({ kind: "script", plugin: "@test/a" })]);
|
||||
expect(plugins.discovery.plugins[0]!.dependencies).toEqual(["new-dep"]);
|
||||
plugins.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the reload queues and garbage collection", () => {
|
||||
it("lets a rejected new facet go once its report is dropped, the kept old one live (Sol)", async () => {
|
||||
const root = await tempRoot();
|
||||
const made: WeakRef<object>[] = [];
|
||||
const probe = { made: (value: object) => made.push(new WeakRef(value)) };
|
||||
const source = (bad: boolean) =>
|
||||
[
|
||||
'import { constant, defineFacet, int, local } from "@earendil-works/chord/next";',
|
||||
'import { made } from "@test/probe";',
|
||||
`const state = { bad: ${bad}, big: new Array(10000).fill("payload") };`,
|
||||
"made(state);",
|
||||
'const Type = local("test.failed-gc", { version: 1, params: {}, members: { n: int() } });',
|
||||
"export default defineFacet((h) => {",
|
||||
'\tif (state.bad) throw new Error("bad setup");',
|
||||
"\th.serve(Type, () => ({ n: constant(state.big.length) }));",
|
||||
"});",
|
||||
].join("\n");
|
||||
const dir = await writePlugin(root, "failed", "@test/failed", { "worker.ts": source(false) });
|
||||
const plugins = createPlugins(
|
||||
{ roots: [root], kinds: KINDS, hostPackages: { ...HOST_PACKAGES, "@test/probe": probe }, onError: () => {} },
|
||||
context,
|
||||
);
|
||||
const host = createHost({ onError: () => {} });
|
||||
await plugins.load(host, { kind: "worker", core: [] }, context);
|
||||
await writeFiles(dir, { "worker.ts": source(true) });
|
||||
const built = await plugins.build(["@test/failed"], context);
|
||||
// The report (its failure's error holds the new facet's closure) is dropped here; the queues must not keep it.
|
||||
expect((await plugins.reload(host, built, context)).failed.map(({ plugin }) => plugin)).toEqual(["@test/failed"]);
|
||||
await expectCollected(made[1]!);
|
||||
expect(made[0]!.deref()).toBeDefined();
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
});
|
||||
});
|
||||
123
packages/chord/test/next-node/plugins/stack.test.ts
Normal file
123
packages/chord/test/next-node/plugins/stack.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// Tests of stack traces (slice 7c §3, decision Q1 (b)): `mapStack` names plugin files and lines through each
|
||||
// evaluated script's source map; nothing global; a script's map goes with its facet.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import {
|
||||
type BuiltScript,
|
||||
buildFacet,
|
||||
discoverPlugins,
|
||||
evaluateFacet,
|
||||
mapStack,
|
||||
type Plugin,
|
||||
} from "../../../src/next-node/plugins/index.ts";
|
||||
import { expectCollected, settledHeap } from "../../next/support.ts";
|
||||
import { HOST_PACKAGES, KINDS, tempRoot, writePlugin } from "./support.ts";
|
||||
|
||||
/** A plugin `notes` whose worker facet's `fail` throws from `helper.ts` line 3. */
|
||||
async function failing(): Promise<{ script: BuiltScript; plugin: Plugin }> {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "notes", "@test/notes", {
|
||||
"worker.ts": 'import { helper } from "./helper.ts";\nexport default { setup: () => {}, fail: () => helper() };',
|
||||
"helper.ts":
|
||||
'// A helper.\nexport function helper(): never {\n\tthrow new Error("helper failed at " + Date.now());\n}',
|
||||
});
|
||||
const { plugins } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
const plugin = plugins[0]!;
|
||||
const built = await buildFacet(
|
||||
plugin,
|
||||
"worker",
|
||||
{ hostPackages: Object.keys(HOST_PACKAGES), platform: "node" },
|
||||
context,
|
||||
);
|
||||
if (built.kind === "failed") throw new Error(built.errors.join("\n"));
|
||||
return { script: built, plugin };
|
||||
}
|
||||
|
||||
/** The error `facet`'s `fail` throws. */
|
||||
function thrownBy(facet: object): unknown {
|
||||
try {
|
||||
(facet as { readonly fail: () => void }).fail();
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
throw new Error("fail did not throw");
|
||||
}
|
||||
|
||||
describe("mapStack", () => {
|
||||
it("maps a plugin script's positions to the plugin's files and lines; other lines stay; nothing global", async () => {
|
||||
const { script, plugin } = await failing();
|
||||
const prepare = Error.prepareStackTrace;
|
||||
const facet = evaluateFacet(script, { hostPackages: HOST_PACKAGES, plugin });
|
||||
const error = thrownBy(facet);
|
||||
const mapped = mapStack(error);
|
||||
expect(mapped).toMatch(/at \S+ \(notes\/helper\.ts:3:8\)/);
|
||||
expect(mapped).toMatch(/notes\/worker\.ts:2:\d+/);
|
||||
expect(mapped).not.toContain("/chord-plugins/");
|
||||
// The raw stack is untouched: no `Error.prepareStackTrace` of the component's.
|
||||
expect((error as Error).stack).toContain("/chord-plugins/@test/notes/worker.ts?");
|
||||
expect(Error.prepareStackTrace).toBe(prepare);
|
||||
expect(mapStack("not an error")).toBe("not an error");
|
||||
});
|
||||
|
||||
it("lets a script's source map go with its facet", async () => {
|
||||
const { script, plugin } = await failing();
|
||||
const error = await (async () => {
|
||||
const facet = evaluateFacet(script, { hostPackages: HOST_PACKAGES, plugin });
|
||||
return { error: thrownBy(facet), ref: new WeakRef(facet) };
|
||||
})();
|
||||
expect(mapStack(error.error)).toContain("notes/helper.ts:3:8");
|
||||
await expectCollected(error.ref);
|
||||
// The finalization runs after the collection.
|
||||
for (let round = 0; round < 10 && mapStack(error.error).includes("notes/helper.ts"); round++) await settledHeap();
|
||||
expect(mapStack(error.error)).toContain("/chord-plugins/@test/notes/worker.ts?");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapStack, fix round 1", () => {
|
||||
it("maps an import-time throw (the map is registered before the script runs) (Sol)", async () => {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "evaluation", "@test/evaluation", {
|
||||
"worker.ts":
|
||||
'// An import-time throw.\n\nthrow new Error("at import");\nexport default { setup: () => {} };\n',
|
||||
});
|
||||
const { plugins } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
const plugin = plugins[0]!;
|
||||
const built = await buildFacet(plugin, "worker", { hostPackages: [], platform: "node" }, context);
|
||||
if (built.kind === "failed") throw new Error(built.errors.join("\n"));
|
||||
let failure: unknown;
|
||||
try {
|
||||
evaluateFacet(built, { hostPackages: {}, plugin });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(mapStack(failure)).toContain("evaluation/worker.ts:3:");
|
||||
});
|
||||
|
||||
it("maps a throw while its default export is read (a getter) (Sol)", async () => {
|
||||
const root = await tempRoot();
|
||||
await writePlugin(root, "getter", "@test/getter", {
|
||||
"worker.ts": '// A getter failure.\nexport default {\n\tget setup() { throw new Error("getter boom"); } };\n',
|
||||
});
|
||||
const { plugins } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
const plugin = plugins[0]!;
|
||||
const built = await buildFacet(plugin, "worker", { hostPackages: [], platform: "node" }, context);
|
||||
if (built.kind === "failed") throw new Error(built.errors.join("\n"));
|
||||
let failure: unknown;
|
||||
try {
|
||||
evaluateFacet(built, { hostPackages: {}, plugin });
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(mapStack(failure)).toContain("getter/worker.ts:3:");
|
||||
});
|
||||
|
||||
it("keeps a script's map while any evaluation of it lives: two hosts share it (Sol, Opus N3)", async () => {
|
||||
const { script, plugin } = await failing();
|
||||
const first = evaluateFacet(script, { hostPackages: HOST_PACKAGES, plugin });
|
||||
const second = (() => new WeakRef(evaluateFacet(script, { hostPackages: HOST_PACKAGES, plugin })))();
|
||||
await expectCollected(second);
|
||||
for (let round = 0; round < 5; round++) await settledHeap();
|
||||
expect(mapStack(thrownBy(first))).toContain("notes/helper.ts:3:8");
|
||||
});
|
||||
});
|
||||
66
packages/chord/test/next-node/plugins/support.ts
Normal file
66
packages/chord/test/next-node/plugins/support.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// Test support for the plugins component (slice 7c): fixture plugins copied into temporary roots, plugins and npm
|
||||
// packages written there, and the host packages the fixtures import.
|
||||
|
||||
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach } from "vitest";
|
||||
import * as chordNext from "../../../src/next/index.ts";
|
||||
import type { HostPackages } from "../../../src/next-node/plugins/index.ts";
|
||||
|
||||
/** The fixture plugins' directory. */
|
||||
export const FIXTURES = fileURLToPath(new URL("../fixtures/plugins/", import.meta.url));
|
||||
|
||||
/** The host packages the fixtures import: this process's Chord. */
|
||||
export const HOST_PACKAGES: HostPackages = { "@earendil-works/chord/next": chordNext };
|
||||
|
||||
/** The facet kinds of the fixtures' app. */
|
||||
export const KINDS = ["server", "worker", "web"] as const;
|
||||
|
||||
const made: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of made.splice(0)) await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A temporary directory removed after the test; `fixtures` (names of fixture plugins) copied into it. */
|
||||
export async function tempRoot(fixtures: readonly string[] = []): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "chord-plugins-"));
|
||||
made.push(root);
|
||||
for (const name of fixtures) await cp(join(FIXTURES, name), join(root, name), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Writes `files` (relative path to text) into `dir`, creating directories. */
|
||||
export async function writeFiles(dir: string, files: Readonly<Record<string, string>>): Promise<void> {
|
||||
for (const [path, text] of Object.entries(files)) {
|
||||
await mkdir(dirname(join(dir, path)), { recursive: true });
|
||||
await writeFile(join(dir, path), text);
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes a plugin named `name` into `root/dir`: its `package.json` (with `manifest`'s fields) and `files`. */
|
||||
export async function writePlugin(
|
||||
root: string,
|
||||
dir: string,
|
||||
name: string,
|
||||
files: Readonly<Record<string, string>>,
|
||||
manifest: Readonly<Record<string, unknown>> = {},
|
||||
): Promise<string> {
|
||||
const path = join(root, dir);
|
||||
await writeFiles(path, { "package.json": JSON.stringify({ name, type: "module", ...manifest }), ...files });
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Writes an npm package `name` into `dir`'s `node_modules`: its `package.json` (CommonJS unless `type`) and files. */
|
||||
export async function writePackage(
|
||||
dir: string,
|
||||
name: string,
|
||||
files: Readonly<Record<string, string>>,
|
||||
manifest: Readonly<Record<string, unknown>> = {},
|
||||
): Promise<void> {
|
||||
const main = Object.keys(files)[0]!;
|
||||
const json = JSON.stringify({ name, version: "1.0.0", main, ...manifest });
|
||||
await writeFiles(join(dir, "node_modules", name), { "package.json": json, ...files });
|
||||
}
|
||||
202
packages/chord/test/next-node/plugins/watcher.test.ts
Normal file
202
packages/chord/test/next-node/plugins/watcher.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// Tests of the watcher (slice 7c §5): dirty marks from the files facets used and from plugin directories, `install`,
|
||||
// `set`, `base`, a change during a build keeping its mark, `node_modules` and `.git` ignored, real file events, and the
|
||||
// marks as a JSON watchable.
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { Batch } from "../../../src/next/state/internal.ts";
|
||||
import { type BuiltFacet, discoverPlugins } from "../../../src/next-node/plugins/index.ts";
|
||||
import { PluginWatcher } from "../../../src/next-node/plugins/watcher.ts";
|
||||
import { changesOf, delivered } from "../../next/support.ts";
|
||||
import { KINDS, tempRoot, writeFiles, writePlugin } from "./support.ts";
|
||||
|
||||
/** A root with plugins `a` (worker), `b` (server) and the contracts fixture, a base directory, and a watcher of both. */
|
||||
async function watched() {
|
||||
const root = await realpath(await tempRoot(["contracts"]));
|
||||
const base = await realpath(await tempRoot());
|
||||
await writePlugin(root, "a", "@test/a", { "worker.ts": "", "own.ts": "" });
|
||||
await writePlugin(root, "b", "@test/b", { "server.ts": "" });
|
||||
const failures: unknown[] = [];
|
||||
const watcher = new PluginWatcher(
|
||||
{ roots: [root], base: [base], kinds: KINDS, failed: (error) => failures.push(error) },
|
||||
new Batch(() => {}, undefined),
|
||||
);
|
||||
const discovery = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
watcher.know(discovery, watcher.discoverySnapshot());
|
||||
const contract = join(root, "contracts", "contract.ts");
|
||||
const used = (plugin: string, facet: string, files: readonly string[]): BuiltFacet => ({
|
||||
kind: "script",
|
||||
plugin,
|
||||
facet,
|
||||
source: "",
|
||||
digest: "",
|
||||
inputs: files.map((file) => ({ file, hash: "" })),
|
||||
copied: [contract],
|
||||
assets: [],
|
||||
});
|
||||
watcher.used([
|
||||
used("@test/a", "worker", [join(root, "a", "worker.ts"), contract]),
|
||||
used("@test/b", "server", [join(root, "b", "server.ts"), contract]),
|
||||
]);
|
||||
return { root, base, watcher, failures };
|
||||
}
|
||||
|
||||
describe("the watcher's marks", () => {
|
||||
it("dirties every plugin whose facets used a changed file, and the plugin whose directory contains it", async () => {
|
||||
const { root, watcher } = await watched();
|
||||
watcher.mark(join(root, "contracts", "contract.ts"));
|
||||
expect(watcher.value).toEqual({
|
||||
dirty: ["@test/a", "@test/b", "@test/contracts"],
|
||||
install: [],
|
||||
set: false,
|
||||
base: false,
|
||||
});
|
||||
watcher.close();
|
||||
});
|
||||
|
||||
it("dirties the plugin of a file no facet read yet; asks for an install for a package.json", async () => {
|
||||
const { root, watcher } = await watched();
|
||||
watcher.mark(join(root, "a", "new.ts"));
|
||||
watcher.mark(join(root, "b", "package.json"));
|
||||
expect(watcher.value).toEqual({ dirty: ["@test/a"], install: ["@test/b"], set: false, base: false });
|
||||
// Installed: the mark clears, and the plugin is dirty (its packages changed).
|
||||
watcher.installed("@test/b");
|
||||
expect(watcher.value).toEqual({ dirty: ["@test/a", "@test/b"], install: [], set: false, base: false });
|
||||
watcher.close();
|
||||
});
|
||||
|
||||
it("asks for discovery for a directory under a root and a facet file at a plugin's top; base for base code", async () => {
|
||||
const { root, base, watcher } = await watched();
|
||||
watcher.mark(join(root, "c"));
|
||||
expect(watcher.value).toMatchObject({ set: true, dirty: [] });
|
||||
const seen = watcher.discoverySnapshot();
|
||||
watcher.know(await discoverPlugins([root], { kinds: KINDS }, context), seen);
|
||||
expect(watcher.value).toMatchObject({ set: false });
|
||||
watcher.mark(join(root, "b", "web.tsx"));
|
||||
expect(watcher.value).toMatchObject({ set: true, dirty: ["@test/b"] });
|
||||
watcher.mark(join(base, "core.ts"));
|
||||
expect(watcher.value).toMatchObject({ base: true });
|
||||
watcher.close();
|
||||
});
|
||||
|
||||
it("ignores node_modules and .git", async () => {
|
||||
const { root, watcher } = await watched();
|
||||
watcher.mark(join(root, "a", "node_modules", "x", "index.js"));
|
||||
watcher.mark(join(root, "a", ".git", "HEAD"));
|
||||
expect(watcher.value).toEqual({ dirty: [], install: [], set: false, base: false });
|
||||
watcher.close();
|
||||
});
|
||||
|
||||
it("keeps the mark of a change during a build: only an unchanged mark clears when applied", async () => {
|
||||
const { root, watcher } = await watched();
|
||||
watcher.mark(join(root, "a", "own.ts"));
|
||||
const snapshot = watcher.snapshot(["@test/a"]);
|
||||
// Saved again while the build runs.
|
||||
watcher.mark(join(root, "a", "own.ts"));
|
||||
watcher.applied(["@test/a"], snapshot);
|
||||
expect(watcher.value).toMatchObject({ dirty: ["@test/a"] });
|
||||
watcher.applied(["@test/a"], watcher.snapshot(["@test/a"]));
|
||||
expect(watcher.value).toMatchObject({ dirty: [] });
|
||||
// A discovery that started before a change of the set keeps `set`.
|
||||
const seen = watcher.discoverySnapshot();
|
||||
watcher.mark(join(root, "d"));
|
||||
watcher.know(await discoverPlugins([root], { kinds: KINDS }, context), seen);
|
||||
expect(watcher.value).toMatchObject({ set: true });
|
||||
watcher.close();
|
||||
});
|
||||
|
||||
it("marks from real file events, publishing the marks to a watch", async () => {
|
||||
const { root, watcher } = await watched();
|
||||
const changes = changesOf(await watcher.watch(context));
|
||||
await writeFile(join(root, "a", "node_modules-free.ts"), "export {};");
|
||||
await writeFiles(join(root, "b", "node_modules", "pkg"), { "index.js": "" });
|
||||
await vi.waitFor(() => expect(watcher.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 });
|
||||
await delivered();
|
||||
expect(changes.at(-1)).toMatchObject({ kind: "value", value: { dirty: ["@test/a"] } });
|
||||
watcher.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the watcher, fix round 1", () => {
|
||||
it("never lets an older build's applied snapshot clear a newer edit's mark (Astra)", async () => {
|
||||
const { root, watcher } = await watched();
|
||||
const file = join(root, "a", "own.ts");
|
||||
watcher.mark(file);
|
||||
const first = watcher.snapshot(["@test/a"]);
|
||||
watcher.mark(file);
|
||||
const second = watcher.snapshot(["@test/a"]);
|
||||
watcher.applied(["@test/a"], second);
|
||||
expect(watcher.value).toMatchObject({ dirty: [] });
|
||||
watcher.mark(file);
|
||||
watcher.applied(["@test/a"], first);
|
||||
expect(watcher.value).toMatchObject({ dirty: ["@test/a"] });
|
||||
watcher.close();
|
||||
});
|
||||
|
||||
it("drops the install mark of a plugin the next discovery no longer has (example 38)", async () => {
|
||||
const { root, watcher } = await watched();
|
||||
watcher.mark(join(root, "b", "package.json"));
|
||||
expect(watcher.value).toMatchObject({ install: ["@test/b"] });
|
||||
await rm(join(root, "b"), { recursive: true });
|
||||
const seen = watcher.discoverySnapshot();
|
||||
watcher.know(await discoverPlugins([root], { kinds: KINDS }, context), seen);
|
||||
expect(watcher.value).toMatchObject({ install: [] });
|
||||
watcher.close();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "linux")(
|
||||
"puts no watch on node_modules or .git (Opus N2): 200 packages add no inotify watch",
|
||||
async () => {
|
||||
const root = await realpath(await tempRoot());
|
||||
const dir = await writePlugin(root, "a", "@test/a", { "worker.ts": "" });
|
||||
for (let i = 0; i < 200; i++)
|
||||
await writeFiles(join(dir, "node_modules", `pkg${i}`, "lib"), { "index.js": "" });
|
||||
await writeFiles(join(dir, ".git", "objects"), { x: "" });
|
||||
const before = inotifyWatches();
|
||||
const watcher = new PluginWatcher(
|
||||
{ roots: [root], base: [], kinds: KINDS, failed: () => {} },
|
||||
new Batch(() => {}, undefined),
|
||||
);
|
||||
// The root and the plugin's directory.
|
||||
expect(inotifyWatches() - before).toBe(2);
|
||||
watcher.close();
|
||||
},
|
||||
);
|
||||
|
||||
it("watches a directory that appears later, and marks the files in it", async () => {
|
||||
const { root, watcher } = await watched();
|
||||
await writeFiles(join(root, "a", "lib"), { "first.ts": "" });
|
||||
await vi.waitFor(() => expect(watcher.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 });
|
||||
watcher.applied(["@test/a"], watcher.snapshot(["@test/a"]));
|
||||
await writeFile(join(root, "a", "lib", "second.ts"), "export {};");
|
||||
await vi.waitFor(() => expect(watcher.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 });
|
||||
watcher.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the watcher and links", () => {
|
||||
it("survives a link loop created under a root (its stat never escapes the callback) and still marks it (Sol)", async () => {
|
||||
const { root, watcher } = await watched();
|
||||
await symlink("loop", join(root, "a", "loop"));
|
||||
await vi.waitFor(() => expect(watcher.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 });
|
||||
watcher.close();
|
||||
});
|
||||
});
|
||||
|
||||
/** The inotify watches this process holds (Linux): one line per watch in its inotify descriptors' fdinfo. */
|
||||
function inotifyWatches(): number {
|
||||
let count = 0;
|
||||
for (const fd of readdirSync("/proc/self/fdinfo")) {
|
||||
try {
|
||||
count += readFileSync(`/proc/self/fdinfo/${fd}`, "utf8")
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("inotify wd:")).length;
|
||||
} catch {
|
||||
// Closed meanwhile.
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
146
packages/chord/test/next/bench/plugins.ts
Normal file
146
packages/chord/test/next/bench/plugins.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// Benchmark of the plugins component (slice 7c §7): a cold build of a 20-plugin set (one worker facet each, its own
|
||||
// files, a copied contract), a rebuild of one facet after an edit, the evaluation of a facet (`vm.Script`), and the heap
|
||||
// after 100 reloads of one plugin on a host (old facets collected, V8's compilation cache off). Medians over runs.
|
||||
// Run by hand from packages/chord (not in CI), pinned for comparisons:
|
||||
// taskset -c 6 node --conditions=source --experimental-strip-types --expose-gc test/next/bench/plugins.ts
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { createHost } from "../../../src/next/host/index.ts";
|
||||
import * as chordNext from "../../../src/next/index.ts";
|
||||
import {
|
||||
type BuiltScript,
|
||||
buildFacet,
|
||||
createPlugins,
|
||||
discoverPlugins,
|
||||
evaluateFacet,
|
||||
} from "../../../src/next-node/plugins/index.ts";
|
||||
|
||||
const PLUGINS = 20;
|
||||
const RUNS = 7;
|
||||
const RELOADS = 100;
|
||||
|
||||
const gc = (globalThis as { gc?: () => void }).gc!;
|
||||
const HOST_PACKAGES = { "@earendil-works/chord/next": chordNext };
|
||||
const KINDS = ["server", "worker"];
|
||||
|
||||
/** A plugin's worker facet: a store over a document, a derive, a method, a helper module and the shared contract. */
|
||||
function worker(index: number, label: string): string {
|
||||
return [
|
||||
'import { constant, defineFacet, defineState, int, local, method, obj, str } from "@earendil-works/chord/next";',
|
||||
'import { Shared } from "../contracts/contract.ts";',
|
||||
'import { format } from "./format.ts";',
|
||||
`const Store = local("bench.store${index}", { version: 1, params: {}, members: { label: str(), n: int(), bump: method(undefined, int()) } });`,
|
||||
'const Count = defineState("count", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) });',
|
||||
"export default defineFacet((host) => host.serve(Store, (_, self) => {",
|
||||
" const doc = self.state(Count);",
|
||||
` return { label: constant(format(${JSON.stringify(label)}, Shared.name)), n: self.derive([doc], ([d]) => d.n),`,
|
||||
" bump: async (ctx) => { await doc.change((d) => { d.n += 1; }, ctx); return doc.value.n; } };",
|
||||
"}));",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Writes the 20-plugin set (and the contracts plugin) under a new root. */
|
||||
async function pluginSet(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "chord-bench-plugins-"));
|
||||
const write = async (dir: string, files: Record<string, string>) => {
|
||||
await mkdir(join(root, dir), { recursive: true });
|
||||
for (const [file, text] of Object.entries(files)) await writeFile(join(root, dir, file), text);
|
||||
};
|
||||
await write("contracts", {
|
||||
"package.json": JSON.stringify({ name: "@bench/contracts", type: "module" }),
|
||||
"contract.ts":
|
||||
'import { local, str } from "@earendil-works/chord/next";\nexport const Shared = local("bench.shared", { version: 1, params: {}, members: { name: str() } });',
|
||||
});
|
||||
for (let index = 0; index < PLUGINS; index++) {
|
||||
await write(`p${index}`, {
|
||||
"package.json": JSON.stringify({ name: `@bench/p${index}`, type: "module" }),
|
||||
"worker.ts": worker(index, "v1"),
|
||||
"format.ts":
|
||||
'export function format(label: string, name: string): string {\n return label + " (" + name + ")";\n}',
|
||||
});
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Median of `values`. */
|
||||
function median(values: number[]): number {
|
||||
return values.sort((a, b) => a - b)[Math.floor(values.length / 2)]!;
|
||||
}
|
||||
|
||||
const root = await pluginSet();
|
||||
const { plugins } = await discoverPlugins([root], { kinds: KINDS }, context);
|
||||
const options = { hostPackages: Object.keys(HOST_PACKAGES), platform: "node", plugins } as const;
|
||||
const facets = plugins.filter((plugin) => plugin.facets.worker !== undefined);
|
||||
|
||||
// A cold build of every worker facet (each its own esbuild build, all at once as the component runs them).
|
||||
const cold: number[] = [];
|
||||
let scripts: BuiltScript[] = [];
|
||||
for (let run = 0; run < RUNS; run++) {
|
||||
const started = performance.now();
|
||||
const built = await Promise.all(facets.map((plugin) => buildFacet(plugin, "worker", options, context)));
|
||||
cold.push(performance.now() - started);
|
||||
scripts = built.filter((each): each is BuiltScript => each.kind === "script");
|
||||
}
|
||||
const bytes = scripts.reduce((sum, each) => sum + each.source.length, 0);
|
||||
console.log(
|
||||
`cold build of ${facets.length} worker facets: ${median(cold).toFixed(1)} ms (median of ${RUNS}), ${(bytes / 1024).toFixed(1)} KiB of scripts`,
|
||||
);
|
||||
|
||||
// A rebuild of one facet after an edit.
|
||||
const rebuild: number[] = [];
|
||||
for (let run = 0; run < RUNS; run++) {
|
||||
await writeFile(join(root, "p0", "worker.ts"), worker(0, `edit ${run}`));
|
||||
const started = performance.now();
|
||||
await buildFacet(facets[0]!, "worker", options, context);
|
||||
rebuild.push(performance.now() - started);
|
||||
}
|
||||
console.log(`rebuild of one facet: ${median(rebuild).toFixed(1)} ms (median of ${RUNS})`);
|
||||
|
||||
// The evaluation of a facet: compile and run its script.
|
||||
const evaluations: number[] = [];
|
||||
for (let run = 0; run < RUNS; run++) {
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < scripts.length; index++) {
|
||||
evaluateFacet(scripts[index]!, { hostPackages: HOST_PACKAGES, plugin: facets[index]! });
|
||||
}
|
||||
evaluations.push((performance.now() - started) / scripts.length);
|
||||
}
|
||||
console.log(`evaluation per facet: ${median(evaluations).toFixed(2)} ms (median of ${RUNS})`);
|
||||
|
||||
// The heap after 100 reloads of one plugin on a host, against after a warm-up (two builds alternating).
|
||||
const component = createPlugins(
|
||||
{ roots: [root], kinds: KINDS, hostPackages: HOST_PACKAGES, onError: () => {} },
|
||||
context,
|
||||
);
|
||||
const host = createHost({ onError: () => {} });
|
||||
await component.load(host, { kind: "worker", core: [] }, context);
|
||||
await writeFile(join(root, "p0", "worker.ts"), worker(0, "a"));
|
||||
const first = await component.build(["@bench/p0"], context);
|
||||
await writeFile(join(root, "p0", "worker.ts"), worker(0, "b"));
|
||||
const second = await component.build(["@bench/p0"], context);
|
||||
const settled = async (): Promise<number> => {
|
||||
for (let round = 0; round < 5; round++) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
gc();
|
||||
}
|
||||
return process.memoryUsage().heapUsed;
|
||||
};
|
||||
for (let reload = 0; reload < 10; reload++) await component.reload(host, reload % 2 === 0 ? second : first, context);
|
||||
const warm = await settled();
|
||||
const reloadTimes: number[] = [];
|
||||
for (let reload = 0; reload < RELOADS; reload++) {
|
||||
const started = performance.now();
|
||||
await component.reload(host, reload % 2 === 0 ? second : first, context);
|
||||
reloadTimes.push(performance.now() - started);
|
||||
}
|
||||
const after = await settled();
|
||||
console.log(
|
||||
`reload of one plugin (evaluation and host reload): ${median(reloadTimes).toFixed(2)} ms (median of ${RELOADS})`,
|
||||
);
|
||||
console.log(`heap after ${RELOADS} reloads: ${((after - warm) / 1024).toFixed(0)} KiB against after the warm-up`);
|
||||
await host.close(context);
|
||||
component.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
99
packages/chord/test/next/examples/35-plugins/main.ts
Normal file
99
packages/chord/test/next/examples/35-plugins/main.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Plugins (slice 7c): plugin directories turned into facets, loaded on their hosts, watched, rebuilt and rolled out.
|
||||
// A plugin is a directory with a `package.json` (its name) and one file per facet kind (`server.ts`, `worker.ts`);
|
||||
// `createPlugins` discovers them, builds each facet into a script with esbuild (host packages external) and evaluates
|
||||
// it on Node. Here (`plugins/`): a contracts-only plugin, `@example/notes` with a server and a worker facet showing one
|
||||
// label, and `@example/clock`, which the example never edits. A server and a worker load them; the notes' label is
|
||||
// edited on disk, the watcher marks the plugin, the app builds it and rolls it out (server, then worker), a holder sees
|
||||
// one frame, and the clock plugin is untouched (not in the reports, its object the same).
|
||||
// Run from packages/chord:
|
||||
// node --conditions=source --experimental-strip-types test/next/examples/35-plugins/main.ts
|
||||
import { cp, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts";
|
||||
import { createHost, type Host } from "../../../../src/next/host/index.ts";
|
||||
import * as chordNext from "../../../../src/next/index.ts";
|
||||
import { memoryLinkPair } from "../../../../src/next/link/index.ts";
|
||||
import { createPlugins } from "../../../../src/next-node/plugins/index.ts";
|
||||
import { Clock, Index, Notes, Worker } from "./plugins/contracts/contract.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// The example edits files: it works on a copy of `plugins/`, removed at the end.
|
||||
const root = await mkdtemp(join(tmpdir(), "chord-example-35-"));
|
||||
await cp(fileURLToPath(new URL("./plugins/", import.meta.url)), root, { recursive: true });
|
||||
|
||||
/** Waits until `ready()` holds (the watcher's events arrive on their own time). */
|
||||
async function until(ready: () => boolean): Promise<void> {
|
||||
while (!ready()) await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
// 1. The component: the roots, the facet kinds, and the host packages every script gets (this process's Chord: a
|
||||
// plugin's `import { defineFacet } from "@earendil-works/chord/next"` is the app's own instance).
|
||||
const plugins = createPlugins(
|
||||
{
|
||||
roots: [root],
|
||||
kinds: ["server", "worker"],
|
||||
hostPackages: { "@earendil-works/chord/next": chordNext },
|
||||
onError: (error) => console.log("plugins:", error.kind),
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
// 2. A worker and a server joined by a memory link; each loads the plugins' facets of its kind (no core facets here).
|
||||
type P = { readonly kind: "server" } | { readonly kind: "worker" };
|
||||
const [up, down] = memoryLinkPair();
|
||||
const worker = createHost<P>({ address: Worker(), principal: { kind: "worker" }, onError: () => {} });
|
||||
worker.connect(up, { principal: { kind: "server" } }, context);
|
||||
const server = createHost<P>({ principal: { kind: "server" }, onError: () => {} });
|
||||
server.route(
|
||||
Worker,
|
||||
{ authorize: () => true, resolve: async () => ({ link: down, principal: { kind: "worker" } }) },
|
||||
context,
|
||||
);
|
||||
const workerLoad = await plugins.load(worker, { kind: "worker", core: [] }, context);
|
||||
const serverLoad = await plugins.load(server, { kind: "server", core: [] }, context);
|
||||
console.log(
|
||||
"1. discovered:",
|
||||
plugins.discovery.plugins.map(({ name, facets }) => `${name} [${Object.keys(facets)}]`).join(", "),
|
||||
);
|
||||
console.log(" load failures: worker", workerLoad.failed.length, "| server", serverLoad.failed.length);
|
||||
const notes = await server.get(Notes, {}, context);
|
||||
const index = await server.get(Index, {}, context);
|
||||
const clock = await server.get(Clock, {}, context);
|
||||
await notes.add(context);
|
||||
const evaluatedAt = clock.evaluatedAt.value;
|
||||
console.log(" notes:", notes.label.value, "count", notes.count.value, "| index:", index.label.value);
|
||||
const watch = await notes.label.watch(context);
|
||||
watch.start(async (call) => {
|
||||
if (call.kind === "value") console.log(" a frame on the server's reference:", JSON.stringify(call.value));
|
||||
});
|
||||
|
||||
// 3. An edit on disk (the app's editor, a git pull): the watcher marks the plugin dirty; nothing is applied yet.
|
||||
await writeFile(join(root, "notes", "label.ts"), 'export const label = "v2";\n');
|
||||
await until(() => plugins.state.value.dirty.includes("@example/notes"));
|
||||
console.log("2. marks after the edit:", JSON.stringify(plugins.state.value));
|
||||
|
||||
// 4. The app builds the dirty plugins and rolls the build out: the server first, then the worker. Each step reloads its
|
||||
// host with the facets of its kind; the clock plugin was not built, so it keeps its facet and its object.
|
||||
const steps = [plugins.step("server", server as Host<P>), plugins.step("worker", worker as Host<P>)];
|
||||
const built = await plugins.build(plugins.state.value.dirty, context);
|
||||
console.log(
|
||||
"3. built:",
|
||||
built.facets.map(({ plugin, facet, kind }) => `${plugin}/${facet}: ${kind}`),
|
||||
);
|
||||
const rolled = await plugins.rollout(steps, built, context);
|
||||
for (const { name, report } of rolled.steps) {
|
||||
console.log(` ${name}:`, JSON.stringify(report.plugins), "objects", JSON.stringify(report.objects));
|
||||
}
|
||||
await until(() => notes.label.value === "v2");
|
||||
console.log("4. notes:", notes.label.value, "count", notes.count.value, "| index:", index.label.value);
|
||||
console.log(" clock untouched:", clock.evaluatedAt.value === evaluatedAt);
|
||||
console.log(" marks after the rollout:", JSON.stringify(plugins.state.value));
|
||||
|
||||
await watch.stop();
|
||||
await server.close(context);
|
||||
await worker.close(context);
|
||||
plugins.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/clock",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// The clock plugin's server facet: serves `Clock` with the time its script was evaluated. The example never edits
|
||||
// this plugin, so a reload leaves it as it is: same script, same facet, same object.
|
||||
|
||||
import { constant, defineFacet } from "@earendil-works/chord/next";
|
||||
import { Clock } from "../contracts/contract.ts";
|
||||
|
||||
const evaluatedAt = new Date().toISOString();
|
||||
|
||||
export default defineFacet((host) => host.serve(Clock, () => ({ evaluatedAt: constant(evaluatedAt) })));
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// The contracts the plugins share: a contracts-only plugin (no facet file). A plugin that imports this file gets its
|
||||
// own copy built into its script; the app's code declares the same contracts (same names, versions and shapes).
|
||||
|
||||
import { hostKind, int, local, method, remote, str } from "@earendil-works/chord/next";
|
||||
|
||||
/** The worker's host kind: `Notes` lives there. */
|
||||
export const Worker = hostKind("example.worker");
|
||||
|
||||
/** Notes on the worker: a label, a count and `add`. */
|
||||
export const Notes = remote("example.notes", {
|
||||
version: 1,
|
||||
params: {},
|
||||
hostAddress: () => Worker(),
|
||||
variants: { main: { state: { label: str(), count: int() }, methods: { add: method(undefined, int()) } } },
|
||||
});
|
||||
|
||||
/** The notes' label as the server shows it. */
|
||||
export const Index = local("example.index", { version: 1, params: {}, members: { label: str() } });
|
||||
|
||||
/** When the clock plugin's script was evaluated: it changes only when the plugin is reloaded. */
|
||||
export const Clock = local("example.clock", { version: 1, params: {}, members: { evaluatedAt: str() } });
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/contracts",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// The label both of the plugin's facets show; the example edits it.
|
||||
|
||||
export const label = "v1";
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/notes",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// The notes plugin's server facet: serves `Index` with the same label.
|
||||
|
||||
import { constant, defineFacet } from "@earendil-works/chord/next";
|
||||
import { Index } from "../contracts/contract.ts";
|
||||
import { label } from "./label.ts";
|
||||
|
||||
export default defineFacet((host) => host.serve(Index, () => ({ label: constant(label) })));
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
// The notes plugin's worker facet: serves `Notes`, its count kept in a document.
|
||||
|
||||
import { constant, defineFacet, defineState, int, obj } from "@earendil-works/chord/next";
|
||||
import { Notes } from "../contracts/contract.ts";
|
||||
import { label } from "./label.ts";
|
||||
|
||||
const Count = defineState("count", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) });
|
||||
|
||||
export default defineFacet((host) =>
|
||||
host.serve(Notes, (_params, _principal, self) => {
|
||||
const count = self.state(Count);
|
||||
return {
|
||||
variant: "main",
|
||||
members: {
|
||||
label: constant(label),
|
||||
count: self.derive([count], ([value]) => value.n),
|
||||
add: async (ctx) => {
|
||||
await count.change((draft) => {
|
||||
draft.n += 1;
|
||||
}, ctx);
|
||||
return count.value.n;
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
156
packages/chord/test/next/examples/36-plugin-failures/main.ts
Normal file
156
packages/chord/test/next/examples/36-plugin-failures/main.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// Plugin failures (slice 7c): every failure is per plugin. At startup a plugin whose setup throws is left out and the
|
||||
// others run (a consumer of what it would serve waits). At a reload a plugin whose new code fails keeps its old code and
|
||||
// is listed in the report's `failed`: a syntax error (its build failed), a module that throws while it is evaluated
|
||||
// (`mapStack` names the plugin's file and line), a factory that throws while the host stages it. The closure rule: a
|
||||
// provider and its consumer edited together, the provider broken, the consumer kept on its old code too (its new code
|
||||
// needs the provider's new version) and still working; the next good edit applies both. Last, a reload whose context
|
||||
// aborts after its commit: the commit stands, and the component keeps what was committed.
|
||||
// Run from packages/chord:
|
||||
// node --conditions=source --experimental-strip-types test/next/examples/36-plugin-failures/main.ts
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BACKGROUND_CONTEXT, withAbortSignal } from "../../../../src/context/index.ts";
|
||||
import { int, local, str } from "../../../../src/next/contracts/index.ts";
|
||||
import { isChordError } from "../../../../src/next/data/index.ts";
|
||||
import { createHost, type ReloadReport } from "../../../../src/next/host/index.ts";
|
||||
import * as chordNext from "../../../../src/next/index.ts";
|
||||
import { createPlugins, mapStack } from "../../../../src/next-node/plugins/index.ts";
|
||||
import { Weather } from "./plugins/weather/contract.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// The example edits files: it works on a copy of `plugins/`, removed at the end.
|
||||
const root = await mkdtemp(join(tmpdir(), "chord-example-36-"));
|
||||
await cp(fileURLToPath(new URL("./plugins/", import.meta.url)), root, { recursive: true });
|
||||
|
||||
/** Waits until `ready()` holds (the watcher's events arrive on their own time). */
|
||||
async function until(ready: () => boolean): Promise<void> {
|
||||
while (!ready()) await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
/** Replaces `from` with `to` in the plugin file `file` (the copy). */
|
||||
async function edit(file: string, from: string, to: string): Promise<void> {
|
||||
const text = await readFile(join(root, file), "utf8");
|
||||
await writeFile(join(root, file), text.replace(from, to));
|
||||
}
|
||||
|
||||
/** Waits until the watcher marks every plugin in `names` dirty, then builds the dirty plugins. */
|
||||
async function buildDirty(names: readonly string[]) {
|
||||
await until(() => names.every((name) => plugins.state.value.dirty.includes(name)));
|
||||
return plugins.build(plugins.state.value.dirty, context);
|
||||
}
|
||||
|
||||
/** A reload's report: what changed, its objects' fates, and each failed plugin with its error's mapped stack. */
|
||||
function print(report: ReloadReport): void {
|
||||
console.log(" plugins:", JSON.stringify(report.plugins));
|
||||
const objects = report.objects.map((each) => `${each.object} ${each.kind}`);
|
||||
if (objects.length > 0) console.log(" objects:", objects.join(", "));
|
||||
for (const { plugin, error } of report.failed) {
|
||||
// The first line, and the second where it names the plugin's code: a build error, or a mapped stack frame.
|
||||
const [message, next = ""] = mapStack(error).split("\n");
|
||||
const detail = !next.trimStart().startsWith("at ") || next.includes("/server.ts:") ? ` ${next.trim()}` : "";
|
||||
console.log(` failed ${plugin}: ${message}${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
// The contracts the app reads (the plugins declare the same ones).
|
||||
const Forecast = local("example.forecast", { version: 1, params: {}, members: { text: str() } });
|
||||
const Greeting = local("example.greeting", { version: 1, params: {}, members: { text: str() } });
|
||||
const Boot = local("example.boot", { version: 1, params: {}, members: { mode: str() } });
|
||||
const Counter = local("example.counter", { version: 1, params: {}, members: { start: int() } });
|
||||
const Summary = local("example.summary", { version: 1, params: {}, members: { text: str() } });
|
||||
const Status = local("example.status", { version: 1, params: {}, members: { text: str() } });
|
||||
|
||||
const plugins = createPlugins(
|
||||
{
|
||||
roots: [root],
|
||||
kinds: ["server"],
|
||||
hostPackages: { "@earendil-works/chord/next": chordNext },
|
||||
onError: (error) => console.log("plugins:", error.kind),
|
||||
},
|
||||
context,
|
||||
);
|
||||
const host = createHost({ onError: () => {} });
|
||||
|
||||
// 1. Startup: the weather plugin's setup throws; it is left out and reported, the other plugins run. A reference to
|
||||
// what it would serve waits (`failed`, cause `waiting`: not served here), and so does its consumer's object.
|
||||
const loaded = await plugins.load(host, { kind: "server", core: [] }, context);
|
||||
for (const { plugin, error } of loaded.failed) console.log(`1. left out ${plugin}: ${(error as Error).message}`);
|
||||
const weather = host.hold(Weather, {}, context);
|
||||
const forecast = host.hold(Forecast, {}, context);
|
||||
await until(() => forecast.state.value.kind === "failed");
|
||||
console.log(" weather:", JSON.stringify(weather.state.value));
|
||||
console.log(" forecast:", JSON.stringify(forecast.state.value));
|
||||
const greeting = await host.get(Greeting, {}, context);
|
||||
const boot = await host.get(Boot, {}, context);
|
||||
const counter = await host.get(Counter, {}, context);
|
||||
const summary = await host.get(Summary, {}, context);
|
||||
const status = await host.get(Status, {}, context);
|
||||
const values = () =>
|
||||
[greeting.text.value, boot.mode.value, counter.start.value, summary.text.value]
|
||||
.map((v) => JSON.stringify(v))
|
||||
.join(" ");
|
||||
console.log(" running:", values());
|
||||
|
||||
// 2. Four bad edits at once. The greeter's label gets a syntax error; the boot module throws while it is evaluated;
|
||||
// the counter's factory throws; the prices plugin moves its contract to version 2 and breaks its facet in the same
|
||||
// edit, while the summary plugin (its copy of the contract now at version 2) builds fine.
|
||||
await edit("greeter/label.ts", '"hello"', '"hello" +');
|
||||
await edit("boot/server.ts", "const Boot", 'throw new Error("boot: config missing");\n\nconst Boot');
|
||||
await edit("counter/server.ts", "() => ({ start: constant(1) })", '() => {\n\tthrow new Error("counter: no seed");\n}');
|
||||
await edit("prices/contract.ts", "version: 1", "version: 2");
|
||||
await edit("prices/server.ts", "constant(250)", "constant(250");
|
||||
const bad = await buildDirty(["@example/greeter", "@example/boot", "@example/counter", "@example/summary"]);
|
||||
console.log("2. built:", bad.facets.map(({ plugin, kind }) => `${plugin}: ${kind}`).join(", "));
|
||||
// Each failed plugin keeps its old code; the summary's new code would ask for `Price` v2, which only the prices
|
||||
// plugin's new (failed) code serves: the closure rule keeps the summary on its old code too.
|
||||
print(await plugins.reload(host, bad, context));
|
||||
console.log(" running:", values());
|
||||
console.log(" still dirty:", JSON.stringify(plugins.state.value.dirty));
|
||||
|
||||
// 3. The fixes: the next build and reload apply every plugin, the prices provider and its consumer together.
|
||||
await edit("greeter/label.ts", '"hello" +', '"hi"');
|
||||
await edit("boot/server.ts", 'throw new Error("boot: config missing");\n\n', "");
|
||||
await edit("boot/server.ts", 'constant("v1")', 'constant("v2")');
|
||||
await edit("counter/server.ts", '() => {\n\tthrow new Error("counter: no seed");\n}', "() => ({ start: constant(2) })");
|
||||
await edit("prices/server.ts", "constant(250", "constant(300)");
|
||||
const good = await buildDirty(["@example/greeter", "@example/boot", "@example/counter", "@example/prices"]);
|
||||
console.log("3. fixed:");
|
||||
print(await plugins.reload(host, good, context));
|
||||
console.log(" running:", values());
|
||||
// The summary's new code waited for the new `Price` object (parked at the report): it runs once that one is live.
|
||||
await until(() => summary.text.value.startsWith("price v2"));
|
||||
console.log(" then:", values());
|
||||
|
||||
// 4. A reload whose context aborts after its commit, while it waits for its report: here the app shuts down the moment
|
||||
// it sees the status object leave `live` (its new code waits for a new `Cache`, which takes 200 ms to open). The
|
||||
// reload rejects `aborted`, but its commit stands: `details.committed` says what it applied, and the component's
|
||||
// loaded set is what was committed (a later reload does not roll the cache plugin back).
|
||||
console.log("4. status:", status.text.value);
|
||||
await edit("cache/server.ts", 'const generation = "v1"', 'const generation = "v2"');
|
||||
const cache = await buildDirty(["@example/cache"]);
|
||||
const abort = new AbortController();
|
||||
const watchStatus = await status.state.watch(context);
|
||||
watchStatus.start(async (call) => {
|
||||
if (call.kind !== "value") return;
|
||||
console.log(" the status reference:", call.value.kind);
|
||||
if (call.value.kind !== "live") abort.abort(new Error("the app shuts down"));
|
||||
});
|
||||
try {
|
||||
await plugins.reload(host, cache, withAbortSignal(abort.signal, context));
|
||||
} catch (error) {
|
||||
if (!isChordError(error, "aborted")) throw error;
|
||||
console.log(" rejected:", error.code, error.message, JSON.stringify(error.details));
|
||||
}
|
||||
await until(() => status.text.value === "cache v2");
|
||||
console.log(" status:", status.text.value);
|
||||
await edit("greeter/label.ts", '"hi"', '"hey"');
|
||||
print(await plugins.reload(host, await buildDirty(["@example/greeter"]), context));
|
||||
console.log(" after another reload:", status.text.value, "| greeting:", greeting.text.value);
|
||||
|
||||
await watchStatus.stop();
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/boot",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// The boot plugin's server facet: serves `Boot`. The example makes this module throw while it is evaluated.
|
||||
|
||||
import { constant, defineFacet, local, str } from "@earendil-works/chord/next";
|
||||
|
||||
const Boot = local("example.boot", { version: 1, params: {}, members: { mode: str() } });
|
||||
|
||||
export default defineFacet((host) => host.serve(Boot, () => ({ mode: constant("v1") })));
|
||||
5
packages/chord/test/next/examples/36-plugin-failures/plugins/cache/package.json
vendored
Normal file
5
packages/chord/test/next/examples/36-plugin-failures/plugins/cache/package.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/cache",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
27
packages/chord/test/next/examples/36-plugin-failures/plugins/cache/server.ts
vendored
Normal file
27
packages/chord/test/next/examples/36-plugin-failures/plugins/cache/server.ts
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// The cache plugin's server facet: `Cache` opens a store per generation, which takes a while (a native); `Status` reads
|
||||
// the cache of the plugin's generation. A reload to a new generation swaps `Status` in place; its new code needs a new
|
||||
// `Cache` object, and its first dependency check waits until that one is open: the reload's report waits for it too.
|
||||
|
||||
import { constant, defineFacet, local, str } from "@earendil-works/chord/next";
|
||||
|
||||
const generation = "v1";
|
||||
|
||||
const Cache = local("example.cache", { version: 1, params: { generation: str() }, members: { generation: str() } });
|
||||
const Status = local("example.status", { version: 1, params: {}, members: { text: str() } });
|
||||
|
||||
export default defineFacet((host) => {
|
||||
host.serve(Cache, (params, self) => {
|
||||
self.open(
|
||||
async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return {};
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
return { generation: constant(params.generation) };
|
||||
});
|
||||
host.serve(Status, (_params, self) => {
|
||||
const cache = self.get(Cache, { generation });
|
||||
return { text: self.derive([cache.generation], ([value]) => `cache ${value}`) };
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/counter",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// The counter plugin's server facet: serves `Counter`. The example makes its factory throw.
|
||||
|
||||
import { constant, defineFacet, int, local } from "@earendil-works/chord/next";
|
||||
|
||||
const Counter = local("example.counter", { version: 1, params: {}, members: { start: int() } });
|
||||
|
||||
export default defineFacet((host) => host.serve(Counter, () => ({ start: constant(1) })));
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/forecast",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// The forecast plugin's server facet: a consumer of the weather plugin's `Weather`.
|
||||
|
||||
import { defineFacet, local, str } from "@earendil-works/chord/next";
|
||||
import { Weather } from "../weather/contract.ts";
|
||||
|
||||
const Forecast = local("example.forecast", { version: 1, params: {}, members: { text: str() } });
|
||||
|
||||
export default defineFacet((host) =>
|
||||
host.serve(Forecast, (_params, self) => {
|
||||
const weather = self.get(Weather, {});
|
||||
return { text: self.derive([weather.celsius], ([celsius]) => `${celsius} degrees`) };
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// The greeting's text; the example breaks it with a syntax error, then fixes it.
|
||||
|
||||
export const greeting = "hello";
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/greeter",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// The greeter plugin's server facet: serves `Greeting` with the text of `label.ts`.
|
||||
|
||||
import { constant, defineFacet, local, str } from "@earendil-works/chord/next";
|
||||
import { greeting } from "./label.ts";
|
||||
|
||||
const Greeting = local("example.greeting", { version: 1, params: {}, members: { text: str() } });
|
||||
|
||||
export default defineFacet((host) => host.serve(Greeting, () => ({ text: constant(greeting) })));
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// The prices plugin's contract; the summary plugin imports (copies) it. The example moves it to version 2.
|
||||
|
||||
import { int, local } from "@earendil-works/chord/next";
|
||||
|
||||
/** A price in cents. */
|
||||
export const Price = local("example.price", { version: 1, params: {}, members: { cents: int() } });
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/prices",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// The prices plugin's server facet: serves `Price`.
|
||||
|
||||
import { constant, defineFacet } from "@earendil-works/chord/next";
|
||||
import { Price } from "./contract.ts";
|
||||
|
||||
export default defineFacet((host) => host.serve(Price, () => ({ cents: constant(250) })));
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/summary",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// The summary plugin's server facet: a consumer of the prices plugin's `Price`, at the version its copy of the
|
||||
// contract declares.
|
||||
|
||||
import { defineFacet, local, str } from "@earendil-works/chord/next";
|
||||
import { Price } from "../prices/contract.ts";
|
||||
|
||||
const Summary = local("example.summary", { version: 1, params: {}, members: { text: str() } });
|
||||
|
||||
export default defineFacet((host) =>
|
||||
host.serve(Summary, (_params, self) => {
|
||||
const price = self.get(Price, {});
|
||||
return { text: self.derive([price.cents], ([cents]) => `price v${Price.version}: ${cents} cents`) };
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// The weather plugin's contract; the forecast plugin imports (copies) it.
|
||||
|
||||
import { int, local } from "@earendil-works/chord/next";
|
||||
|
||||
/** Today's temperature. */
|
||||
export const Weather = local("example.weather", { version: 1, params: {}, members: { celsius: int() } });
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/weather",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// The weather plugin's server facet: its setup throws (no API key configured), so the host leaves the plugin out at
|
||||
// startup and runs the others.
|
||||
|
||||
import { constant, defineFacet } from "@earendil-works/chord/next";
|
||||
import { Weather } from "./contract.ts";
|
||||
|
||||
const apiKey: string | undefined = undefined;
|
||||
|
||||
export default defineFacet((host) => {
|
||||
if (apiKey === undefined) throw new Error("weather: no API key configured");
|
||||
host.serve(Weather, () => ({ celsius: constant(21) }));
|
||||
});
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// The app's own code (base code): given to plugins as the host package `@example/app` (an ES module: a default export
|
||||
// and named ones). A plugin imports it by that name, never by its path.
|
||||
|
||||
/** The app's name and version. */
|
||||
export default { name: "example app", version: 3 };
|
||||
|
||||
/** Cents as a price. */
|
||||
export function currency(cents: number): string {
|
||||
return `EUR ${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// A CommonJS module of the app: given to plugins as the host package `@example/legacy`.
|
||||
|
||||
module.exports = {
|
||||
pad(text, width) {
|
||||
return String(text).padStart(width, ".");
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// A small npm package (local, so the example needs no network): a plugin lists it in its `dependencies`, and the build
|
||||
// inlines it into the plugin's script.
|
||||
|
||||
export function greet(name) {
|
||||
return `hello, ${name}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "example-greeting",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.js"
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// A package a plugin keeps external (`chord.external`: a native addon, a package that reads its own files): the build
|
||||
// leaves its import as it is, and the facet's `require` loads it from the plugin's `node_modules` at run time.
|
||||
|
||||
exports.where = () => __filename;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "example-native",
|
||||
"version": "1.0.0",
|
||||
"type": "commonjs",
|
||||
"main": "index.js"
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// A stand-in for `npm install` (the example needs no network): run in a plugin's directory, it copies every
|
||||
// `file:` dependency of the plugin's package.json into its node_modules, as npm would install it.
|
||||
|
||||
import { cpSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const { dependencies = {} } = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
for (const [name, spec] of Object.entries(dependencies)) {
|
||||
if (!spec.startsWith("file:")) throw new Error(`fake npm: ${name}@${spec} is not a local package`);
|
||||
cpSync(resolve(spec.slice("file:".length)), join("node_modules", name), { recursive: true });
|
||||
}
|
||||
76
packages/chord/test/next/examples/37-plugin-imports/main.ts
Normal file
76
packages/chord/test/next/examples/37-plugin-imports/main.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// What a facet may import (slice 7c §2). The build inlines a plugin's own files, files of other plugins (a copied
|
||||
// contract) and its npm `dependencies`; it leaves host packages (the app's instances: an ES module's default and named
|
||||
// exports, a CommonJS module), `chord.external` packages and Node builtins external, answered when the script runs; a
|
||||
// plugin's own files are read through `pluginFile`. Here (`plugins/`): the report plugin uses each of these; its
|
||||
// dependencies are installed first (`installDependencies`; `fake-npm.mjs` stands in for npm: no network). The ruler
|
||||
// plugin copies a file that imports a package, the refused plugin imports what a script may not, and the paths plugin
|
||||
// uses `__dirname` and `__filename`: they are left out with their build errors.
|
||||
// Run from packages/chord:
|
||||
// node --conditions=source --experimental-strip-types test/next/examples/37-plugin-imports/main.ts
|
||||
import { cp, mkdtemp, readdir, rm } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts";
|
||||
import { createHost } from "../../../../src/next/host/index.ts";
|
||||
import * as chordNext from "../../../../src/next/index.ts";
|
||||
import { createPlugins } from "../../../../src/next-node/plugins/index.ts";
|
||||
import * as app from "./app/index.ts";
|
||||
import { Report } from "./plugins/shapes/contract.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// The example installs packages into a plugin: it works on a copy of `plugins/`, `app/` and `deps/` (the plugins'
|
||||
// `file:` dependencies point there), removed at the end.
|
||||
const copy = await mkdtemp(join(tmpdir(), "chord-example-37-"));
|
||||
for (const dir of ["plugins", "app", "deps"]) {
|
||||
await cp(fileURLToPath(new URL(`./${dir}/`, import.meta.url)), join(copy, dir), { recursive: true });
|
||||
}
|
||||
const root = join(copy, "plugins");
|
||||
|
||||
// 1. The component. Its host packages: Chord, the app's ES module and a CommonJS module of the app. Its npm command
|
||||
// (here a stand-in): what `installDependencies` runs in a plugin's directory.
|
||||
const plugins = createPlugins(
|
||||
{
|
||||
roots: [root],
|
||||
kinds: ["server"],
|
||||
hostPackages: {
|
||||
"@earendil-works/chord/next": chordNext,
|
||||
"@example/app": app,
|
||||
"@example/legacy": createRequire(import.meta.url)("./app/legacy.cjs"),
|
||||
},
|
||||
install: { npmCommand: [process.execPath, fileURLToPath(new URL("./fake-npm.mjs", import.meta.url))] },
|
||||
onError: (error) => console.log("plugins:", error.kind),
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
// 2. Startup: every plugin whose build fails is left out with its errors (one per line: file, line, column, why). The
|
||||
// report plugin's dependency is not installed yet.
|
||||
const host = createHost({ onError: () => {} });
|
||||
const loaded = await plugins.load(host, { kind: "server", core: [] }, context);
|
||||
console.log("1. left out at startup:");
|
||||
for (const { plugin, error } of loaded.failed) {
|
||||
const [first, ...errors] = (error as Error).message.split("\n");
|
||||
console.log(` ${plugin}: ${first}`);
|
||||
for (const line of errors) console.log(` ${line}`);
|
||||
}
|
||||
|
||||
// 3. The report plugin's dependencies, installed into its own node_modules; then it builds, and a reload adds it.
|
||||
await plugins.installDependencies("@example/report", context);
|
||||
console.log("2. installed:", (await readdir(join(root, "report", "node_modules"))).join(", "));
|
||||
const built = await plugins.build(["@example/report"], context);
|
||||
for (const facet of built.facets) {
|
||||
if (facet.kind !== "script") continue;
|
||||
const files = facet.inputs.map(({ file }) => file.slice(root.length + 1));
|
||||
console.log(` built ${facet.plugin}/${facet.facet}: read ${files.join(", ")}; copied ${facet.copied.length} file`);
|
||||
}
|
||||
const report = await plugins.reload(host, built, context);
|
||||
console.log(" reload:", JSON.stringify(report.plugins), "failed", report.failed.length);
|
||||
const text = (await host.get(Report, {}, context)).text.value;
|
||||
console.log("3. the report:", text);
|
||||
|
||||
await host.close(context);
|
||||
plugins.close();
|
||||
await rm(copy, { recursive: true, force: true });
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/paths",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// The paths plugin's server facet: a script has no `__dirname` or `__filename` (it is evaluated with no file of its
|
||||
// own); a file of the plugin is read through `pluginFile`. Each use is a build error.
|
||||
|
||||
import { join } from "node:path";
|
||||
import { defineFacet } from "@earendil-works/chord/next";
|
||||
|
||||
export default defineFacet(() => {
|
||||
console.log(join(__dirname, "data.json"));
|
||||
console.log(__filename);
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// A file of the refused plugin, imported dynamically by its facet (refused).
|
||||
|
||||
export const other = 1;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/refused",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// The refused plugin's server facet: imports a facet script may not have, each a build error with its file and line.
|
||||
// A script is evaluated with no module identity: no `import.meta` (a file of the plugin is read through `pluginFile`),
|
||||
// no literal dynamic `import()`; and base code is reached through a host package, not by its path.
|
||||
|
||||
import { defineFacet } from "@earendil-works/chord/next";
|
||||
import app from "../../app/index.ts";
|
||||
|
||||
export default defineFacet(() => {
|
||||
console.log(import.meta.url, app.name);
|
||||
void import("./other.ts");
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
{ "vat": 20 }
|
||||
21
packages/chord/test/next/examples/37-plugin-imports/plugins/report/env.d.ts
vendored
Normal file
21
packages/chord/test/next/examples/37-plugin-imports/plugins/report/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Types for the report plugin's imports that its directory does not hold: the app's host packages (an app ships their
|
||||
// types with its plugin template) and its dependencies before `npm install` (their own types once installed).
|
||||
|
||||
declare module "@example/app" {
|
||||
const app: { readonly name: string; readonly version: number };
|
||||
export default app;
|
||||
export function currency(cents: number): string;
|
||||
}
|
||||
|
||||
declare module "@example/legacy" {
|
||||
const legacy: { pad(text: string, width: number): string };
|
||||
export default legacy;
|
||||
}
|
||||
|
||||
declare module "example-greeting" {
|
||||
export function greet(name: string): string;
|
||||
}
|
||||
|
||||
declare module "example-native" {
|
||||
export function where(): string;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "@example/report",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"example-greeting": "file:../../deps/example-greeting",
|
||||
"example-native": "file:../../deps/example-native"
|
||||
},
|
||||
"chord": {
|
||||
"external": ["example-native"]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// The report plugin's server facet: one of each import a facet may have.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { constant, defineFacet } from "@earendil-works/chord/next";
|
||||
import { pluginFile } from "@earendil-works/chord/next/plugins";
|
||||
// Host packages: an ES module (default and named imports) and a CommonJS one, the app's own instances.
|
||||
import app, { currency } from "@example/app";
|
||||
import legacy from "@example/legacy";
|
||||
// An npm dependency: inlined into this script by the build (installed first).
|
||||
import { greet } from "example-greeting";
|
||||
// A `chord.external` package: left external, loaded from this plugin's node_modules when the script runs.
|
||||
import { where } from "example-native";
|
||||
// A contract of another plugin: a copy built into this script.
|
||||
import { Report } from "../shapes/contract.ts";
|
||||
|
||||
// A file of this plugin, read at run time by its absolute path.
|
||||
const rates = JSON.parse(readFileSync(pluginFile("data/rates.json"), "utf8")) as { readonly vat: number };
|
||||
|
||||
export default defineFacet((host) =>
|
||||
host.serve(Report, () => ({
|
||||
text: constant(
|
||||
[
|
||||
`${app.name} v${app.version}`,
|
||||
currency(1250),
|
||||
legacy.pad("vat", 6),
|
||||
`${rates.vat}%`,
|
||||
greet("plugins"),
|
||||
where().split("/").slice(-3).join("/"),
|
||||
].join(" | "),
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/ruler",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// The ruler plugin's server facet: imports a file of the shapes plugin that imports a package. Refused at build: a
|
||||
// copied file may import only host packages and files of its own plugin.
|
||||
|
||||
import { defineFacet } from "@earendil-works/chord/next";
|
||||
import { Millimetres } from "../shapes/units.ts";
|
||||
|
||||
export default defineFacet(() => {
|
||||
console.log(Millimetres);
|
||||
});
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// The shapes plugin's contract (a contracts-only plugin): other plugins import this file and get their own copy of it.
|
||||
// A copied file may import host packages and files of its own plugin only.
|
||||
|
||||
import { local, str } from "@earendil-works/chord/next";
|
||||
|
||||
/** A report: its lines. */
|
||||
export const Report = local("example.report", { version: 1, params: {}, members: { text: str() } });
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/shapes",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// A file of the shapes plugin that imports a package (not a host package): fine for the shapes plugin itself, but a
|
||||
// plugin importing this file would copy it, and a copied file may not import packages (the ruler plugin shows it).
|
||||
|
||||
import { Type } from "typebox";
|
||||
|
||||
/** A length in millimetres. */
|
||||
export const Millimetres = Type.Number({ minimum: 0 });
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// The app's own code (base code): the plugins component watches it and marks `base` when it changes; Chord restarts
|
||||
// nothing for it, the app restarts its hosts.
|
||||
|
||||
export const appName = "example";
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/extra",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// The extra plugin's server facet: not in plugins/ at first; the example copies the plugin in while the app runs. It
|
||||
// declares its type itself (the same shape as `labelOf("extra")`).
|
||||
|
||||
import { constant, defineFacet, local, str } from "@earendil-works/chord/next";
|
||||
|
||||
const Extra = local("example.label.extra", { version: 1, params: {}, members: { text: str() } });
|
||||
|
||||
export default defineFacet((host) => host.serve(Extra, () => ({ text: constant("extra on the server") })));
|
||||
150
packages/chord/test/next/examples/38-watch-and-rollout/main.ts
Normal file
150
packages/chord/test/next/examples/38-watch-and-rollout/main.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// The watcher's marks and rollouts (slice 7c §5). The watcher marks what changed and never applies it: `dirty` for a
|
||||
// plugin whose file changed (a contract file: every plugin that copies it), `install` for a changed `package.json`,
|
||||
// `set` when a plugin directory or facet file is added or removed, `base` when the app's own code changes. The app
|
||||
// builds the dirty plugins and rolls the build out over its hosts in order; a rollout clears the marks of the plugins
|
||||
// it applied on every step. Here: a plugin added, one removed and one dropping its worker facet in one rollout; a
|
||||
// rollout stopping at a step that rejects; a failed plugin staying dirty while the others clear; an edit during a build
|
||||
// keeping its mark.
|
||||
// Run from packages/chord:
|
||||
// node --conditions=source --experimental-strip-types test/next/examples/38-watch-and-rollout/main.ts
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts";
|
||||
import { createHost, type Host, type ReloadReport } from "../../../../src/next/host/index.ts";
|
||||
import * as chordNext from "../../../../src/next/index.ts";
|
||||
import { createPlugins, type RolloutResult, type RolloutStep } from "../../../../src/next-node/plugins/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// The example edits files: it works on a copy of `plugins/` and `app/`, removed at the end.
|
||||
const copy = await mkdtemp(join(tmpdir(), "chord-example-38-"));
|
||||
for (const dir of ["plugins", "app"]) {
|
||||
await cp(fileURLToPath(new URL(`./${dir}/`, import.meta.url)), join(copy, dir), { recursive: true });
|
||||
}
|
||||
const root = join(copy, "plugins");
|
||||
|
||||
/** Waits until `ready()` holds (the watcher's events arrive on their own time). */
|
||||
async function until(ready: () => boolean): Promise<void> {
|
||||
while (!ready()) await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
/** Replaces `from` with `to` in `file` (under the copy). */
|
||||
async function edit(file: string, from: string, to: string): Promise<void> {
|
||||
const text = await readFile(join(copy, file), "utf8");
|
||||
await writeFile(join(copy, file), text.replace(from, to));
|
||||
}
|
||||
|
||||
/** Waits until every plugin of `names` is marked dirty. */
|
||||
const dirty = (...names: string[]) => until(() => names.every((name) => plugins.state.value.dirty.includes(name)));
|
||||
|
||||
/** The marks, in one line. */
|
||||
const marks = () => JSON.stringify(plugins.state.value);
|
||||
|
||||
/** What a step's report says, in one line. */
|
||||
const line = (report: ReloadReport) =>
|
||||
JSON.stringify({ ...report.plugins, failed: report.failed.map(({ plugin }) => plugin) });
|
||||
|
||||
/** A rollout, step by step. */
|
||||
function print(result: RolloutResult): void {
|
||||
for (const { name, report } of result.steps) console.log(` ${name}:`, line(report));
|
||||
if (result.stopped !== undefined) {
|
||||
console.log(` stopped at ${result.stopped.step}: ${(result.stopped.error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = createPlugins(
|
||||
{
|
||||
roots: [root],
|
||||
kinds: ["server", "worker"],
|
||||
hostPackages: { "@earendil-works/chord/next": chordNext },
|
||||
// The app's own code: a change marks `base`.
|
||||
base: [join(copy, "app")],
|
||||
// A stand-in for npm (no network; these plugins have no dependencies).
|
||||
install: { npmCommand: [process.execPath, "-e", ""] },
|
||||
onError: (error) => console.log("plugins:", error.kind),
|
||||
},
|
||||
context,
|
||||
);
|
||||
const server = createHost({ onError: () => {} });
|
||||
const worker = createHost({ onError: () => {} });
|
||||
await plugins.load(server, { kind: "server", core: [] }, context);
|
||||
await plugins.load(worker, { kind: "worker", core: [] }, context);
|
||||
const steps = [plugins.step("server", server as Host), plugins.step("worker", worker as Host)];
|
||||
console.log("1. loaded:", plugins.discovery.plugins.map(({ name }) => name).join(", "), "| marks:", marks());
|
||||
|
||||
// 2. Each kind of change and its mark.
|
||||
await edit("plugins/notes/label.ts", "notes v1", "notes v2");
|
||||
await dirty("@example/notes");
|
||||
console.log("2. a file of notes edited:", marks());
|
||||
await edit("plugins/contracts/contract.ts", "one type per plugin", "one type per plugin (edited)");
|
||||
await dirty("@example/contracts", "@example/tags");
|
||||
console.log(" the shared contract edited:", marks());
|
||||
await edit("plugins/tags/package.json", '"private": true', '"private": true,\n\t"description": "tags"');
|
||||
await until(() => plugins.state.value.install.includes("@example/tags"));
|
||||
console.log(" tags' package.json edited:", marks());
|
||||
await plugins.installDependencies("@example/tags", context);
|
||||
console.log(" tags installed:", marks());
|
||||
await cp(fileURLToPath(new URL("./incoming/extra/", import.meta.url)), join(root, "extra"), { recursive: true });
|
||||
await rm(join(root, "old"), { recursive: true });
|
||||
await rm(join(root, "tags", "worker.ts"));
|
||||
await until(() => plugins.state.value.set);
|
||||
// Deleting old's directory deleted its package.json too: an install mark, which goes with the next discovery.
|
||||
console.log(" extra added, old removed, tags' worker facet deleted:", marks());
|
||||
await edit("app/config.ts", '"example"', '"example 2"');
|
||||
await until(() => plugins.state.value.base);
|
||||
console.log(" the app's own code edited:", marks());
|
||||
|
||||
// 3. One build of the dirty plugins (discovery runs again first: `set`), rolled out server then worker. The server adds
|
||||
// extra and removes old; the worker leaves tags out (it has no worker facet any more). The marks clear, but `base`:
|
||||
// that one is the app's to act on (it restarts its hosts).
|
||||
const built = await plugins.build(plugins.state.value.dirty, context);
|
||||
console.log("3. built:", built.facets.map(({ plugin, facet }) => `${plugin}/${facet}`).join(", "));
|
||||
console.log(" removed:", JSON.stringify(built.removed));
|
||||
print(await plugins.rollout(steps, built, context));
|
||||
console.log(" marks:", marks());
|
||||
|
||||
// 4. A rollout stops at a step that rejects (a host the app cannot reach): the steps after it do not run, and the marks
|
||||
// stay (the plugin was not applied everywhere). The next rollout clears them.
|
||||
const unreachable: RolloutStep = {
|
||||
name: "edge",
|
||||
kinds: ["server"],
|
||||
reload: async () => {
|
||||
throw new Error("the edge host is unreachable");
|
||||
},
|
||||
};
|
||||
await edit("plugins/notes/label.ts", "notes v2", "notes v3");
|
||||
await dirty("@example/notes");
|
||||
const notes = await plugins.build(plugins.state.value.dirty, context);
|
||||
console.log("4. rollout over server, edge, worker:");
|
||||
print(await plugins.rollout([steps[0]!, unreachable, steps[1]!], notes, context));
|
||||
console.log(" marks:", marks());
|
||||
print(await plugins.rollout(steps, notes, context));
|
||||
console.log(" marks after the next rollout:", marks());
|
||||
|
||||
// 5. A failed plugin stays dirty, the others clear: notes gets a syntax error, tags a good edit.
|
||||
await edit("plugins/notes/label.ts", '"notes v3"', '"notes v4" +');
|
||||
await edit("plugins/tags/server.ts", "tags on the server", "tags v2 on the server");
|
||||
await dirty("@example/notes", "@example/tags");
|
||||
console.log("5. notes broken, tags edited:");
|
||||
print(await plugins.rollout(steps, await plugins.build(plugins.state.value.dirty, context), context));
|
||||
console.log(" marks:", marks());
|
||||
|
||||
// 6. An edit after the build started keeps its mark: the rollout applies the build, but the newer edit is not in it.
|
||||
await edit("plugins/notes/label.ts", '"notes v4" +', '"notes v4"');
|
||||
await dirty("@example/notes");
|
||||
const early = await plugins.build(plugins.state.value.dirty, context);
|
||||
await edit("plugins/notes/label.ts", '"notes v4"', '"notes v5"');
|
||||
// The edit's event arrives on its own time (the mark is set already, so no change shows it).
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
console.log("6. built before the second edit:");
|
||||
print(await plugins.rollout(steps, early, context));
|
||||
console.log(" marks:", marks());
|
||||
print(await plugins.rollout(steps, await plugins.build(plugins.state.value.dirty, context), context));
|
||||
console.log(" marks after building again:", marks());
|
||||
|
||||
await server.close(context);
|
||||
await worker.close(context);
|
||||
plugins.close();
|
||||
await rm(copy, { recursive: true, force: true });
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// The contracts the plugins share (a contracts-only plugin): every plugin importing this file has a copy of it, so an
|
||||
// edit here marks every one of them dirty.
|
||||
|
||||
import { local, str } from "@earendil-works/chord/next";
|
||||
|
||||
/** The label a plugin shows, one type per plugin. */
|
||||
export function labelOf(plugin: string) {
|
||||
return local(`example.label.${plugin}`, { version: 1, params: {}, members: { text: str() } });
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/contracts",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// The notes' label; the example edits it.
|
||||
|
||||
export const label = "notes v1";
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/notes",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// The notes plugin's server facet: its label.
|
||||
|
||||
import { constant, defineFacet } from "@earendil-works/chord/next";
|
||||
import { labelOf } from "../contracts/contract.ts";
|
||||
import { label } from "./label.ts";
|
||||
|
||||
export default defineFacet((host) =>
|
||||
host.serve(labelOf("notes"), () => ({ text: constant(`${label} on the server`) })),
|
||||
);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// The notes plugin's worker facet: its label.
|
||||
|
||||
import { constant, defineFacet } from "@earendil-works/chord/next";
|
||||
import { labelOf } from "../contracts/contract.ts";
|
||||
import { label } from "./label.ts";
|
||||
|
||||
export default defineFacet((host) =>
|
||||
host.serve(labelOf("notes"), () => ({ text: constant(`${label} on the worker`) })),
|
||||
);
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@example/old",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// The old plugin's server facet; the example deletes the plugin's directory.
|
||||
|
||||
import { constant, defineFacet } from "@earendil-works/chord/next";
|
||||
import { labelOf } from "../contracts/contract.ts";
|
||||
|
||||
export default defineFacet((host) => host.serve(labelOf("old"), () => ({ text: constant("old on the server") })));
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue