@nimbus-sh/core 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/runtime/bash-runner.d.ts +63 -0
- package/dist/runtime/bash-runner.d.ts.map +1 -0
- package/dist/runtime/bash-runner.generated.d.ts +14 -0
- package/dist/runtime/bash-runner.generated.d.ts.map +1 -0
- package/dist/runtime/bash-runner.generated.js +13 -0
- package/dist/runtime/bash-runner.js +290 -0
- package/dist/runtime/cpython-runner.d.ts +86 -0
- package/dist/runtime/cpython-runner.d.ts.map +1 -0
- package/dist/runtime/cpython-runner.js +425 -0
- package/dist/runtime/facet-host.d.ts +159 -0
- package/dist/runtime/facet-host.d.ts.map +1 -0
- package/dist/runtime/facet-host.js +22 -0
- package/dist/runtime/installed-runtimes.d.ts +99 -0
- package/dist/runtime/installed-runtimes.d.ts.map +1 -0
- package/dist/runtime/installed-runtimes.js +162 -0
- package/dist/runtime/local-facet-host.d.ts +38 -0
- package/dist/runtime/local-facet-host.d.ts.map +1 -0
- package/dist/runtime/local-facet-host.js +171 -0
- package/dist/runtime/python-pip.d.ts +38 -0
- package/dist/runtime/python-pip.d.ts.map +1 -0
- package/dist/runtime/python-pip.js +1063 -0
- package/dist/runtime/runtime-manifest.d.ts +86 -0
- package/dist/runtime/runtime-manifest.d.ts.map +1 -0
- package/dist/runtime/runtime-manifest.js +72 -0
- package/dist/runtime/runtime-package.d.ts +63 -0
- package/dist/runtime/runtime-package.d.ts.map +1 -0
- package/dist/runtime/runtime-package.js +66 -0
- package/dist/runtime/runtime-registry.d.ts +162 -0
- package/dist/runtime/runtime-registry.d.ts.map +1 -0
- package/dist/runtime/runtime-registry.js +363 -0
- package/dist/runtime/vfs-snapshot.d.ts.map +1 -1
- package/dist/runtime/vfs-snapshot.js +15 -1
- package/dist/runtime/vfs-supervisor.d.ts +22 -0
- package/dist/runtime/vfs-supervisor.d.ts.map +1 -0
- package/dist/runtime/vfs-supervisor.js +65 -0
- package/dist/runtime/virtual-socket-kernel.generated.d.ts +14 -0
- package/dist/runtime/virtual-socket-kernel.generated.d.ts.map +1 -0
- package/dist/runtime/virtual-socket-kernel.generated.js +13 -0
- package/dist/runtime/wasm-runner.d.ts +80 -0
- package/dist/runtime/wasm-runner.d.ts.map +1 -0
- package/dist/runtime/wasm-runner.js +686 -0
- package/dist/workspace/nimbus-workspace.d.ts +116 -20
- package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
- package/dist/workspace/nimbus-workspace.js +238 -45
- package/package.json +4 -2
- package/src/index.ts +16 -0
- package/src/runtime/bash-runner.generated.ts +14 -0
- package/src/runtime/bash-runner.ts +347 -0
- package/src/runtime/cpython-runner.ts +504 -0
- package/src/runtime/facet-host.ts +170 -0
- package/src/runtime/installed-runtimes.ts +235 -0
- package/src/runtime/local-facet-host.ts +205 -0
- package/src/runtime/python-pip.ts +1211 -0
- package/src/runtime/runtime-manifest.ts +155 -0
- package/src/runtime/runtime-package.ts +114 -0
- package/src/runtime/runtime-registry.ts +511 -0
- package/src/runtime/vfs-snapshot.ts +15 -1
- package/src/runtime/vfs-supervisor.ts +67 -0
- package/src/runtime/virtual-socket-kernel.generated.ts +14 -0
- package/src/runtime/wasm-runner.ts +835 -0
- package/src/workspace/nimbus-workspace.ts +349 -54
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* installed-runtimes.ts — the runtimes a session already has, and the shell
|
|
3
|
+
* commands they answer.
|
|
4
|
+
*
|
|
5
|
+
* `nimbus install <name>` unpacks a runtime into
|
|
6
|
+
* `~/.nimbus/runtimes/<name>/<version>/` and this is what reads it back: the
|
|
7
|
+
* manifests on disk, the runner each entrypoint names, and the registration
|
|
8
|
+
* that turns the pair into an invokable command. It runs at install time and
|
|
9
|
+
* again at every boot, because a Durable Object that was evicted comes back
|
|
10
|
+
* with the filesystem and none of the registry.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here fetches. Where a runtime came FROM — an R2 bucket and a digest
|
|
13
|
+
* chain in the Cloudflare deployment — is `@nimbus-sh/worker`'s
|
|
14
|
+
* `runtime/package-manager.ts`; a runtime that is already installed is the
|
|
15
|
+
* same runtime whichever publisher put it there, so an embedder that seeds the
|
|
16
|
+
* tree itself gets working commands out of this with nothing else in play.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { CredentialedVfs, SqliteVFS } from '../vfs/sqlite-vfs.js';
|
|
20
|
+
import {
|
|
21
|
+
CRED_KERNEL,
|
|
22
|
+
NIMBUS_ABI_TARGET,
|
|
23
|
+
NIMBUS_RUNTIME_ABIS,
|
|
24
|
+
NATIVE_UNSUPPORTED_ABI,
|
|
25
|
+
type RuntimePackageAbi,
|
|
26
|
+
} from './os-contracts.js';
|
|
27
|
+
import {
|
|
28
|
+
parseRuntimeManifest,
|
|
29
|
+
type ManifestEntrypoint,
|
|
30
|
+
type RuntimeManifest,
|
|
31
|
+
} from './runtime-manifest.js';
|
|
32
|
+
|
|
33
|
+
/** Minimal shell-registry shape we depend on. */
|
|
34
|
+
export interface MinShellRegistry {
|
|
35
|
+
register(name: string, handler: (ctx: any) => Promise<number>): void;
|
|
36
|
+
unregister?(name: string): void;
|
|
37
|
+
resolve?(name: string): any;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Runner-factory contract. Each registered runner produces a shell-
|
|
41
|
+
* command handler given the manifest + the installed root dir. The
|
|
42
|
+
* package manager invokes the factory at install-time + at boot-time
|
|
43
|
+
* rehydration. */
|
|
44
|
+
export type RunnerFactory = (
|
|
45
|
+
manifest: RuntimeManifest,
|
|
46
|
+
installRoot: string,
|
|
47
|
+
binName: string,
|
|
48
|
+
binKind: string | undefined,
|
|
49
|
+
) => (ctx: any) => Promise<number>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How a manifest entrypoint's `runner` key is resolved to code.
|
|
53
|
+
*
|
|
54
|
+
* A parameter rather than a fixed lookup because the process-global table
|
|
55
|
+
* below is a property of the Cloudflare session, not of the idea: a factory
|
|
56
|
+
* closes over one session's filesystem and one session's facet host, so an
|
|
57
|
+
* embedder holding two workspaces in one process must be able to give each its
|
|
58
|
+
* own without the second silently retargeting the first.
|
|
59
|
+
*/
|
|
60
|
+
export type RunnerLookup = (key: string) => RunnerFactory | undefined;
|
|
61
|
+
|
|
62
|
+
/** Map of runner-key → factory. Populated by init.ts before install. */
|
|
63
|
+
const runnerFactories: Record<string, RunnerFactory> = {};
|
|
64
|
+
|
|
65
|
+
export function registerRunnerFactory(key: string, factory: RunnerFactory): void {
|
|
66
|
+
runnerFactories[key] = factory;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getRegisteredRunners(): string[] {
|
|
70
|
+
return Object.keys(runnerFactories);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The factory a manifest entrypoint's `runner` names, or undefined. */
|
|
74
|
+
export function runnerFactoryFor(key: string): RunnerFactory | undefined {
|
|
75
|
+
return runnerFactories[key];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface RuntimeSummary {
|
|
79
|
+
name: string;
|
|
80
|
+
version: string;
|
|
81
|
+
root: string;
|
|
82
|
+
abi: RuntimePackageAbi;
|
|
83
|
+
bins: string[];
|
|
84
|
+
sizeBytes: number;
|
|
85
|
+
license: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function runtimeAbiForManifest(manifest: RuntimeManifest): RuntimePackageAbi {
|
|
89
|
+
const byName = NIMBUS_RUNTIME_ABIS[manifest.name];
|
|
90
|
+
if (byName) return byName;
|
|
91
|
+
if (manifest.wasi_namespace) return NIMBUS_ABI_TARGET;
|
|
92
|
+
if (manifest.entrypoints.some((entrypoint) => entrypoint.runner === 'clang-runner')) {
|
|
93
|
+
return NIMBUS_ABI_TARGET;
|
|
94
|
+
}
|
|
95
|
+
return NATIVE_UNSUPPORTED_ABI;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Commands a runtime provides beyond its manifest entrypoints. The
|
|
100
|
+
* python/ruby package-manager front-ends (pip, gem, bundler) ride the
|
|
101
|
+
* language runner rather than shipping as manifest files, and already-
|
|
102
|
+
* deployed R2 manifests cannot retroactively declare them.
|
|
103
|
+
*
|
|
104
|
+
* This is the ONE hand-maintained command table: install aliasing
|
|
105
|
+
* (`nimbus install pip` → python), command-not-found hints, and bin
|
|
106
|
+
* registration all derive from `runtimeEntrypoints`, which merges this
|
|
107
|
+
* with the catalog manifest. Catalog-declared aliases (python3, ruby3,
|
|
108
|
+
* wasm-ld, …) come from manifest entrypoints and must NOT be repeated
|
|
109
|
+
* here. Mechanically validated against `NIMBUS_RUNTIME_ABIS` by
|
|
110
|
+
* tests/unit/runtime-command-aliases.mjs.
|
|
111
|
+
*/
|
|
112
|
+
export const RUNTIME_EXTRA_ENTRYPOINTS: Readonly<Record<string, readonly ManifestEntrypoint[]>> = {
|
|
113
|
+
bash: [
|
|
114
|
+
{ binName: '/bin/bash', runner: 'bash-runner', args: [] },
|
|
115
|
+
{ binName: '/usr/bin/bash', runner: 'bash-runner', args: [] },
|
|
116
|
+
],
|
|
117
|
+
// `pip` belongs to whichever runtime provides the interpreter, and only one
|
|
118
|
+
// may claim it. The python row went with python-runner: Pyodide's manifest
|
|
119
|
+
// still names that runner, so it could not serve pip even if it were listed.
|
|
120
|
+
cpython: [
|
|
121
|
+
{ binName: 'pip', runner: 'cpython-runner', kind: 'pip', args: [] },
|
|
122
|
+
{ binName: 'pip3', runner: 'cpython-runner', kind: 'pip', args: [] },
|
|
123
|
+
],
|
|
124
|
+
ruby: [
|
|
125
|
+
{ binName: 'gem', runner: 'ruby-runner', kind: 'gem', args: [] },
|
|
126
|
+
{ binName: 'bundle', runner: 'ruby-runner', kind: 'bundle', args: [] },
|
|
127
|
+
{ binName: 'bundler', runner: 'ruby-runner', kind: 'bundle', args: [] },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export function runtimeEntrypoints(manifest: RuntimeManifest): RuntimeManifest['entrypoints'] {
|
|
132
|
+
const out = [...manifest.entrypoints];
|
|
133
|
+
const seen = new Set(out.map((ep) => ep.binName));
|
|
134
|
+
for (const ep of RUNTIME_EXTRA_ENTRYPOINTS[manifest.name] ?? []) {
|
|
135
|
+
if (seen.has(ep.binName)) continue;
|
|
136
|
+
out.push({ ...ep });
|
|
137
|
+
seen.add(ep.binName);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Compute the per-user install root for (name, version). Uses
|
|
143
|
+
* `process.env.HOME` if present; falls back to `/home/user`. */
|
|
144
|
+
export function installRoot(homeDir: string, name: string, version: string): string {
|
|
145
|
+
// Strip leading slash so SqliteFS sees a relative-looking VFS path,
|
|
146
|
+
// matching the convention used elsewhere in src/session/init.ts.
|
|
147
|
+
const home = homeDir.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
148
|
+
return `${home}/.nimbus/runtimes/${name}/${version}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Read all installed manifests off SqliteFS. Used by both `--list`
|
|
152
|
+
* and boot-time rehydration. */
|
|
153
|
+
export function listInstalledManifests(
|
|
154
|
+
vfs: SqliteVFS,
|
|
155
|
+
homeDir: string,
|
|
156
|
+
): Array<{ root: string; manifest: RuntimeManifest }> {
|
|
157
|
+
return listInstalledManifestsView(vfs.as(CRED_KERNEL), homeDir);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function listInstalledManifestsView(
|
|
161
|
+
fs: CredentialedVfs,
|
|
162
|
+
homeDir: string,
|
|
163
|
+
): Array<{ root: string; manifest: RuntimeManifest }> {
|
|
164
|
+
const home = homeDir.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
165
|
+
const runtimesRoot = `${home}/.nimbus/runtimes`;
|
|
166
|
+
const out: Array<{ root: string; manifest: RuntimeManifest }> = [];
|
|
167
|
+
if (!fs.exists(runtimesRoot)) return out;
|
|
168
|
+
// Each entry under runtimesRoot is a <name>; each entry under that
|
|
169
|
+
// is a <version>; each <version> dir has a manifest.json.
|
|
170
|
+
for (const nameEntry of fs.readdir(runtimesRoot)) {
|
|
171
|
+
if (nameEntry.type !== 'directory') continue;
|
|
172
|
+
const nameDir = `${runtimesRoot}/${nameEntry.name}`;
|
|
173
|
+
for (const verEntry of fs.readdir(nameDir)) {
|
|
174
|
+
if (verEntry.type !== 'directory') continue;
|
|
175
|
+
const verDir = `${nameDir}/${verEntry.name}`;
|
|
176
|
+
const manifestPath = `${verDir}/manifest.json`;
|
|
177
|
+
if (!fs.exists(manifestPath)) continue;
|
|
178
|
+
try {
|
|
179
|
+
const manifest = parseRuntimeManifest(JSON.parse(fs.readFileString(manifestPath)));
|
|
180
|
+
out.push({ root: verDir, manifest });
|
|
181
|
+
} catch {
|
|
182
|
+
// Malformed manifest — skip silently. Surfacing via stderr
|
|
183
|
+
// would require a ctx we don't have at boot-time rehydration.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Re-register every installed runtime's entrypoints in the shell
|
|
192
|
+
* registry. Call once at session-init time after all runner factories
|
|
193
|
+
* are registered (init.ts:registerRunnerFactory blocks).
|
|
194
|
+
*/
|
|
195
|
+
export function rehydrateInstalledRuntimes(
|
|
196
|
+
vfs: SqliteVFS,
|
|
197
|
+
registry: MinShellRegistry,
|
|
198
|
+
homeDir: string,
|
|
199
|
+
): { count: number; bins: string[] } {
|
|
200
|
+
return rehydrateInstalledRuntimesView(vfs.as(CRED_KERNEL), registry, homeDir, runnerFactoryFor);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function rehydrateInstalledRuntimesView(
|
|
204
|
+
vfs: CredentialedVfs,
|
|
205
|
+
registry: MinShellRegistry,
|
|
206
|
+
homeDir: string,
|
|
207
|
+
runnerFor: RunnerLookup,
|
|
208
|
+
): { count: number; bins: string[] } {
|
|
209
|
+
const bins: string[] = [];
|
|
210
|
+
for (const { root, manifest } of listInstalledManifestsView(vfs, homeDir)) {
|
|
211
|
+
for (const ep of runtimeEntrypoints(manifest)) {
|
|
212
|
+
const factory = runnerFor(ep.runner);
|
|
213
|
+
if (!factory) continue; // runner not registered yet — skip
|
|
214
|
+
const handler = factory(manifest, root, ep.binName, ep.kind);
|
|
215
|
+
registry.register(ep.binName, handler);
|
|
216
|
+
bins.push(ep.binName);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { count: bins.length, bins };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function listInstalledRuntimes(
|
|
223
|
+
vfs: SqliteVFS,
|
|
224
|
+
homeDir: string,
|
|
225
|
+
): RuntimeSummary[] {
|
|
226
|
+
return listInstalledManifests(vfs, homeDir).map(({ root, manifest }) => ({
|
|
227
|
+
name: manifest.name,
|
|
228
|
+
version: manifest.version,
|
|
229
|
+
root,
|
|
230
|
+
abi: runtimeAbiForManifest(manifest),
|
|
231
|
+
bins: runtimeEntrypoints(manifest).map((e) => e.binName),
|
|
232
|
+
sizeBytes: manifest.files.reduce((a, f) => a + f.size, 0),
|
|
233
|
+
license: manifest.license,
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* local-facet-host.ts — a facet that runs in the caller's own isolate.
|
|
3
|
+
*
|
|
4
|
+
* The {@link FacetHost} for every embedder that is not workerd. There is no
|
|
5
|
+
* dynamic-worker substrate to reach for and no CSP forbidding a compile, so the
|
|
6
|
+
* scope a facet needs is built where it is asked for: compile the wasm table,
|
|
7
|
+
* evaluate the preamble once, and evaluate each submitted function inside it.
|
|
8
|
+
*
|
|
9
|
+
* The function is still SERIALIZED rather than called in place, and that is the
|
|
10
|
+
* point rather than an accident of symmetry. A runner's facet function reads
|
|
11
|
+
* names the preamble declares — `__wasiMakeImports`, `__bashBoot` — which exist
|
|
12
|
+
* only in the scope the preamble was evaluated in; calling the original closure
|
|
13
|
+
* would resolve them against this module instead and fail. Serializing also
|
|
14
|
+
* keeps the contract honest: a closure reference that would break on workerd
|
|
15
|
+
* breaks here too, in a unit test, rather than in production.
|
|
16
|
+
*
|
|
17
|
+
* `globalThis` inside a facet is the facet's own scope object, not the process
|
|
18
|
+
* global. Preambles publish their entry points on it and runners read them back
|
|
19
|
+
* from it, so two facets in one process must not see each other's — and the
|
|
20
|
+
* process must not see either.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
Facet,
|
|
25
|
+
FacetBindings,
|
|
26
|
+
FacetFilesystemOptions,
|
|
27
|
+
FacetFilesystemSeed,
|
|
28
|
+
FacetFn,
|
|
29
|
+
FacetHost,
|
|
30
|
+
FacetSpec,
|
|
31
|
+
FacetSubmitOptions,
|
|
32
|
+
} from './facet-host.js';
|
|
33
|
+
import type { CredentialedVfs } from '../vfs/sqlite-vfs.js';
|
|
34
|
+
import { snapshotVfs } from './vfs-snapshot.js';
|
|
35
|
+
import { vfsSupervisor } from './vfs-supervisor.js';
|
|
36
|
+
|
|
37
|
+
/** A submitted function after it has been re-created inside the facet's scope. */
|
|
38
|
+
type ScopedFacetFn = (args: unknown, bindings: FacetBindings) => unknown;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `new Function`, but for a body that may `await` at its top level.
|
|
42
|
+
*
|
|
43
|
+
* A facet preamble is written as a MODULE body, and the WASI shim uses that:
|
|
44
|
+
* it resolves `cloudflare:sockets` with a top-level `await import(...)` inside a
|
|
45
|
+
* try/catch, so a host that does not have the module gets a shim without
|
|
46
|
+
* sockets instead of a shim that fails to parse. `new Function` cannot hold
|
|
47
|
+
* that; an async function body can, and the rejected import lands in the same
|
|
48
|
+
* catch it was written for.
|
|
49
|
+
*/
|
|
50
|
+
const AsyncFunction = Object.getPrototypeOf(async (): Promise<void> => {}).constructor as
|
|
51
|
+
new (...args: string[]) => (this: object, ...args: unknown[]) => Promise<unknown>;
|
|
52
|
+
|
|
53
|
+
type WasmCompiler = (bytes: BufferSource) => Promise<WebAssembly.Module>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The standard `WebAssembly.compile`, checked for rather than assumed.
|
|
57
|
+
*
|
|
58
|
+
* `@cloudflare/workers-types` declares no compiler and an abstract `Module`,
|
|
59
|
+
* which is not an oversight: workerd forbids compiling at request time, and
|
|
60
|
+
* that prohibition is the entire reason facets exist. Core is typed against
|
|
61
|
+
* that surface, so the one host that DOES compile asks for the capability by
|
|
62
|
+
* name and says so plainly when it is absent.
|
|
63
|
+
*/
|
|
64
|
+
function wasmCompiler(): WasmCompiler {
|
|
65
|
+
const compile: unknown = Reflect.get(WebAssembly, 'compile');
|
|
66
|
+
if (typeof compile !== 'function') {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'Nimbus: this host cannot compile WebAssembly in place (no WebAssembly.compile), '
|
|
69
|
+
+ 'so it needs a facet host with its own isolates rather than this one',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return compile as WasmCompiler;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The bound on a complete seed: a limit on this process's own heap.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately far above `snapshotVfs`'s 32 MiB default, which is a TRANSPORT
|
|
79
|
+
* limit — the ceiling on one workerd RPC payload. Nothing is transported here,
|
|
80
|
+
* so keeping that number would refuse a program over a filesystem the host is
|
|
81
|
+
* already holding. Exceeding this is still an error rather than a truncation:
|
|
82
|
+
* a seed that silently omitted a file would make it absent to the guest.
|
|
83
|
+
*/
|
|
84
|
+
const LOCAL_SEED_MAX_BYTES = 512 * 1024 * 1024;
|
|
85
|
+
const LOCAL_SEED_MAX_FILES = 200_000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run facets in this isolate.
|
|
89
|
+
*
|
|
90
|
+
* `parking: 'none'` is the whole character of this host, and everything else
|
|
91
|
+
* follows from it: the guest is entered on an ordinary stack, so no syscall may
|
|
92
|
+
* suspend it, so {@link FacetHost.seedFilesystem} hands over the bytes rather
|
|
93
|
+
* than a manifest to fetch them with, and the supervisor it mints serves only
|
|
94
|
+
* the writes — which drain after the program returns, where a promise is free.
|
|
95
|
+
*
|
|
96
|
+
* The one thing a substrate with its own isolates gives that this cannot:
|
|
97
|
+
* {@link FacetSubmitOptions.timeoutMs} is not honoured. A guest spinning
|
|
98
|
+
* synchronously holds the only thread, so no timer fires until it is already
|
|
99
|
+
* finished; racing one would return while the program ran on.
|
|
100
|
+
*/
|
|
101
|
+
export function localFacetHost(): FacetHost {
|
|
102
|
+
return {
|
|
103
|
+
parking: 'none',
|
|
104
|
+
/**
|
|
105
|
+
* By value, and exhaustively: no skip list, because a directory hidden
|
|
106
|
+
* from the seed is a directory the guest cannot see at all — there is no
|
|
107
|
+
* second chance to fetch it. That completeness is what
|
|
108
|
+
* `snapshotVfs` records as `enumeratedRoots`, and it is what keeps CPython's
|
|
109
|
+
* thousands of startup probes for absent paths from each trying to suspend.
|
|
110
|
+
*/
|
|
111
|
+
seedFilesystem(
|
|
112
|
+
vfs: CredentialedVfs,
|
|
113
|
+
root: string,
|
|
114
|
+
options?: FacetFilesystemOptions,
|
|
115
|
+
): FacetFilesystemSeed | { error: string } {
|
|
116
|
+
return snapshotVfs(vfs, root, {
|
|
117
|
+
extraRoots: options?.extraRoots,
|
|
118
|
+
skipSubdirs: [],
|
|
119
|
+
maxBytes: LOCAL_SEED_MAX_BYTES,
|
|
120
|
+
maxFiles: LOCAL_SEED_MAX_FILES,
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
open: (spec) => new LocalFacet(spec),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class LocalFacet implements Facet {
|
|
128
|
+
/** Evaluates a source string in the preamble's own scope. Built on first use. */
|
|
129
|
+
private evaluate: ((source: string) => unknown) | null = null;
|
|
130
|
+
private readonly wasmTable: Record<string, WebAssembly.Module> = {};
|
|
131
|
+
/** Keyed by the bytes themselves: the same image is compiled once per facet. */
|
|
132
|
+
private readonly modules = new WeakMap<ArrayBuffer, WebAssembly.Module>();
|
|
133
|
+
private readonly scoped = new Map<FacetFn<never, unknown>, ScopedFacetFn>();
|
|
134
|
+
/** Submits are serialized: one scope, and a facet's calls are ordered. */
|
|
135
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
136
|
+
private disposed = false;
|
|
137
|
+
|
|
138
|
+
private readonly bindings: FacetBindings;
|
|
139
|
+
|
|
140
|
+
constructor(private readonly spec: FacetSpec) {
|
|
141
|
+
this.bindings = spec.syscalls
|
|
142
|
+
? { SUPERVISOR: vfsSupervisor(spec.syscalls.vfs) }
|
|
143
|
+
: {};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
submit<A, R>(fn: FacetFn<A, R>, args: A, options?: FacetSubmitOptions): Promise<Awaited<R>> {
|
|
147
|
+
const run = this.queue.then(() => this.call(fn, args, options));
|
|
148
|
+
// The chain must survive a rejected call, or one failure poisons the facet.
|
|
149
|
+
this.queue = run.catch(() => undefined);
|
|
150
|
+
return run as Promise<Awaited<R>>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async call<A, R>(fn: FacetFn<A, R>, args: A, options?: FacetSubmitOptions): Promise<R> {
|
|
154
|
+
if (this.disposed) throw new Error(`Nimbus: facet '${this.spec.tag}' is disposed`);
|
|
155
|
+
const scope = await this.scope();
|
|
156
|
+
await this.addModules(options?.wasmModules);
|
|
157
|
+
let scoped = this.scoped.get(fn as FacetFn<never, unknown>);
|
|
158
|
+
if (!scoped) {
|
|
159
|
+
const value = scope(`(${fn.toString()})`);
|
|
160
|
+
if (typeof value !== 'function') {
|
|
161
|
+
throw new Error(`Nimbus: facet '${this.spec.tag}' was submitted a value that is not a function`);
|
|
162
|
+
}
|
|
163
|
+
scoped = value as ScopedFacetFn;
|
|
164
|
+
this.scoped.set(fn as FacetFn<never, unknown>, scoped);
|
|
165
|
+
}
|
|
166
|
+
return await scoped(args, this.bindings) as R;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The facet's scope, built once.
|
|
171
|
+
*
|
|
172
|
+
* The returned closure's `eval` is a DIRECT eval inside the body the preamble
|
|
173
|
+
* was evaluated in, which is what puts the preamble's top-level declarations
|
|
174
|
+
* in scope for every function submitted afterwards.
|
|
175
|
+
*/
|
|
176
|
+
private async scope(): Promise<(source: string) => unknown> {
|
|
177
|
+
if (this.evaluate) return this.evaluate;
|
|
178
|
+
await this.addModules(this.spec.wasmModules);
|
|
179
|
+
const globals = { __NIMBUS_WASM: this.wasmTable };
|
|
180
|
+
const build = new AsyncFunction(
|
|
181
|
+
'globalThis',
|
|
182
|
+
`${this.spec.preamble ?? ''}\nreturn (source) => eval(source);`,
|
|
183
|
+
);
|
|
184
|
+
this.evaluate = await build.call(globals, globals) as (source: string) => unknown;
|
|
185
|
+
return this.evaluate;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async addModules(modules: Record<string, ArrayBuffer> | undefined): Promise<void> {
|
|
189
|
+
for (const [name, bytes] of Object.entries(modules ?? {})) {
|
|
190
|
+
let module = this.modules.get(bytes);
|
|
191
|
+
if (!module) {
|
|
192
|
+
module = await wasmCompiler()(bytes);
|
|
193
|
+
this.modules.set(bytes, module);
|
|
194
|
+
}
|
|
195
|
+
this.wasmTable[name] = module;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
dispose(): void {
|
|
200
|
+
this.disposed = true;
|
|
201
|
+
this.evaluate = null;
|
|
202
|
+
this.scoped.clear();
|
|
203
|
+
for (const name of Object.keys(this.wasmTable)) delete this.wasmTable[name];
|
|
204
|
+
}
|
|
205
|
+
}
|