logisheets-runtime 1.1.1 → 1.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/craft.d.ts +92 -0
- package/dist/craft.js +219 -0
- package/dist/enterprise.d.ts +116 -0
- package/dist/enterprise.js +292 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/rpc.d.ts +1 -0
- package/dist/watcher.d.ts +94 -0
- package/dist/watcher.js +198 -0
- package/package.json +3 -3
package/dist/craft.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/watcher.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"author": "Jeremy He",
|
|
19
19
|
"license": "MIT",
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"logisheets": "^1.
|
|
22
|
-
"logisheets-core": "^1.
|
|
21
|
+
"logisheets": "^1.3.0",
|
|
22
|
+
"logisheets-core": "^1.3.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"typescript": "^5.5.0",
|