logisheets-runtime 1.1.1 → 1.2.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.
@@ -0,0 +1,92 @@
1
+ import type { CraftManifest, CraftRuntime, CraftState, Violation, JsonRpcRequest, JsonRpcResponse } from 'logisheets-core';
2
+ import type { Workbook } from './index.js';
3
+ /**
4
+ * A {@link CraftRuntime} bound to this runtime's {@link Workbook} — the shape a
5
+ * Node craft implements. logisheets-core keeps the workbook type generic; here
6
+ * we pin it to the concrete runtime workbook.
7
+ */
8
+ export type NodeCraftRuntime<S extends CraftState = CraftState> = CraftRuntime<S, Workbook>;
9
+ /**
10
+ * Resolves craft ids to their code. This is the only part that talks to the
11
+ * outside world (an HTTP registry, a filesystem, a test double), so the loader
12
+ * stays environment-agnostic.
13
+ */
14
+ export interface CraftRegistry {
15
+ /**
16
+ * Look up a craft's manifest by its stable id. Returns `undefined` when the
17
+ * registry has no such craft (the loader then skips it).
18
+ */
19
+ getManifest(craftId: string): Promise<CraftManifest | undefined>;
20
+ /**
21
+ * Download and import a craft's runtime module. `manifest.rtJs` is the path
22
+ * of the runtime JS file; implementations fetch it and `import()` the
23
+ * result. Returns the imported module namespace (the loader extracts the
24
+ * {@link CraftRuntime} from it), or `undefined` when the craft ships no
25
+ * runtime (`rtJs` empty) or the import yields nothing.
26
+ */
27
+ importRuntime(craftId: string, manifest: CraftManifest): Promise<unknown | undefined>;
28
+ }
29
+ /** One craft the loader brought up, with its manifest, live runtime, and the
30
+ * parsed state it was loaded with (kept so the request/validate/response hooks
31
+ * can be re-invoked with the same state per RPC exchange). */
32
+ export interface LoadedCraft<S extends CraftState = CraftState> {
33
+ readonly craftId: string;
34
+ readonly manifest: CraftManifest;
35
+ readonly runtime: NodeCraftRuntime<S>;
36
+ readonly state: S;
37
+ }
38
+ /**
39
+ * Read a workbook's AppData and return the `craftStates` map it carries
40
+ * (craftId -> opaque serialized state), or an empty object when there is none.
41
+ *
42
+ * The state string is left opaque exactly as the craft wrote it — parsing is
43
+ * the craft's business (see {@link loadCrafts}, which hands it to `onLoad`).
44
+ */
45
+ export declare function readCraftStates(wb: Workbook): Promise<Record<string, string>>;
46
+ /**
47
+ * Bring up every craft a workbook depends on and hand each its saved state.
48
+ *
49
+ * For each craft id found in the workbook's AppData: resolve its manifest,
50
+ * download + import its runtime, then call `runtime.onLoad(state, wb)`. A craft
51
+ * that has no manifest, ships no runtime, or throws while loading is skipped —
52
+ * one broken craft never blocks the rest. Returns the crafts that loaded.
53
+ */
54
+ export declare function loadCrafts(wb: Workbook, registry: CraftRegistry): Promise<LoadedCraft[]>;
55
+ /**
56
+ * Notify every loaded craft that an RPC request's inputs are about to be
57
+ * applied, and collect any objections. A craft signals "reject this request"
58
+ * by returning an ErrorMessage (or throwing) from `onRequest` — e.g. the
59
+ * data-gateway craft rejects a request that names a block it isn't allowed to
60
+ * write. Returns the objection messages (empty when every craft is fine); a
61
+ * host that gets a non-empty result should reject the request before applying
62
+ * it.
63
+ */
64
+ export declare function applyCraftRequest(loaded: readonly LoadedCraft[], req: JsonRpcRequest, wb: Workbook): Promise<string[]>;
65
+ /**
66
+ * The validation checkpoint: run every loaded craft's `onValidate` now that
67
+ * the request's inputs are in place, and return the union of the cells that
68
+ * fail their rules. An empty result means every craft is satisfied and the
69
+ * host may proceed to read the response; a non-empty result means the host
70
+ * should reject the request and roll the inputs back. Crafts with no
71
+ * `onValidate` contribute nothing; a craft that errors is treated as
72
+ * contributing no violations (its objection, if any, surfaced in onRequest).
73
+ */
74
+ export declare function validateLoadedCrafts(loaded: readonly LoadedCraft[], wb: Workbook): Promise<Violation[]>;
75
+ /**
76
+ * Hand every loaded craft the RPC response about to be returned (e.g. so a
77
+ * craft can read its output blocks back). Returns any objection messages, same
78
+ * contract as {@link applyCraftRequest}.
79
+ */
80
+ export declare function applyCraftResponse(loaded: readonly LoadedCraft[], resp: JsonRpcResponse, wb: Workbook): Promise<string[]>;
81
+ /**
82
+ * A default {@link CraftRegistry} for local dev and tests. Crafts are
83
+ * registered in-process against a fake base url; `importRuntime` just returns
84
+ * the pre-registered module so no network or bundler is involved.
85
+ */
86
+ export declare class MockCraftRegistry implements CraftRegistry {
87
+ private readonly entries;
88
+ /** Register a craft's manifest and its already-imported runtime module. */
89
+ add(craftId: string, manifest: CraftManifest, module: unknown): this;
90
+ getManifest(craftId: string): Promise<CraftManifest | undefined>;
91
+ importRuntime(craftId: string): Promise<unknown | undefined>;
92
+ }
package/dist/craft.js ADDED
@@ -0,0 +1,219 @@
1
+ // Craft loading for the headless runtime.
2
+ //
3
+ // A workbook can carry per-craft state in its AppData side channel (see
4
+ // logisheets-core's craft/state.ts). When such state is present it signals that
5
+ // the workbook depends on one or more crafts. This module reconstructs those
6
+ // crafts on the runtime side so their logic runs headlessly:
7
+ //
8
+ // 1. read the workbook's AppData and pull out the `craftStates` map
9
+ // (craftId -> opaque serialized state) folded in by the host on save,
10
+ // 2. for every craft id found, ask the {@link CraftRegistry} for its
11
+ // {@link CraftManifest},
12
+ // 3. if the manifest names a runtime JS file, download + import it — the
13
+ // imported module *is* the craft's {@link CraftRuntime},
14
+ // 4. call the runtime's `onLoad(state, workbook)` so it can rehydrate.
15
+ //
16
+ // The registry is a seam: production wires it to a real HTTP registry, tests
17
+ // (and local dev) use {@link MockCraftRegistry}.
18
+ /**
19
+ * The name of the AppData entry the host folds craft/block/interaction state
20
+ * into. Mirrors the envelope written by the browser host on save.
21
+ */
22
+ const APP_DATA_ENVELOPE_NAME = 'logisheets';
23
+ /**
24
+ * Read a workbook's AppData and return the `craftStates` map it carries
25
+ * (craftId -> opaque serialized state), or an empty object when there is none.
26
+ *
27
+ * The state string is left opaque exactly as the craft wrote it — parsing is
28
+ * the craft's business (see {@link loadCrafts}, which hands it to `onLoad`).
29
+ */
30
+ export async function readCraftStates(wb) {
31
+ const appData = await wb.client.getAppData();
32
+ if (!Array.isArray(appData))
33
+ return {};
34
+ const entry = appData.find((d) => !!d &&
35
+ typeof d.name === 'string' &&
36
+ d.name === APP_DATA_ENVELOPE_NAME);
37
+ if (!entry)
38
+ return {};
39
+ let envelope;
40
+ try {
41
+ envelope = JSON.parse(entry.data);
42
+ }
43
+ catch {
44
+ // Legacy (raw BlockManager) payload — no craft states.
45
+ return {};
46
+ }
47
+ const raw = envelope?.craftStates;
48
+ if (!raw || typeof raw !== 'object')
49
+ return {};
50
+ const out = {};
51
+ for (const [craftId, state] of Object.entries(raw)) {
52
+ if (typeof state === 'string')
53
+ out[craftId] = state;
54
+ }
55
+ return out;
56
+ }
57
+ /**
58
+ * Bring up every craft a workbook depends on and hand each its saved state.
59
+ *
60
+ * For each craft id found in the workbook's AppData: resolve its manifest,
61
+ * download + import its runtime, then call `runtime.onLoad(state, wb)`. A craft
62
+ * that has no manifest, ships no runtime, or throws while loading is skipped —
63
+ * one broken craft never blocks the rest. Returns the crafts that loaded.
64
+ */
65
+ export async function loadCrafts(wb, registry) {
66
+ const states = await readCraftStates(wb);
67
+ const craftIds = Object.keys(states);
68
+ if (craftIds.length === 0)
69
+ return [];
70
+ const loaded = [];
71
+ for (const craftId of craftIds) {
72
+ // Deliberately sequential: crafts may touch shared workbook state in
73
+ // onLoad, so we don't race them. Revisit if that proves too slow.
74
+ // eslint-disable-next-line no-await-in-loop
75
+ const craft = await loadOneCraft(wb, registry, craftId, states[craftId]);
76
+ if (craft)
77
+ loaded.push(craft);
78
+ }
79
+ return loaded;
80
+ }
81
+ async function loadOneCraft(wb, registry, craftId, stateJson) {
82
+ const manifest = await registry.getManifest(craftId);
83
+ if (!manifest)
84
+ return undefined;
85
+ // No runtime file -> nothing to run for this craft (it may be UI-only).
86
+ if (!manifest.rtJs)
87
+ return undefined;
88
+ const mod = await registry.importRuntime(craftId, manifest);
89
+ const runtime = asCraftRuntime(mod);
90
+ if (!runtime)
91
+ return undefined;
92
+ const state = parseState(stateJson);
93
+ if (!state)
94
+ return undefined;
95
+ // onLoad may run engine operations (async on every host), so await it.
96
+ const result = await runtime.onLoad(state, wb);
97
+ if (isErrorMessage(result))
98
+ return undefined;
99
+ return { craftId, manifest, runtime, state };
100
+ }
101
+ /**
102
+ * Notify every loaded craft that an RPC request's inputs are about to be
103
+ * applied, and collect any objections. A craft signals "reject this request"
104
+ * by returning an ErrorMessage (or throwing) from `onRequest` — e.g. the
105
+ * data-gateway craft rejects a request that names a block it isn't allowed to
106
+ * write. Returns the objection messages (empty when every craft is fine); a
107
+ * host that gets a non-empty result should reject the request before applying
108
+ * it.
109
+ */
110
+ export async function applyCraftRequest(loaded, req, wb) {
111
+ const errors = [];
112
+ for (const craft of loaded) {
113
+ try {
114
+ // eslint-disable-next-line no-await-in-loop
115
+ const res = await craft.runtime.onRequest(req, craft.state, wb);
116
+ if (isErrorMessage(res))
117
+ errors.push(res.msg);
118
+ }
119
+ catch (e) {
120
+ errors.push(e instanceof Error ? e.message : String(e));
121
+ }
122
+ }
123
+ return errors;
124
+ }
125
+ /**
126
+ * The validation checkpoint: run every loaded craft's `onValidate` now that
127
+ * the request's inputs are in place, and return the union of the cells that
128
+ * fail their rules. An empty result means every craft is satisfied and the
129
+ * host may proceed to read the response; a non-empty result means the host
130
+ * should reject the request and roll the inputs back. Crafts with no
131
+ * `onValidate` contribute nothing; a craft that errors is treated as
132
+ * contributing no violations (its objection, if any, surfaced in onRequest).
133
+ */
134
+ export async function validateLoadedCrafts(loaded, wb) {
135
+ const violations = [];
136
+ for (const craft of loaded) {
137
+ if (!craft.runtime.onValidate)
138
+ continue;
139
+ // eslint-disable-next-line no-await-in-loop
140
+ const res = await craft.runtime.onValidate(craft.state, wb);
141
+ if (isErrorMessage(res))
142
+ continue;
143
+ violations.push(...res);
144
+ }
145
+ return violations;
146
+ }
147
+ /**
148
+ * Hand every loaded craft the RPC response about to be returned (e.g. so a
149
+ * craft can read its output blocks back). Returns any objection messages, same
150
+ * contract as {@link applyCraftRequest}.
151
+ */
152
+ export async function applyCraftResponse(loaded, resp, wb) {
153
+ const errors = [];
154
+ for (const craft of loaded) {
155
+ try {
156
+ // eslint-disable-next-line no-await-in-loop
157
+ const res = await craft.runtime.onResponse(resp, craft.state, wb);
158
+ if (isErrorMessage(res))
159
+ errors.push(res.msg);
160
+ }
161
+ catch (e) {
162
+ errors.push(e instanceof Error ? e.message : String(e));
163
+ }
164
+ }
165
+ return errors;
166
+ }
167
+ /**
168
+ * A default {@link CraftRegistry} for local dev and tests. Crafts are
169
+ * registered in-process against a fake base url; `importRuntime` just returns
170
+ * the pre-registered module so no network or bundler is involved.
171
+ */
172
+ export class MockCraftRegistry {
173
+ constructor() {
174
+ this.entries = new Map();
175
+ }
176
+ /** Register a craft's manifest and its already-imported runtime module. */
177
+ add(craftId, manifest, module) {
178
+ this.entries.set(craftId, { manifest, module });
179
+ return this;
180
+ }
181
+ getManifest(craftId) {
182
+ return Promise.resolve(this.entries.get(craftId)?.manifest);
183
+ }
184
+ importRuntime(craftId) {
185
+ return Promise.resolve(this.entries.get(craftId)?.module);
186
+ }
187
+ }
188
+ // The runtime may be the module's default export or the module itself.
189
+ function asCraftRuntime(mod) {
190
+ const candidate = mod && typeof mod === 'object' && 'default' in mod
191
+ ? mod.default
192
+ : mod;
193
+ if (candidate &&
194
+ typeof candidate.onLoad === 'function') {
195
+ return candidate;
196
+ }
197
+ return undefined;
198
+ }
199
+ // Craft state is stored as an opaque string but is always a serialized JSON
200
+ // object. Parse it back; return undefined if it isn't a JSON object (a
201
+ // malformed entry we skip rather than hand a craft a bad shape).
202
+ function parseState(stateJson) {
203
+ let parsed;
204
+ try {
205
+ parsed = JSON.parse(stateJson);
206
+ }
207
+ catch {
208
+ return undefined;
209
+ }
210
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
211
+ return undefined;
212
+ }
213
+ return parsed;
214
+ }
215
+ function isErrorMessage(v) {
216
+ return (typeof v === 'object' &&
217
+ v !== null &&
218
+ 'msg' in v);
219
+ }
@@ -0,0 +1,116 @@
1
+ import type { AddressInfo } from 'node:net';
2
+ import { SpreadsheetRuntime, type Workbook } from './index.js';
3
+ import { type CraftRegistry, type LoadedCraft } from './craft.js';
4
+ import { WorkbookWatcher } from './watcher.js';
5
+ import type { CraftManifest } from 'logisheets-core';
6
+ export interface ControlPlaneOptions {
7
+ /** Base URL of the enterprise control panel, e.g. http://cp.internal:3000 */
8
+ controlPlaneUrl: string;
9
+ /** Shared secret presented as Bearer to CP-facing endpoints. Optional (dev). */
10
+ secret?: string;
11
+ }
12
+ export interface RegisterResult {
13
+ runtimeId: string;
14
+ registryUrl: string | null;
15
+ registryToken: string | null;
16
+ }
17
+ export interface AccessEvent {
18
+ wbStringId?: string;
19
+ version?: string;
20
+ caller?: string;
21
+ method?: string;
22
+ }
23
+ export declare class ControlPlaneClient {
24
+ private readonly opts;
25
+ constructor(opts: ControlPlaneOptions);
26
+ private headers;
27
+ private url;
28
+ register(input: {
29
+ address: string;
30
+ name?: string;
31
+ mode?: 'serving' | 'ephemeral' | 'both';
32
+ capacity?: number;
33
+ id?: string;
34
+ }): Promise<RegisterResult>;
35
+ heartbeat(runtimeId: string): Promise<void>;
36
+ ingest(runtimeId: string, accessEvents: AccessEvent[]): Promise<void>;
37
+ }
38
+ export declare class HttpCraftRegistry implements CraftRegistry {
39
+ private readonly registryUrl;
40
+ private readonly token;
41
+ constructor(registryUrl: string, token: string | null);
42
+ private headers;
43
+ getManifest(craftId: string): Promise<CraftManifest | undefined>;
44
+ importRuntime(_craftId: string, manifest: CraftManifest): Promise<unknown | undefined>;
45
+ }
46
+ /** Context handed to a task handler: the loaded workbook + its crafts. */
47
+ export interface TaskContext {
48
+ readonly workbook: Workbook;
49
+ readonly crafts: readonly LoadedCraft[];
50
+ readonly params: unknown;
51
+ }
52
+ /** A task handler runs one `rpcCall` against an ephemerally-loaded workbook. */
53
+ export type TaskHandler = (ctx: TaskContext) => unknown | Promise<unknown>;
54
+ export interface EnterpriseServerOptions {
55
+ runtime: SpreadsheetRuntime;
56
+ /** Registry to load crafts from. Set after registration via {@link setRegistry}. */
57
+ registry?: CraftRegistry;
58
+ /** Shared secret the control panel presents (Bearer). Open when unset. */
59
+ secret?: string;
60
+ /** rpcCall → handler (e.g. extractIndicators, compute). */
61
+ taskHandlers?: Record<string, TaskHandler>;
62
+ }
63
+ export declare class EnterpriseRuntimeServer {
64
+ private readonly runtime;
65
+ private registry?;
66
+ private readonly secret?;
67
+ private readonly handlers;
68
+ private readonly pins;
69
+ private server?;
70
+ constructor(opts: EnterpriseServerOptions);
71
+ setRegistry(registry: CraftRegistry): void;
72
+ registerTask(name: string, handler: TaskHandler): this;
73
+ listen(port: number, host?: string): Promise<AddressInfo>;
74
+ close(): Promise<void>;
75
+ private authOk;
76
+ private loadFromUrl;
77
+ private onRequest;
78
+ private pin;
79
+ private unpin;
80
+ private task;
81
+ }
82
+ export interface EnterpriseRuntimeOptions extends ControlPlaneOptions {
83
+ /** Externally-reachable base URL of THIS runtime (control panel dials it). */
84
+ address: string;
85
+ /** Port to listen on. Default 0 (OS-assigned; then set `address` accordingly). */
86
+ port?: number;
87
+ host?: string;
88
+ name?: string;
89
+ mode?: 'serving' | 'ephemeral' | 'both';
90
+ heartbeatMs?: number;
91
+ taskHandlers?: Record<string, TaskHandler>;
92
+ /**
93
+ * Optional local-directory watcher (pull-based `wb_*.json` loading). **Off by
94
+ * default** — the enterprise model is control-plane-driven (the panel pushes
95
+ * via `/pin`), so this is only for standalone / hybrid setups that also want
96
+ * the file-drop convention. Enable by giving it a directory to watch.
97
+ */
98
+ watch?: {
99
+ dir: string;
100
+ intervalMs?: number;
101
+ };
102
+ }
103
+ export interface EnterpriseRuntimeHandle {
104
+ runtimeId: string;
105
+ address: AddressInfo;
106
+ server: EnterpriseRuntimeServer;
107
+ /** The local-dir watcher, if `watch` was enabled (§ opts.watch). */
108
+ watcher?: WorkbookWatcher;
109
+ stop: () => Promise<void>;
110
+ }
111
+ /**
112
+ * Bring up an enterprise runtime: start the RPC server, register with the
113
+ * control panel (getting registry creds), wire the craft registry, and begin
114
+ * heartbeating. The control panel then dials this runtime for pin/task.
115
+ */
116
+ export declare function startEnterpriseRuntime(opts: EnterpriseRuntimeOptions): Promise<EnterpriseRuntimeHandle>;
@@ -0,0 +1,292 @@
1
+ // Enterprise integration for the headless runtime (logisheets-enterprise
2
+ // RUNTIME_CHANGES.md). One runtime process that:
3
+ //
4
+ // 1. registers with the control panel on startup and gets back registry
5
+ // creds + its runtimeId (§2.2),
6
+ // 2. exposes its own RPC the control panel dials (§2.3):
7
+ // POST /pin {wbUrl} → load + keep resident (serving)
8
+ // POST /unpin {wbId} → release
9
+ // POST /task {workbookUrl, rpcCall, params} → one-shot (extract/what-if)
10
+ // GET /status → pins + load
11
+ // 3. pulls crafts from the enterprise registry using those creds (§2.5).
12
+ //
13
+ // Transport + lifecycle live here; the actual business RPCs (extractIndicators,
14
+ // what-if compute) are registered as task handlers by the consumer, since they
15
+ // depend on the merged data-gateway craft + engine cell ops (DATA_GATEWAY_CHANGES).
16
+ import { createServer, } from 'node:http';
17
+ import { SpreadsheetRuntime } from './index.js';
18
+ import { loadCrafts, } from './craft.js';
19
+ import { WorkbookWatcher } from './watcher.js';
20
+ export class ControlPlaneClient {
21
+ constructor(opts) {
22
+ this.opts = opts;
23
+ }
24
+ headers() {
25
+ return {
26
+ 'content-type': 'application/json',
27
+ ...(this.opts.secret
28
+ ? { authorization: `Bearer ${this.opts.secret}` }
29
+ : {}),
30
+ };
31
+ }
32
+ url(path) {
33
+ return `${this.opts.controlPlaneUrl.replace(/\/$/, '')}${path}`;
34
+ }
35
+ async register(input) {
36
+ const res = await fetch(this.url('/api/runtimes/register'), {
37
+ method: 'POST',
38
+ headers: this.headers(),
39
+ body: JSON.stringify(input),
40
+ });
41
+ if (!res.ok)
42
+ throw new Error(`register failed: ${res.status}`);
43
+ return (await res.json());
44
+ }
45
+ async heartbeat(runtimeId) {
46
+ await fetch(this.url(`/api/runtimes/${runtimeId}/heartbeat`), {
47
+ method: 'POST',
48
+ headers: this.headers(),
49
+ }).catch(() => { });
50
+ }
51
+ async ingest(runtimeId, accessEvents) {
52
+ if (accessEvents.length === 0)
53
+ return;
54
+ await fetch(this.url('/api/ingest'), {
55
+ method: 'POST',
56
+ headers: this.headers(),
57
+ body: JSON.stringify({ runtimeId, accessEvents }),
58
+ }).catch(() => { });
59
+ }
60
+ }
61
+ // ── Enterprise craft registry (§2.5) ─────────────────────────────────────────
62
+ // Pulls craft manifests + runtime modules from the enterprise registry using
63
+ // the creds handed back at registration. Best-effort import via a data: URL
64
+ // (works for self-contained ESM bundles; host-SDK externals must be provided by
65
+ // the runtime's own module resolution). TODO: tarball unpack + external mapping
66
+ // to match craft-registry's bundle format.
67
+ export class HttpCraftRegistry {
68
+ constructor(registryUrl, token) {
69
+ this.registryUrl = registryUrl;
70
+ this.token = token;
71
+ }
72
+ headers() {
73
+ return this.token ? { 'x-api-key': this.token } : {};
74
+ }
75
+ async getManifest(craftId) {
76
+ const res = await fetch(`${this.registryUrl.replace(/\/$/, '')}/api/craft/${encodeURIComponent(craftId)}`, { headers: this.headers() }).catch(() => undefined);
77
+ if (!res || !res.ok)
78
+ return undefined;
79
+ const body = (await res.json());
80
+ if (body.manifest)
81
+ return body.manifest;
82
+ // Map the registry's `logisheets` metadata to the manifest shape.
83
+ const rtJs = body.logisheets?.runtime;
84
+ if (!rtJs)
85
+ return undefined;
86
+ return { rtJs, html: body.logisheets?.html ?? '' };
87
+ }
88
+ async importRuntime(_craftId, manifest) {
89
+ if (!manifest.rtJs)
90
+ return undefined;
91
+ // manifest.rtJs is expected to be a fetchable URL to the runtime bundle.
92
+ const res = await fetch(manifest.rtJs, { headers: this.headers() }).catch(() => undefined);
93
+ if (!res || !res.ok)
94
+ return undefined;
95
+ const code = await res.text();
96
+ const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`;
97
+ try {
98
+ return await import(dataUrl);
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
104
+ }
105
+ export class EnterpriseRuntimeServer {
106
+ constructor(opts) {
107
+ this.handlers = new Map();
108
+ // Resident (pinned) workbooks, keyed by the engine's numeric id as a string.
109
+ this.pins = new Map();
110
+ this.runtime = opts.runtime;
111
+ this.registry = opts.registry;
112
+ this.secret = opts.secret;
113
+ for (const [name, h] of Object.entries(opts.taskHandlers ?? {}))
114
+ this.handlers.set(name, h);
115
+ }
116
+ setRegistry(registry) {
117
+ this.registry = registry;
118
+ }
119
+ registerTask(name, handler) {
120
+ this.handlers.set(name, handler);
121
+ return this;
122
+ }
123
+ listen(port, host = '0.0.0.0') {
124
+ if (this.server)
125
+ throw new Error('server already listening');
126
+ const server = createServer((req, res) => void this.onRequest(req, res));
127
+ this.server = server;
128
+ return new Promise((resolve, reject) => {
129
+ server.once('error', reject);
130
+ server.listen(port, host, () => {
131
+ server.removeListener('error', reject);
132
+ resolve(server.address());
133
+ });
134
+ });
135
+ }
136
+ close() {
137
+ const server = this.server;
138
+ if (!server)
139
+ return Promise.resolve();
140
+ this.server = undefined;
141
+ return new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
142
+ }
143
+ authOk(req) {
144
+ if (!this.secret)
145
+ return true;
146
+ return req.headers.authorization === `Bearer ${this.secret}`;
147
+ }
148
+ async loadFromUrl(url) {
149
+ const res = await fetch(url);
150
+ if (!res.ok)
151
+ throw new Error(`fetch workbook failed: ${res.status}`);
152
+ const bytes = new Uint8Array(await res.arrayBuffer());
153
+ const wb = this.runtime.loadWorkbookFromBytes(bytes, nameFromUrl(url));
154
+ if (this.registry)
155
+ await loadCrafts(wb, this.registry);
156
+ return wb;
157
+ }
158
+ async onRequest(req, res) {
159
+ try {
160
+ const url = req.url ?? '/';
161
+ if (req.method === 'GET' && url === '/status') {
162
+ if (!this.authOk(req))
163
+ return json(res, 403, { error: 'forbidden' });
164
+ return json(res, 200, {
165
+ pins: [...this.pins.keys()],
166
+ open: this.runtime.workbooks.length,
167
+ });
168
+ }
169
+ if (req.method !== 'POST')
170
+ return void res.writeHead(405).end();
171
+ if (!this.authOk(req))
172
+ return json(res, 403, { error: 'forbidden' });
173
+ const body = JSON.parse((await readBody(req)) || '{}');
174
+ if (url === '/pin')
175
+ return json(res, 200, await this.pin(body));
176
+ if (url === '/unpin')
177
+ return json(res, 200, this.unpin(body));
178
+ if (url === '/task')
179
+ return json(res, 200, await this.task(body));
180
+ return json(res, 404, { error: 'not found' });
181
+ }
182
+ catch (e) {
183
+ json(res, 500, { error: e instanceof Error ? e.message : String(e) });
184
+ }
185
+ }
186
+ // Load a workbook and keep it resident (= serving it).
187
+ async pin(body) {
188
+ if (!body.wbUrl)
189
+ throw new Error('wbUrl required');
190
+ const wb = await this.loadFromUrl(body.wbUrl);
191
+ const wbId = String(wb.id);
192
+ this.pins.set(wbId, wb);
193
+ return { wbId };
194
+ }
195
+ unpin(body) {
196
+ if (!body.wbId)
197
+ throw new Error('wbId required');
198
+ const wb = this.pins.get(body.wbId);
199
+ if (wb) {
200
+ this.runtime.close(wb);
201
+ this.pins.delete(body.wbId);
202
+ }
203
+ return { ok: true };
204
+ }
205
+ // One-shot: load → run handler → release (ephemeral, workbookless).
206
+ async task(body) {
207
+ if (!body.workbookUrl || !body.rpcCall)
208
+ throw new Error('workbookUrl and rpcCall required');
209
+ const handler = this.handlers.get(body.rpcCall);
210
+ if (!handler)
211
+ throw new Error(`unknown rpcCall: ${body.rpcCall}`);
212
+ const wb = await this.loadFromUrl(body.workbookUrl);
213
+ try {
214
+ const crafts = this.registry ? await loadCrafts(wb, this.registry) : [];
215
+ return await handler({ workbook: wb, crafts, params: body.params });
216
+ }
217
+ finally {
218
+ this.runtime.close(wb);
219
+ }
220
+ }
221
+ }
222
+ /**
223
+ * Bring up an enterprise runtime: start the RPC server, register with the
224
+ * control panel (getting registry creds), wire the craft registry, and begin
225
+ * heartbeating. The control panel then dials this runtime for pin/task.
226
+ */
227
+ export async function startEnterpriseRuntime(opts) {
228
+ const runtime = new SpreadsheetRuntime();
229
+ const server = new EnterpriseRuntimeServer({
230
+ runtime,
231
+ secret: opts.secret,
232
+ taskHandlers: opts.taskHandlers,
233
+ });
234
+ const address = await server.listen(opts.port ?? 0, opts.host);
235
+ const client = new ControlPlaneClient(opts);
236
+ const reg = await client.register({
237
+ address: opts.address,
238
+ name: opts.name,
239
+ mode: opts.mode ?? 'both',
240
+ });
241
+ if (reg.registryUrl) {
242
+ server.setRegistry(new HttpCraftRegistry(reg.registryUrl, reg.registryToken));
243
+ }
244
+ const interval = setInterval(() => void client.heartbeat(reg.runtimeId), opts.heartbeatMs ?? 30000);
245
+ interval.unref?.();
246
+ // Opt-in local-dir watcher (off by default; enterprise is pin-driven).
247
+ let watcher;
248
+ if (opts.watch) {
249
+ watcher = new WorkbookWatcher(runtime, opts.watch.dir, {
250
+ intervalMs: opts.watch.intervalMs,
251
+ });
252
+ watcher.start();
253
+ }
254
+ return {
255
+ runtimeId: reg.runtimeId,
256
+ address,
257
+ server,
258
+ watcher,
259
+ stop: async () => {
260
+ clearInterval(interval);
261
+ watcher?.stop();
262
+ await server.close();
263
+ runtime.closeAll();
264
+ },
265
+ };
266
+ }
267
+ function nameFromUrl(url) {
268
+ try {
269
+ const last = new URL(url).pathname.split('/').filter(Boolean).pop();
270
+ if (last)
271
+ return decodeURIComponent(last);
272
+ }
273
+ catch {
274
+ /* not a URL — fall through */
275
+ }
276
+ return 'workbook.xlsx';
277
+ }
278
+ function readBody(req) {
279
+ return new Promise((resolve, reject) => {
280
+ const chunks = [];
281
+ req.on('data', (c) => chunks.push(c));
282
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
283
+ req.on('error', reject);
284
+ });
285
+ }
286
+ function json(res, status, payload) {
287
+ const body = JSON.stringify(payload);
288
+ res.writeHead(status, {
289
+ 'content-type': 'application/json',
290
+ 'content-length': Buffer.byteLength(body),
291
+ }).end(body);
292
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,9 @@ import type { Value, Client } from 'logisheets-web';
2
2
  import { WorkbookOps } from 'logisheets-core';
3
3
  export * from 'logisheets-core';
4
4
  export * from './rpc.js';
5
+ export * from './craft.js';
6
+ export * from './watcher.js';
7
+ export * from './enterprise.js';
5
8
  /**
6
9
  * A single live workbook in the Node WASM engine, with logisheets-core's logic
7
10
  * available as methods. You don't construct one directly — obtain it from
package/dist/index.js CHANGED
@@ -18,6 +18,13 @@ import { WorkbookOps } from 'logisheets-core';
18
18
  export * from 'logisheets-core';
19
19
  // The developer-defined JSON-RPC server (operations run against this runtime).
20
20
  export * from './rpc.js';
21
+ // Craft loading: reconstruct the crafts a workbook depends on, headlessly.
22
+ export * from './craft.js';
23
+ // Directory watcher: hot-(re)load workbooks from `wb_*.json` descriptors.
24
+ export * from './watcher.js';
25
+ // Enterprise integration: register with the control panel, expose pin/unpin/task
26
+ // RPC, pull crafts from the enterprise registry (logisheets-enterprise).
27
+ export * from './enterprise.js';
21
28
  /**
22
29
  * Adapt the synchronous Node `handle()` entry point into the async {@link
23
30
  * Client} that logisheets-core's operation layer expects.
package/dist/rpc.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type IncomingMessage } from 'node:http';
2
2
  import type { AddressInfo } from 'node:net';
3
3
  import type { SpreadsheetRuntime, Workbook } from './index.js';
4
+ export type { JsonRpcRequest, JsonRpcError, JsonRpcResponse } from 'logisheets-core';
4
5
  export declare const RPC_PARSE_ERROR = -32700;
5
6
  export declare const RPC_INVALID_REQUEST = -32600;
6
7
  export declare const RPC_METHOD_NOT_FOUND = -32601;
@@ -0,0 +1,94 @@
1
+ import type { SpreadsheetRuntime, Workbook } from './index.js';
2
+ /** The descriptor persisted in each `wb_*.json` file. */
3
+ export interface WorkbookDescriptor {
4
+ /** Stable external string id for this workbook (the watcher's key). */
5
+ id: string;
6
+ /**
7
+ * Revision marker. Any change from the last-loaded value triggers a reload;
8
+ * accepted as a number or string and compared as its string form.
9
+ */
10
+ version: number | string;
11
+ /**
12
+ * Path to the .xlsx file. Absolute, or resolved relative to the watched
13
+ * directory. Optional when {@link url} is given; if both are present,
14
+ * `path` wins (local reads are cheaper).
15
+ */
16
+ path?: string;
17
+ /**
18
+ * URL to fetch the .xlsx from (via `fetch`). Used when {@link path} is
19
+ * absent. At least one of `path`/`url` must be present.
20
+ */
21
+ url?: string;
22
+ }
23
+ /** Options for {@link WorkbookWatcher}. */
24
+ export interface WorkbookWatcherOptions {
25
+ /** Poll interval in milliseconds. Default `10_000` (10s). */
26
+ intervalMs?: number;
27
+ /**
28
+ * Called after a workbook is (re)loaded and swapped in, with the string id,
29
+ * the new live handle, and the descriptor that triggered the load.
30
+ */
31
+ onLoad?: (id: string, wb: Workbook, descriptor: WorkbookDescriptor) => void;
32
+ /**
33
+ * Called when a descriptor file can't be read, parsed, or loaded. The
34
+ * previously-loaded workbook (if any) is left in place. `file` is the
35
+ * descriptor's absolute path.
36
+ */
37
+ onError?: (file: string, err: unknown) => void;
38
+ }
39
+ /**
40
+ * Polls a directory for `wb_*.json` descriptors and keeps the runtime's
41
+ * workbooks in sync with them — loading new ones and replacing changed ones.
42
+ *
43
+ * ```ts
44
+ * const rt = new SpreadsheetRuntime()
45
+ * const watcher = new WorkbookWatcher(rt, './watch', {
46
+ * intervalMs: 10_000,
47
+ * onLoad: (id) => console.log('loaded', id),
48
+ * })
49
+ * watcher.start()
50
+ * // ... later ...
51
+ * const wb = watcher.get('sales-2026')
52
+ * watcher.stop()
53
+ * ```
54
+ */
55
+ export declare class WorkbookWatcher {
56
+ private readonly runtime;
57
+ private readonly options;
58
+ private readonly dir;
59
+ private readonly intervalMs;
60
+ private readonly loaded;
61
+ private timer?;
62
+ /** Guards against a slow scan overlapping the next interval tick. */
63
+ private scanning;
64
+ constructor(runtime: SpreadsheetRuntime, dir: string, options?: WorkbookWatcherOptions);
65
+ /**
66
+ * Begin polling. Runs one scan immediately, then every `intervalMs`. The
67
+ * interval is `unref`'d so it never keeps the process alive on its own.
68
+ * Calling {@link start} while already running is a no-op.
69
+ */
70
+ start(): void;
71
+ /** Stop polling. Loaded workbooks stay open — {@link close} them if needed. */
72
+ stop(): void;
73
+ /** The live workbook currently loaded under `id`, if any. */
74
+ get(id: string): Workbook | undefined;
75
+ /** The string ids of every workbook currently loaded by the watcher. */
76
+ get ids(): readonly string[];
77
+ /**
78
+ * Run a single scan pass now: read every `wb_*.json` descriptor and reload
79
+ * any whose version changed. Exposed for tests and manual triggering; the
80
+ * interval calls it for you. Overlapping calls are skipped (a scan already
81
+ * in flight wins), so this never runs two passes concurrently.
82
+ */
83
+ scanOnce(): Promise<void>;
84
+ private scan;
85
+ /** Load-or-replace the workbook described by a single descriptor file. */
86
+ private reconcile;
87
+ /**
88
+ * Fetch a descriptor's .xlsx bytes — from its local {@link
89
+ * WorkbookDescriptor.path} if present, otherwise its {@link
90
+ * WorkbookDescriptor.url} — and load a fresh workbook. Only the local-path
91
+ * source records a `path` on the handle (a URL isn't a filesystem path).
92
+ */
93
+ private load;
94
+ }
@@ -0,0 +1,198 @@
1
+ // Directory watcher: hot-(re)load workbooks from descriptor files.
2
+ //
3
+ // A host process drops small JSON descriptor files named `wb_*.json` into a
4
+ // watched directory, each naming a workbook by a stable *string* id, a version,
5
+ // and where to fetch its .xlsx — a local `path`, a `url`, or both:
6
+ //
7
+ // { "id": "sales-2026", "version": 7, "path": "books/sales.xlsx" }
8
+ // { "id": "sales-2026", "version": 7, "url": "https://host/books/sales.xlsx" }
9
+ //
10
+ // On a fixed interval (10s by default, configurable) the {@link WorkbookWatcher}
11
+ // scans the directory, and for every descriptor whose version differs from what
12
+ // it last loaded it (re)loads the workbook and swaps it in under that string id.
13
+ // This lets an external system publish new workbook revisions — bumping the
14
+ // version each time — and have the running runtime pick them up automatically.
15
+ //
16
+ // The string id namespace is the watcher's own, distinct from the engine's
17
+ // numeric {@link Workbook.id}; use {@link WorkbookWatcher.get} to resolve a
18
+ // string id to its currently-loaded live {@link Workbook}.
19
+ import { readFile, readdir } from 'node:fs/promises';
20
+ import { basename, isAbsolute, resolve } from 'node:path';
21
+ /**
22
+ * Polls a directory for `wb_*.json` descriptors and keeps the runtime's
23
+ * workbooks in sync with them — loading new ones and replacing changed ones.
24
+ *
25
+ * ```ts
26
+ * const rt = new SpreadsheetRuntime()
27
+ * const watcher = new WorkbookWatcher(rt, './watch', {
28
+ * intervalMs: 10_000,
29
+ * onLoad: (id) => console.log('loaded', id),
30
+ * })
31
+ * watcher.start()
32
+ * // ... later ...
33
+ * const wb = watcher.get('sales-2026')
34
+ * watcher.stop()
35
+ * ```
36
+ */
37
+ export class WorkbookWatcher {
38
+ constructor(runtime, dir, options = {}) {
39
+ this.runtime = runtime;
40
+ this.options = options;
41
+ this.loaded = new Map();
42
+ /** Guards against a slow scan overlapping the next interval tick. */
43
+ this.scanning = false;
44
+ this.dir = resolve(dir);
45
+ this.intervalMs = options.intervalMs ?? 10000;
46
+ }
47
+ /**
48
+ * Begin polling. Runs one scan immediately, then every `intervalMs`. The
49
+ * interval is `unref`'d so it never keeps the process alive on its own.
50
+ * Calling {@link start} while already running is a no-op.
51
+ */
52
+ start() {
53
+ if (this.timer !== undefined)
54
+ return;
55
+ this.timer = setInterval(() => void this.scanOnce(), this.intervalMs);
56
+ this.timer.unref?.();
57
+ void this.scanOnce();
58
+ }
59
+ /** Stop polling. Loaded workbooks stay open — {@link close} them if needed. */
60
+ stop() {
61
+ if (this.timer === undefined)
62
+ return;
63
+ clearInterval(this.timer);
64
+ this.timer = undefined;
65
+ }
66
+ /** The live workbook currently loaded under `id`, if any. */
67
+ get(id) {
68
+ return this.loaded.get(id)?.wb;
69
+ }
70
+ /** The string ids of every workbook currently loaded by the watcher. */
71
+ get ids() {
72
+ return [...this.loaded.keys()];
73
+ }
74
+ /**
75
+ * Run a single scan pass now: read every `wb_*.json` descriptor and reload
76
+ * any whose version changed. Exposed for tests and manual triggering; the
77
+ * interval calls it for you. Overlapping calls are skipped (a scan already
78
+ * in flight wins), so this never runs two passes concurrently.
79
+ */
80
+ async scanOnce() {
81
+ if (this.scanning)
82
+ return;
83
+ this.scanning = true;
84
+ try {
85
+ await this.scan();
86
+ }
87
+ finally {
88
+ this.scanning = false;
89
+ }
90
+ }
91
+ async scan() {
92
+ let files;
93
+ try {
94
+ files = await readdir(this.dir);
95
+ }
96
+ catch (err) {
97
+ this.options.onError?.(this.dir, err);
98
+ return;
99
+ }
100
+ for (const name of files) {
101
+ if (!isDescriptorFile(name))
102
+ continue;
103
+ const file = resolve(this.dir, name);
104
+ // eslint-disable-next-line no-await-in-loop
105
+ await this.reconcile(file);
106
+ }
107
+ }
108
+ /** Load-or-replace the workbook described by a single descriptor file. */
109
+ async reconcile(file) {
110
+ let descriptor;
111
+ try {
112
+ descriptor = parseDescriptor(await readFile(file, 'utf8'));
113
+ }
114
+ catch (err) {
115
+ this.options.onError?.(file, err);
116
+ return;
117
+ }
118
+ const version = String(descriptor.version);
119
+ const prev = this.loaded.get(descriptor.id);
120
+ if (prev && prev.version === version)
121
+ return; // unchanged — nothing to do
122
+ // Fetch the bytes and load a fresh handle directly, bypassing
123
+ // SpreadsheetRuntime.loadWorkbook's path-dedup: a new version usually
124
+ // reuses the same source with changed content, so the dedup cache would
125
+ // hand back the stale handle. Load the new one first, then swap and
126
+ // release the old — on failure the old workbook is left untouched.
127
+ let loaded;
128
+ try {
129
+ loaded = await this.load(descriptor);
130
+ }
131
+ catch (err) {
132
+ this.options.onError?.(file, err);
133
+ return;
134
+ }
135
+ this.loaded.set(descriptor.id, { version, ...loaded });
136
+ if (prev)
137
+ this.runtime.close(prev.wb);
138
+ this.options.onLoad?.(descriptor.id, loaded.wb, descriptor);
139
+ }
140
+ /**
141
+ * Fetch a descriptor's .xlsx bytes — from its local {@link
142
+ * WorkbookDescriptor.path} if present, otherwise its {@link
143
+ * WorkbookDescriptor.url} — and load a fresh workbook. Only the local-path
144
+ * source records a `path` on the handle (a URL isn't a filesystem path).
145
+ */
146
+ async load(d) {
147
+ if (d.path !== undefined) {
148
+ const source = isAbsolute(d.path)
149
+ ? d.path
150
+ : resolve(this.dir, d.path);
151
+ const content = await readFile(source);
152
+ const wb = this.runtime.loadWorkbookFromBytes(content, basename(source), source);
153
+ return { wb, source };
154
+ }
155
+ // d.url is guaranteed present by parseDescriptor when path is absent.
156
+ const url = d.url;
157
+ const res = await fetch(url);
158
+ if (!res.ok)
159
+ throw new Error(`fetch ${url} failed: ${res.status} ${res.statusText}`);
160
+ const content = new Uint8Array(await res.arrayBuffer());
161
+ const wb = this.runtime.loadWorkbookFromBytes(content, nameFromUrl(url));
162
+ return { wb, source: url };
163
+ }
164
+ }
165
+ /** True for descriptor file names — `wb_*.json`. */
166
+ function isDescriptorFile(name) {
167
+ return /^wb_.*\.json$/i.test(name);
168
+ }
169
+ /** Parse and validate a descriptor's JSON text, throwing on a bad shape. */
170
+ function parseDescriptor(text) {
171
+ const raw = JSON.parse(text);
172
+ if (typeof raw !== 'object' || raw === null)
173
+ throw new Error('descriptor must be a JSON object');
174
+ const { id, version, path, url } = raw;
175
+ if (typeof id !== 'string' || id.length === 0)
176
+ throw new Error('descriptor.id must be a non-empty string');
177
+ if (typeof version !== 'string' && typeof version !== 'number')
178
+ throw new Error('descriptor.version must be a string or number');
179
+ if (path !== undefined && (typeof path !== 'string' || path.length === 0))
180
+ throw new Error('descriptor.path, if present, must be a non-empty string');
181
+ if (url !== undefined && (typeof url !== 'string' || url.length === 0))
182
+ throw new Error('descriptor.url, if present, must be a non-empty string');
183
+ if (path === undefined && url === undefined)
184
+ throw new Error('descriptor must have a `path` or a `url`');
185
+ return { id, version, path, url };
186
+ }
187
+ /** Derive a workbook file name from a URL, falling back to a generic name. */
188
+ function nameFromUrl(url) {
189
+ try {
190
+ const last = new URL(url).pathname.split('/').filter(Boolean).pop();
191
+ if (last)
192
+ return decodeURIComponent(last);
193
+ }
194
+ catch {
195
+ /* not a parseable URL — fall through */
196
+ }
197
+ return 'workbook.xlsx';
198
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logisheets-runtime",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Headless LogiSheets spreadsheet runtime for Node — logisheets-core wired to the Node WASM engine. The Node counterpart of the browser app.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",