@nimbus-sh/fabric 0.4.0 → 0.6.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/bindings.d.ts +5 -49
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +59 -27
- package/dist/fanout.d.ts +4 -11
- package/dist/fanout.d.ts.map +1 -1
- package/dist/fanout.js +38 -42
- package/dist/generation.d.ts +6 -0
- package/dist/generation.d.ts.map +1 -1
- package/dist/generation.js +6 -0
- package/dist/host-dispatch.d.ts +6 -0
- package/dist/host-dispatch.d.ts.map +1 -0
- package/dist/host-dispatch.js +27 -0
- package/dist/image-store.d.ts +11 -1
- package/dist/image-store.d.ts.map +1 -1
- package/dist/image-store.js +31 -20
- package/dist/isolate-pool.d.ts +40 -0
- package/dist/isolate-pool.d.ts.map +1 -1
- package/dist/isolate-pool.js +120 -14
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +60 -33
- package/dist/turn-budget.d.ts +4 -4
- package/dist/turn-budget.js +4 -4
- package/package.json +4 -4
- package/src/bindings.ts +57 -92
- package/src/fanout.ts +48 -67
- package/src/generation.ts +6 -0
- package/src/host-dispatch.ts +34 -0
- package/src/image-store.ts +33 -20
- package/src/isolate-pool.ts +179 -17
- package/src/process-host.ts +64 -44
- package/src/turn-budget.ts +4 -4
package/src/bindings.ts
CHANGED
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
import { WorkerEntrypoint } from 'cloudflare:workers';
|
|
27
27
|
import { z } from 'zod/v4';
|
|
28
28
|
import { disposeRpcResource, useRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
29
|
-
import { supervisorEntrypoint, supervisorEntrypointName } from './composition.js';
|
|
29
|
+
import { hostNamespace, supervisorEntrypoint, supervisorEntrypointName } from './composition.js';
|
|
30
30
|
import { stagedBootAssembler } from './composition.js';
|
|
31
|
+
import { hostNamespaceBinding, hostOpDispatch, type HostOpDispatch } from './host-dispatch.js';
|
|
31
32
|
import { assertModuleMapWithinCodeLimit } from './budgets.js';
|
|
32
33
|
import type { EntrypointLoopbackFactory } from './composition.js';
|
|
33
34
|
import type { WorkerCode } from './vendor/types.js';
|
|
@@ -44,17 +45,6 @@ interface ShimCtxExports {
|
|
|
44
45
|
NimbusDOStub?: EntrypointLoopbackFactory;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
/**
|
|
48
|
-
* The supervisor DO namespace a shim resolves ONE stub from, by the id its
|
|
49
|
-
* props carry. `Stub` is that DO's RPC surface as the calling shim uses it —
|
|
50
|
-
* the supervisor class belongs to the embedder, so each shim names the methods
|
|
51
|
-
* it calls rather than the class.
|
|
52
|
-
*/
|
|
53
|
-
interface SupervisorNamespace<Stub> {
|
|
54
|
-
idFromString(id: string): DurableObjectId;
|
|
55
|
-
get(id: DurableObjectId): Stub;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
48
|
/**
|
|
59
49
|
* A dynamic worker's entrypoint, as hop 3 relays to it. `fetch` is the
|
|
60
50
|
* entrypoint contract every loaded worker answers; `handleHttpRequest` is the
|
|
@@ -116,34 +106,6 @@ function shimCtxExports(ctx: unknown): ShimCtxExports {
|
|
|
116
106
|
return exports as ShimCtxExports;
|
|
117
107
|
}
|
|
118
108
|
|
|
119
|
-
// ── Inner-Worker loopback bindings ────────────────────────────────────
|
|
120
|
-
//
|
|
121
|
-
// These WorkerEntrypoint classes are top-level exports so that ctx.exports
|
|
122
|
-
// auto-populates Service Bindings for them (enable_ctx_exports compat
|
|
123
|
-
// flag is already enabled via default compatibility_date 2026-04-01).
|
|
124
|
-
//
|
|
125
|
-
// They are re-exported from src/index.ts so wrangler detects them as
|
|
126
|
-
// reachable from the entry file and bundles their classes.
|
|
127
|
-
//
|
|
128
|
-
// Usage pattern (in nimbus-wrangler.ts):
|
|
129
|
-
// ctx.exports.NimbusAssetsRPC({ props: { vfsRoot, assetsDir } })
|
|
130
|
-
// produces a Service Binding stub that can be placed in the inner
|
|
131
|
-
// Worker's `env` under whatever binding name the user declared in
|
|
132
|
-
// wrangler.jsonc's `assets.binding` (typically "ASSETS").
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* What the assets shim reads off the supervisor DO: the VFS bytes of one path,
|
|
136
|
-
* or null when it holds no such file.
|
|
137
|
-
*/
|
|
138
|
-
interface AssetsSupervisorStub {
|
|
139
|
-
_rpcReadFileBytes(path: string): Promise<ArrayBuffer | Uint8Array | null>;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** `env` for the assets shim: the supervisor its VFS reads round-trip through. */
|
|
143
|
-
interface NimbusAssetsEnv {
|
|
144
|
-
NIMBUS_SESSION?: SupervisorNamespace<AssetsSupervisorStub>;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
109
|
/** Props the assets shim is minted with. */
|
|
148
110
|
interface NimbusAssetsProps {
|
|
149
111
|
/** Project root in VFS (e.g. "home/user/myapp"). */
|
|
@@ -175,7 +137,7 @@ interface NimbusAssetsProps {
|
|
|
175
137
|
* For Phase 1, we use a simpler approach: the props carry a supervisor
|
|
176
138
|
* DO id so we can round-trip through an RPC method that reads the file.
|
|
177
139
|
*/
|
|
178
|
-
export class NimbusAssetsRPC extends WorkerEntrypoint<
|
|
140
|
+
export class NimbusAssetsRPC extends WorkerEntrypoint<object, NimbusAssetsProps> {
|
|
179
141
|
/**
|
|
180
142
|
* Fetch a static asset. Called by the inner Worker as
|
|
181
143
|
* `env.ASSETS.fetch(request)`. The request URL's pathname is used to
|
|
@@ -193,12 +155,22 @@ export class NimbusAssetsRPC extends WorkerEntrypoint<NimbusAssetsEnv, NimbusAss
|
|
|
193
155
|
const parts = clean.split('/').filter((p) => p && p !== '..' && p !== '.');
|
|
194
156
|
clean = parts.join('/');
|
|
195
157
|
|
|
196
|
-
// Resolve the supervisor DO
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
158
|
+
// Resolve the supervisor DO through the composed host namespace and
|
|
159
|
+
// dispatch readFileBytes through its supervisorOp entrypoint — a host
|
|
160
|
+
// forwards envelopes, not private _rpc* methods.
|
|
161
|
+
if (!doId) {
|
|
162
|
+
return new Response('ASSETS binding not wired: missing doId', { status: 500 });
|
|
163
|
+
}
|
|
164
|
+
let stub: DurableObjectStub | null = null;
|
|
165
|
+
let dispatch: HostOpDispatch;
|
|
166
|
+
try {
|
|
167
|
+
const ns = hostNamespaceBinding(this.env, 'NimbusAssetsRPC');
|
|
168
|
+
stub = ns.get(ns.idFromString(doId));
|
|
169
|
+
dispatch = hostOpDispatch(stub, 'NimbusAssetsRPC');
|
|
170
|
+
} catch (e) {
|
|
171
|
+
disposeRpcResource(stub);
|
|
172
|
+
return new Response(`ASSETS binding not wired: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
|
|
200
173
|
}
|
|
201
|
-
const stub = ns.get(ns.idFromString(doId));
|
|
202
174
|
|
|
203
175
|
// Candidate VFS paths, tried in order. The assetsDir is relative to
|
|
204
176
|
// the project root in VFS. Trailing-slash and bare dir → index.html.
|
|
@@ -219,10 +191,14 @@ export class NimbusAssetsRPC extends WorkerEntrypoint<NimbusAssetsEnv, NimbusAss
|
|
|
219
191
|
for (const candidate of candidates) {
|
|
220
192
|
try {
|
|
221
193
|
const response = await useRpcResource(
|
|
222
|
-
|
|
223
|
-
(bytes
|
|
224
|
-
if (
|
|
225
|
-
|
|
194
|
+
dispatch({ op: 'readFileBytes', args: [candidate] }),
|
|
195
|
+
(bytes) => {
|
|
196
|
+
if (bytes === null) return null;
|
|
197
|
+
const body = bytes instanceof ArrayBuffer ? bytes
|
|
198
|
+
: bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer
|
|
199
|
+
? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) : null;
|
|
200
|
+
if (!body) return new Response('Nimbus: readFileBytes returned invalid bytes', { status: 502 });
|
|
201
|
+
return new Response(body, {
|
|
226
202
|
status: 200,
|
|
227
203
|
headers: {
|
|
228
204
|
'Content-Type': mimeTypeForPath(candidate),
|
|
@@ -785,31 +761,6 @@ export class NimbusDurableObjectNamespace extends WorkerEntrypoint<unknown, Nimb
|
|
|
785
761
|
}
|
|
786
762
|
}
|
|
787
763
|
|
|
788
|
-
/**
|
|
789
|
-
* What the DO shim reads off the supervisor: one inner-DO request, answered
|
|
790
|
-
* from the facet the supervisor resolves in its own request context.
|
|
791
|
-
*/
|
|
792
|
-
interface InnerDoSupervisorStub {
|
|
793
|
-
_rpcInnerDoFetch(request: {
|
|
794
|
-
bindingName: string;
|
|
795
|
-
id: string;
|
|
796
|
-
method: string;
|
|
797
|
-
url: string;
|
|
798
|
-
headers: [string, string][];
|
|
799
|
-
body: ArrayBuffer | null;
|
|
800
|
-
}): Promise<{
|
|
801
|
-
body: ArrayBuffer;
|
|
802
|
-
status: number;
|
|
803
|
-
statusText: string;
|
|
804
|
-
headers: [string, string][];
|
|
805
|
-
}>;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/** `env` for the DO shim: the supervisor that owns the facet. */
|
|
809
|
-
interface NimbusInnerDoEnv {
|
|
810
|
-
NIMBUS_SESSION?: SupervisorNamespace<InnerDoSupervisorStub>;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
764
|
/** Props the DO stub carries: which binding, which supervisor, which id. */
|
|
814
765
|
interface NimbusDoStubProps extends NimbusDoNamespaceProps {
|
|
815
766
|
id?: string;
|
|
@@ -823,21 +774,28 @@ interface NimbusDoStubProps extends NimbusDoNamespaceProps {
|
|
|
823
774
|
* spins up / attaches to a facet via the supervisor's ctx.facets in
|
|
824
775
|
* the SAME outer request context — never reusing stubs across requests.
|
|
825
776
|
*/
|
|
826
|
-
export class NimbusDOStub extends WorkerEntrypoint<
|
|
777
|
+
export class NimbusDOStub extends WorkerEntrypoint<object, NimbusDoStubProps> {
|
|
827
778
|
/**
|
|
828
|
-
* Resolve the supervisor DO
|
|
829
|
-
*
|
|
830
|
-
*
|
|
779
|
+
* Resolve the supervisor DO through the composed host namespace and
|
|
780
|
+
* dispatch the innerDoFetch op through its one supervisorOp entrypoint —
|
|
781
|
+
* a host forwards envelopes, not private _rpc* methods.
|
|
831
782
|
*/
|
|
832
783
|
async fetch(request: Request): Promise<Response> {
|
|
833
784
|
const props: NimbusDoStubProps = this.ctx.props || {};
|
|
834
|
-
const ns = this.env?.NIMBUS_SESSION;
|
|
835
|
-
if (!ns) return new Response('Nimbus: env.NIMBUS_SESSION unavailable', { status: 500 });
|
|
836
785
|
const supervisorDoId = String(props.supervisorDoId || '');
|
|
837
786
|
if (!supervisorDoId) return new Response('Nimbus: supervisorDoId missing', { status: 500 });
|
|
838
787
|
const bindingName = String(props.bindingName || '');
|
|
839
788
|
const id = String(props.id || '');
|
|
840
|
-
|
|
789
|
+
let stub: DurableObjectStub | null = null;
|
|
790
|
+
let dispatch: HostOpDispatch;
|
|
791
|
+
try {
|
|
792
|
+
const ns = hostNamespaceBinding(this.env ?? {}, 'NimbusDOStub');
|
|
793
|
+
stub = ns.get(ns.idFromString(supervisorDoId));
|
|
794
|
+
dispatch = hostOpDispatch(stub, 'NimbusDOStub');
|
|
795
|
+
} catch (e) {
|
|
796
|
+
disposeRpcResource(stub);
|
|
797
|
+
return new Response(`Nimbus: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
|
|
798
|
+
}
|
|
841
799
|
// Forward the full request (method, body, headers preserved) by
|
|
842
800
|
// serializing what's needed and reconstructing on the other side.
|
|
843
801
|
// The supervisor reconstitutes the Request from these fields and
|
|
@@ -849,20 +807,27 @@ export class NimbusDOStub extends WorkerEntrypoint<NimbusInnerDoEnv, NimbusDoStu
|
|
|
849
807
|
request.headers.forEach((v, k) => { headerList.push([k, v]); });
|
|
850
808
|
try {
|
|
851
809
|
return await useRpcResource(
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
810
|
+
dispatch({
|
|
811
|
+
op: 'innerDoFetch',
|
|
812
|
+
args: [{
|
|
813
|
+
bindingName,
|
|
814
|
+
id,
|
|
815
|
+
method: request.method,
|
|
816
|
+
url: request.url,
|
|
817
|
+
headers: headerList,
|
|
818
|
+
body,
|
|
819
|
+
}],
|
|
859
820
|
}),
|
|
860
|
-
(res) =>
|
|
861
|
-
|
|
821
|
+
(res) => {
|
|
822
|
+
if (!(res instanceof Response)) {
|
|
823
|
+
return new Response('Nimbus: innerDoFetch returned an invalid result', { status: 502 });
|
|
824
|
+
}
|
|
825
|
+
return new Response(res.body, {
|
|
862
826
|
status: res.status,
|
|
863
827
|
statusText: res.statusText,
|
|
864
828
|
headers: res.headers,
|
|
865
|
-
})
|
|
829
|
+
});
|
|
830
|
+
},
|
|
866
831
|
);
|
|
867
832
|
} finally {
|
|
868
833
|
disposeRpcResource(stub);
|
package/src/fanout.ts
CHANGED
|
@@ -20,20 +20,18 @@ import { IsolatePool, type FacetTaskFn } from './isolate-pool.js';
|
|
|
20
20
|
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
21
21
|
import { describeError, isDoOverloaded, isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
|
|
22
22
|
import type { WorkerLoader } from './vendor/types.js';
|
|
23
|
+
import {
|
|
24
|
+
hostNamespaceBinding,
|
|
25
|
+
hostOpDispatch,
|
|
26
|
+
type HostOpDispatch,
|
|
27
|
+
} from './host-dispatch.js';
|
|
23
28
|
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*/
|
|
28
|
-
interface PeerSessionNamespace {
|
|
29
|
-
idFromName(name: string): DurableObjectId;
|
|
30
|
-
get(id: DurableObjectId): unknown;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** The bindings a fan-out needs off the coordinator DO's env. */
|
|
29
|
+
/** The bindings a fan-out needs off the coordinator DO's env. The host
|
|
30
|
+
* namespace key is the composed one — whatever this host named its own
|
|
31
|
+
* binding (default NIMBUS_SESSION); peers dispatch supervisorOp envelopes. */
|
|
34
32
|
export interface FanoutEnv {
|
|
35
33
|
LOADER?: WorkerLoader;
|
|
36
|
-
NIMBUS_SESSION?:
|
|
34
|
+
NIMBUS_SESSION?: unknown;
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
/**
|
|
@@ -168,30 +166,13 @@ export interface FanoutOptions {
|
|
|
168
166
|
maxPeers?: number;
|
|
169
167
|
}
|
|
170
168
|
|
|
171
|
-
interface
|
|
172
|
-
|
|
173
|
-
fnSource: string,
|
|
174
|
-
args: unknown[],
|
|
175
|
-
poolOpts?: Record<string, unknown>,
|
|
176
|
-
): Promise<{ results?: R[] }>;
|
|
169
|
+
interface FanoutPeerResult<R> {
|
|
170
|
+
results: R[];
|
|
177
171
|
}
|
|
178
172
|
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
const execute = Reflect.get(value, '_rpcFanoutExecute');
|
|
184
|
-
return typeof execute === 'function';
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function fanoutPeerStub(value: unknown): FanoutPeerStub {
|
|
188
|
-
if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
|
|
189
|
-
throw new BindingError('Fanout: NIMBUS_SESSION.get() did not return a peer stub.');
|
|
190
|
-
}
|
|
191
|
-
if (!isFanoutPeerStub(value)) {
|
|
192
|
-
throw new BindingError('Fanout: peer stub does not expose _rpcFanoutExecute().');
|
|
193
|
-
}
|
|
194
|
-
return value;
|
|
173
|
+
// Task values retain the submitted function's type; validate the wire envelope.
|
|
174
|
+
function isPeerResult<R>(value: Awaited<ReturnType<HostOpDispatch>>): value is FanoutPeerResult<R> {
|
|
175
|
+
return value !== null && typeof value === 'object' && 'results' in value && Array.isArray(value.results);
|
|
195
176
|
}
|
|
196
177
|
|
|
197
178
|
/**
|
|
@@ -323,14 +304,10 @@ export class Fanout {
|
|
|
323
304
|
tasks: FanoutTask<A>[],
|
|
324
305
|
fn: FacetTaskFn<A, R>,
|
|
325
306
|
): Promise<R[]> {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
'The peer-DO topology requires it. ' +
|
|
331
|
-
'Add the binding via durable_objects.bindings in wrangler.jsonc.',
|
|
332
|
-
);
|
|
333
|
-
}
|
|
307
|
+
// The peer-DO topology routes through the composed host namespace —
|
|
308
|
+
// a workspace host names its own binding, and its stub forwards one
|
|
309
|
+
// supervisorOp entrypoint, not private _rpc* methods.
|
|
310
|
+
const ns = hostNamespaceBinding(this.env ?? {}, 'Fanout');
|
|
334
311
|
|
|
335
312
|
// Serialize the user function ONCE here on the supervisor side.
|
|
336
313
|
// Each peer DO receives the same fnSource string; warm peer
|
|
@@ -380,38 +357,42 @@ export class Fanout {
|
|
|
380
357
|
// stub points at a torn-down object, so a retry re-resolves the
|
|
381
358
|
// sibling by its stable id.
|
|
382
359
|
const peerStub = ns.get(id);
|
|
383
|
-
const stub = fanoutPeerStub(peerStub);
|
|
384
360
|
try {
|
|
361
|
+
const dispatch = hostOpDispatch(peerStub, `Fanout peer ${siblingName}`);
|
|
385
362
|
// Each peer DO RPC call uses ONE LOADER worker on its side.
|
|
386
363
|
// Supervisor → peer DO is a stub.fetch / RPC method call,
|
|
387
364
|
// NOT an env.LOADER.get(); that's the cap-sidestep that
|
|
388
365
|
// makes peer-DO fanout work.
|
|
389
|
-
const rpcResp = await
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
366
|
+
const rpcResp = await dispatch({
|
|
367
|
+
op: 'fanoutExecute',
|
|
368
|
+
args: [
|
|
369
|
+
fnSource,
|
|
370
|
+
peerArgs,
|
|
371
|
+
{
|
|
372
|
+
tag: this.opts.tag,
|
|
373
|
+
timeoutMs: this.opts.timeoutMs,
|
|
374
|
+
preamble: this.opts.preamble,
|
|
375
|
+
wasmModules: this.opts.wasmModules,
|
|
376
|
+
extraBindings: this.opts.extraBindings,
|
|
377
|
+
omitSupervisor: this.opts.omitSupervisor,
|
|
378
|
+
// INSTALL-HONESTY: forward the COORDINATOR's full doId so
|
|
379
|
+
// the peer's IsolatePool can mint a SUPERVISOR
|
|
380
|
+
// binding that routes back HERE (the user's session DO),
|
|
381
|
+
// not to the peer DO itself. Without this, peer DOs'
|
|
382
|
+
// env.SUPERVISOR.writeBatch / writeBatchStream / stdout /
|
|
383
|
+
// ... write into the peer's own VFS — invisible to the
|
|
384
|
+
// user. See INSTALL-HONESTY-retro.md.
|
|
385
|
+
coordinatorDoId: this.coordDoId,
|
|
386
|
+
// Credential source for peer-side writeBatchStream — the
|
|
387
|
+
// invoking process pid, so package writes are authorized
|
|
388
|
+
// as the user (not rejected as pid:0).
|
|
389
|
+
supervisorPid: this.opts.supervisorPid,
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
});
|
|
413
393
|
try {
|
|
414
|
-
|
|
394
|
+
if (!isPeerResult<R>(rpcResp)) throw new BindingError(`Fanout peer '${siblingName}' returned no result array`);
|
|
395
|
+
const peerResults = rpcResp.results;
|
|
415
396
|
if (peerResults.length !== bucket.length) {
|
|
416
397
|
throw new Error(
|
|
417
398
|
`peer DO returned ${peerResults.length} results for ${bucket.length} tasks ` +
|
package/src/generation.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* counter, and bumps it once. The embedder reads `generation(ctx)` wherever it
|
|
15
15
|
* needs the incarnation number — one source of truth instead of mirrored host
|
|
16
16
|
* fields.
|
|
17
|
+
*
|
|
18
|
+
* This is also the allocator for a workspace's generation: an embedder
|
|
19
|
+
* composing NimbusWorkspace over its own Durable Object calls
|
|
20
|
+
* `adoptGeneration(ctx)` once per incarnation and passes `generation(ctx)`
|
|
21
|
+
* as the workspace's `generation` — the same persisted counter the session
|
|
22
|
+
* derives its pid generation from, so there is no second counter to keep.
|
|
17
23
|
*/
|
|
18
24
|
|
|
19
25
|
import { errorText } from '@nimbus-sh/core/_shared/error-text.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { BindingError } from './vendor/errors.js';
|
|
2
|
+
import { hostDispatchMethod, hostNamespace } from './composition.js';
|
|
3
|
+
import type { createSupervisorOpHandler } from '@nimbus-sh/core/workspace/supervisor-op.js';
|
|
4
|
+
|
|
5
|
+
export type HostNamespaceBinding = Pick<DurableObjectNamespace, 'get' | 'idFromName' | 'idFromString'>;
|
|
6
|
+
export type HostOpDispatch = ReturnType<typeof createSupervisorOpHandler>;
|
|
7
|
+
|
|
8
|
+
function isNamespace(value: object): value is HostNamespaceBinding {
|
|
9
|
+
return typeof Reflect.get(value, 'idFromName') === 'function'
|
|
10
|
+
&& typeof Reflect.get(value, 'idFromString') === 'function'
|
|
11
|
+
&& typeof Reflect.get(value, 'get') === 'function';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hostNamespaceBinding(env: object | null | undefined, usage: string): HostNamespaceBinding {
|
|
15
|
+
const name = hostNamespace();
|
|
16
|
+
const binding = env ? Reflect.get(env, name) : undefined;
|
|
17
|
+
if (binding === null || (typeof binding !== 'object' && typeof binding !== 'function') || !isNamespace(binding)) {
|
|
18
|
+
throw new BindingError(`${usage}: env.${name} must be the Durable Object namespace configured by composeFabric`);
|
|
19
|
+
}
|
|
20
|
+
return binding;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hostOpDispatch(stub: object, usage: string): HostOpDispatch {
|
|
24
|
+
const name = hostDispatchMethod();
|
|
25
|
+
if (stub === null || (typeof stub !== 'object' && typeof stub !== 'function')) {
|
|
26
|
+
throw new BindingError(`${usage}: the workspace namespace returned no stub`);
|
|
27
|
+
}
|
|
28
|
+
const dispatch = Reflect.get(stub, name);
|
|
29
|
+
if (typeof dispatch !== 'function') {
|
|
30
|
+
throw new BindingError(`${usage}: the workspace host must forward ${name}(envelope) to the runtime`);
|
|
31
|
+
}
|
|
32
|
+
// RpcStub.call would invoke a remote method named "call".
|
|
33
|
+
return (envelope) => Promise.resolve(Reflect.apply(dispatch, stub, [envelope]));
|
|
34
|
+
}
|
package/src/image-store.ts
CHANGED
|
@@ -107,36 +107,48 @@ export class ImageStore {
|
|
|
107
107
|
* Materialize generated module sources in the content-addressed image store
|
|
108
108
|
* and return the module-name → path map naming them.
|
|
109
109
|
*
|
|
110
|
+
* Takes the images as a SEQUENCE, produced on demand and released as each
|
|
111
|
+
* one's slices land, so exactly one image's text is resident here. The old
|
|
112
|
+
* record-shaped parameter held every source for the whole call, and the
|
|
113
|
+
* caller held its own copy beside it: measured on a real-vite launch, the
|
|
114
|
+
* second image reported not one slice — ~25 MB of module text as UTF-16 in
|
|
115
|
+
* two places, plus the first image's just-freed 10.36 MB encode buffer, on a
|
|
116
|
+
* 128 MiB isolate. Yielding and dropping is what makes the peak one image
|
|
117
|
+
* instead of all of them; it is not a pacing question, and a fresh turn does
|
|
118
|
+
* not shrink a live heap.
|
|
119
|
+
*
|
|
110
120
|
* Writing the sources here, once, is what lets the session stop holding
|
|
111
121
|
* them: after this returns, the only thing it keeps is a path.
|
|
112
122
|
*/
|
|
113
123
|
async materialize(
|
|
114
124
|
pid: number,
|
|
115
|
-
|
|
125
|
+
images: AsyncIterable<readonly [string, string]> | Iterable<readonly [string, string]>,
|
|
116
126
|
pacer: TurnBudget,
|
|
117
127
|
): Promise<Record<string, string>> {
|
|
118
128
|
const fs = this.blobs();
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
// byte of it exists on disk. That ordering is the entire protocol between
|
|
129
|
-
// a launch and the sweep: an image is rooted from before it is written, so
|
|
130
|
-
// a sweep can never observe a file this launch has written but not yet
|
|
131
|
-
// claimed. The old loop achieved it by not awaiting at all, which read as
|
|
132
|
-
// "the writes must not be interrupted" — they may be. What must not be
|
|
133
|
-
// interrupted is the gap between writing and rooting, and rooting first
|
|
134
|
-
// closes it for every write that follows, however many turns they span.
|
|
135
|
-
this.residentImages.set(pid, [...sources.keys()]);
|
|
129
|
+
const paths: Record<string, string> = {};
|
|
130
|
+
// The root set is this ARRAY, held by the sweep's map from before the
|
|
131
|
+
// first byte and appended to as each image is named. Rooting an image
|
|
132
|
+
// before its own first byte is the whole of the protocol: a sweep that
|
|
133
|
+
// runs while this launch is suspended sees every image already written as
|
|
134
|
+
// rooted, and an image not yet written is not yet a file. The array
|
|
135
|
+
// identity is what makes an append visible — do not replace it.
|
|
136
|
+
const rooted: string[] = [];
|
|
137
|
+
this.residentImages.set(pid, rooted);
|
|
136
138
|
fs.mkdirp(FACET_IMAGE_DIR);
|
|
137
|
-
|
|
139
|
+
let count = 0;
|
|
140
|
+
for await (const [moduleName, source] of images) {
|
|
141
|
+
const path = facetImagePath(await facetImageDigest(source));
|
|
142
|
+
paths[moduleName] = path;
|
|
143
|
+
rooted.push(path);
|
|
144
|
+
count++;
|
|
138
145
|
const stored = path.replace(/^\/+/, '');
|
|
139
146
|
const bytes = new TextEncoder().encode(source);
|
|
147
|
+
console.log(
|
|
148
|
+
'[image-store] pid=' + pid + ' image ' + count + ' ' + moduleName + ' → '
|
|
149
|
+
+ path.slice(-12) + ' ' + bytes.byteLength + ' bytes, slice=' + FACET_IMAGE_WRITE_SLICE_BYTES
|
|
150
|
+
+ ' turns=' + pacer.chunks,
|
|
151
|
+
);
|
|
140
152
|
// An image at its full size is a COMPLETE one: a write only ever grows
|
|
141
153
|
// the file from offset zero, so a write cut short by a reset leaves a
|
|
142
154
|
// strictly shorter file and fails this test. Size is enough of a check
|
|
@@ -163,7 +175,8 @@ export class ImageStore {
|
|
|
163
175
|
} while (offset < bytes.byteLength);
|
|
164
176
|
}
|
|
165
177
|
this.sweep(fs);
|
|
166
|
-
|
|
178
|
+
console.log('[image-store] pid=' + pid + ' materialized ' + count + ' image(s) in ' + pacer.chunks + ' turn(s)');
|
|
179
|
+
return paths;
|
|
167
180
|
}
|
|
168
181
|
|
|
169
182
|
/**
|