logisheets-runtime 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +89 -0
- package/dist/index.js +176 -0
- package/dist/rpc.d.ts +87 -0
- package/dist/rpc.js +235 -0
- package/package.json +27 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Value, Client } from 'logisheets-web';
|
|
2
|
+
import { WorkbookOps } from 'logisheets-core';
|
|
3
|
+
export * from 'logisheets-core';
|
|
4
|
+
export * from './rpc.js';
|
|
5
|
+
/**
|
|
6
|
+
* A single live workbook in the Node WASM engine, with logisheets-core's logic
|
|
7
|
+
* available as methods. You don't construct one directly — obtain it from
|
|
8
|
+
* {@link SpreadsheetRuntime.createWorkbook} or
|
|
9
|
+
* {@link SpreadsheetRuntime.loadWorkbook}, then run every operation against
|
|
10
|
+
* this handle so the target workbook is always explicit.
|
|
11
|
+
*/
|
|
12
|
+
export declare class Workbook {
|
|
13
|
+
/** The workbook's engine id (unique across the whole process). */
|
|
14
|
+
readonly id: number;
|
|
15
|
+
/** The shared, engine-neutral operation layer, bound to this workbook. */
|
|
16
|
+
readonly ops: WorkbookOps;
|
|
17
|
+
/**
|
|
18
|
+
* The raw async {@link Client} bound to this workbook — every
|
|
19
|
+
* `WorkbookMethods` call mapped onto the Node engine. This is the
|
|
20
|
+
* last-resort escape hatch: prefer {@link ops}, but when an operation has
|
|
21
|
+
* no `WorkbookOps` method yet, you can drive the engine directly here.
|
|
22
|
+
*/
|
|
23
|
+
readonly client: Client;
|
|
24
|
+
/** Absolute path this workbook was loaded from, if any. */
|
|
25
|
+
readonly path?: string;
|
|
26
|
+
private released;
|
|
27
|
+
/** @internal Construct via {@link SpreadsheetRuntime}. */
|
|
28
|
+
constructor(bookId: number, path?: string);
|
|
29
|
+
/** Read a single cell's evaluated value. */
|
|
30
|
+
getValue(sheetIdx: number, row: number, col: number): Value;
|
|
31
|
+
/** Undo the most recent transaction. Returns whether anything was undone. */
|
|
32
|
+
undo(): Promise<boolean>;
|
|
33
|
+
/** Redo the most recently undone transaction. Returns whether anything was redone. */
|
|
34
|
+
redo(): Promise<boolean>;
|
|
35
|
+
/**
|
|
36
|
+
* Drop the undo/redo history, keeping the current state as the baseline.
|
|
37
|
+
* Nothing is reverted — only the history is cleared (bounds memory and
|
|
38
|
+
* makes prior changes permanent).
|
|
39
|
+
*/
|
|
40
|
+
cleanHistory(): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Revert every change still on the undo stack, returning the workbook to
|
|
43
|
+
* its current baseline. Assumes the history was clean at the baseline (the
|
|
44
|
+
* mutation lifecycle in {@link RpcServer.registerMutation} guarantees this),
|
|
45
|
+
* so it undoes exactly the changes made since.
|
|
46
|
+
*/
|
|
47
|
+
discardChanges(): Promise<void>;
|
|
48
|
+
/** @internal Release engine resources. Use {@link SpreadsheetRuntime.close}. */
|
|
49
|
+
release(): void;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The headless runtime: a container for many open {@link Workbook}s. Create one
|
|
53
|
+
* per Node process, then load or create as many workbooks as you need — each
|
|
54
|
+
* operation is issued against the specific workbook handle you hold.
|
|
55
|
+
*
|
|
56
|
+
* ```ts
|
|
57
|
+
* const rt = new SpreadsheetRuntime()
|
|
58
|
+
* const wb1 = await rt.loadWorkbook('a.xlsx')
|
|
59
|
+
* const wb2 = await rt.loadWorkbook('b.xlsx')
|
|
60
|
+
* const wb3 = rt.createWorkbook()
|
|
61
|
+
* await wb1.ops.inputCell(0, 0, 0, 'hi')
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export declare class SpreadsheetRuntime {
|
|
65
|
+
/** Loaded-from-disk workbooks, keyed by absolute path, for dedup. */
|
|
66
|
+
private readonly byPath;
|
|
67
|
+
/** Every open workbook this runtime owns. */
|
|
68
|
+
private readonly open;
|
|
69
|
+
/** Create a new empty workbook. */
|
|
70
|
+
createWorkbook(): Workbook;
|
|
71
|
+
/**
|
|
72
|
+
* Load a workbook from a .xlsx file on disk. Calling this twice with the
|
|
73
|
+
* same path returns the already-loaded {@link Workbook} rather than
|
|
74
|
+
* reloading — {@link close} it to release the engine and clear the entry.
|
|
75
|
+
*/
|
|
76
|
+
loadWorkbook(path: string): Promise<Workbook>;
|
|
77
|
+
/**
|
|
78
|
+
* Load a workbook from raw .xlsx bytes already in memory.
|
|
79
|
+
*
|
|
80
|
+
* @param name file name used by the engine (e.g. for the workbook title)
|
|
81
|
+
*/
|
|
82
|
+
loadWorkbookFromBytes(content: Uint8Array, name: string, path?: string): Workbook;
|
|
83
|
+
/** All workbooks currently open in this runtime. */
|
|
84
|
+
get workbooks(): readonly Workbook[];
|
|
85
|
+
/** Close one workbook, releasing its engine resources. */
|
|
86
|
+
close(wb: Workbook): void;
|
|
87
|
+
/** Close every open workbook. */
|
|
88
|
+
closeAll(): void;
|
|
89
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// logisheets-runtime — a headless spreadsheet runtime for Node.
|
|
2
|
+
//
|
|
3
|
+
// This is the Node counterpart of the browser app: it wires logisheets-core's
|
|
4
|
+
// (engine-neutral) logic to the Node WASM engine via the injected-Client model.
|
|
5
|
+
// The browser supplies logisheets-engine's worker client; here we supply a
|
|
6
|
+
// synchronous client built on the Node WASM `handle()` entry point.
|
|
7
|
+
//
|
|
8
|
+
// A single {@link SpreadsheetRuntime} owns many {@link Workbook}s at once
|
|
9
|
+
// (wb1, wb2, wb3, …); every operation runs against one specific workbook. All
|
|
10
|
+
// workbook logic lives in logisheets-core's WorkbookOps; the runtime only
|
|
11
|
+
// adapts the synchronous Node `handle()` entry point into the async Client that
|
|
12
|
+
// WorkbookOps consumes, then exposes that ops layer per workbook.
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import { basename, resolve } from 'node:path';
|
|
15
|
+
import { handle } from 'logisheets/wasm/logisheets_wasm_server.js';
|
|
16
|
+
import { WorkbookOps } from 'logisheets-core';
|
|
17
|
+
// Re-export the core surface so consumers import everything from one place.
|
|
18
|
+
export * from 'logisheets-core';
|
|
19
|
+
// The developer-defined JSON-RPC server (operations run against this runtime).
|
|
20
|
+
export * from './rpc.js';
|
|
21
|
+
/**
|
|
22
|
+
* Adapt the synchronous Node `handle()` entry point into the async {@link
|
|
23
|
+
* Client} that logisheets-core's operation layer expects.
|
|
24
|
+
*
|
|
25
|
+
* Every WorkbookMethods call has the shape `client.method(params)` and maps
|
|
26
|
+
* 1:1 onto `handle({method, value: params}, bookId)`, so a single generic
|
|
27
|
+
* Proxy covers the whole interface — no per-method boilerplate. Results are
|
|
28
|
+
* wrapped in a resolved Promise so a Node caller can `await` exactly like the
|
|
29
|
+
* browser. The callback/register* members are not used by the operation layer
|
|
30
|
+
* and are intentionally absent.
|
|
31
|
+
*/
|
|
32
|
+
function makeNodeClient(bookId) {
|
|
33
|
+
return new Proxy({}, {
|
|
34
|
+
get(_target, prop) {
|
|
35
|
+
const method = String(prop);
|
|
36
|
+
return (params) => Promise.resolve(handle(params === undefined
|
|
37
|
+
? method
|
|
38
|
+
: { method, value: params }, bookId));
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A single live workbook in the Node WASM engine, with logisheets-core's logic
|
|
44
|
+
* available as methods. You don't construct one directly — obtain it from
|
|
45
|
+
* {@link SpreadsheetRuntime.createWorkbook} or
|
|
46
|
+
* {@link SpreadsheetRuntime.loadWorkbook}, then run every operation against
|
|
47
|
+
* this handle so the target workbook is always explicit.
|
|
48
|
+
*/
|
|
49
|
+
export class Workbook {
|
|
50
|
+
/** @internal Construct via {@link SpreadsheetRuntime}. */
|
|
51
|
+
constructor(bookId, path) {
|
|
52
|
+
this.released = false;
|
|
53
|
+
this.id = bookId;
|
|
54
|
+
this.client = makeNodeClient(bookId);
|
|
55
|
+
this.ops = new WorkbookOps(this.client);
|
|
56
|
+
this.path = path;
|
|
57
|
+
}
|
|
58
|
+
/** Read a single cell's evaluated value. */
|
|
59
|
+
getValue(sheetIdx, row, col) {
|
|
60
|
+
return handle({ method: 'getValue', value: { sheetIdx, row, col } }, this.id);
|
|
61
|
+
}
|
|
62
|
+
/** Undo the most recent transaction. Returns whether anything was undone. */
|
|
63
|
+
async undo() {
|
|
64
|
+
return (await this.client.undo()) === true;
|
|
65
|
+
}
|
|
66
|
+
/** Redo the most recently undone transaction. Returns whether anything was redone. */
|
|
67
|
+
async redo() {
|
|
68
|
+
return (await this.client.redo()) === true;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Drop the undo/redo history, keeping the current state as the baseline.
|
|
72
|
+
* Nothing is reverted — only the history is cleared (bounds memory and
|
|
73
|
+
* makes prior changes permanent).
|
|
74
|
+
*/
|
|
75
|
+
async cleanHistory() {
|
|
76
|
+
await this.client.cleanHistory();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Revert every change still on the undo stack, returning the workbook to
|
|
80
|
+
* its current baseline. Assumes the history was clean at the baseline (the
|
|
81
|
+
* mutation lifecycle in {@link RpcServer.registerMutation} guarantees this),
|
|
82
|
+
* so it undoes exactly the changes made since.
|
|
83
|
+
*/
|
|
84
|
+
async discardChanges() {
|
|
85
|
+
// eslint-disable-next-line no-await-in-loop
|
|
86
|
+
while ((await this.client.undo()) === true) {
|
|
87
|
+
/* keep undoing until the stack is empty */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** @internal Release engine resources. Use {@link SpreadsheetRuntime.close}. */
|
|
91
|
+
release() {
|
|
92
|
+
if (this.released)
|
|
93
|
+
return;
|
|
94
|
+
this.released = true;
|
|
95
|
+
handle('release', this.id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The headless runtime: a container for many open {@link Workbook}s. Create one
|
|
100
|
+
* per Node process, then load or create as many workbooks as you need — each
|
|
101
|
+
* operation is issued against the specific workbook handle you hold.
|
|
102
|
+
*
|
|
103
|
+
* ```ts
|
|
104
|
+
* const rt = new SpreadsheetRuntime()
|
|
105
|
+
* const wb1 = await rt.loadWorkbook('a.xlsx')
|
|
106
|
+
* const wb2 = await rt.loadWorkbook('b.xlsx')
|
|
107
|
+
* const wb3 = rt.createWorkbook()
|
|
108
|
+
* await wb1.ops.inputCell(0, 0, 0, 'hi')
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export class SpreadsheetRuntime {
|
|
112
|
+
constructor() {
|
|
113
|
+
/** Loaded-from-disk workbooks, keyed by absolute path, for dedup. */
|
|
114
|
+
this.byPath = new Map();
|
|
115
|
+
/** Every open workbook this runtime owns. */
|
|
116
|
+
this.open = new Set();
|
|
117
|
+
}
|
|
118
|
+
/** Create a new empty workbook. */
|
|
119
|
+
createWorkbook() {
|
|
120
|
+
const wb = new Workbook(handle('newWorkbook'));
|
|
121
|
+
this.open.add(wb);
|
|
122
|
+
return wb;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Load a workbook from a .xlsx file on disk. Calling this twice with the
|
|
126
|
+
* same path returns the already-loaded {@link Workbook} rather than
|
|
127
|
+
* reloading — {@link close} it to release the engine and clear the entry.
|
|
128
|
+
*/
|
|
129
|
+
async loadWorkbook(path) {
|
|
130
|
+
const key = resolve(path);
|
|
131
|
+
const existing = this.byPath.get(key);
|
|
132
|
+
if (existing)
|
|
133
|
+
return existing;
|
|
134
|
+
const content = await readFile(key);
|
|
135
|
+
const wb = this.loadWorkbookFromBytes(content, basename(key), key);
|
|
136
|
+
this.byPath.set(key, wb);
|
|
137
|
+
return wb;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Load a workbook from raw .xlsx bytes already in memory.
|
|
141
|
+
*
|
|
142
|
+
* @param name file name used by the engine (e.g. for the workbook title)
|
|
143
|
+
*/
|
|
144
|
+
loadWorkbookFromBytes(content, name, path) {
|
|
145
|
+
const bookId = handle('newWorkbook');
|
|
146
|
+
// The engine's deserializer expects a plain number array, not a
|
|
147
|
+
// typed array / Buffer — mirror the browser SDK's `Array.from(buf)`.
|
|
148
|
+
const code = handle({
|
|
149
|
+
method: 'loadWorkbook',
|
|
150
|
+
value: { content: Array.from(content), name },
|
|
151
|
+
}, bookId);
|
|
152
|
+
if (code !== 0) {
|
|
153
|
+
handle('release', bookId);
|
|
154
|
+
throw new Error(`failed to load workbook "${name}" (code ${code})`);
|
|
155
|
+
}
|
|
156
|
+
const wb = new Workbook(bookId, path);
|
|
157
|
+
this.open.add(wb);
|
|
158
|
+
return wb;
|
|
159
|
+
}
|
|
160
|
+
/** All workbooks currently open in this runtime. */
|
|
161
|
+
get workbooks() {
|
|
162
|
+
return [...this.open];
|
|
163
|
+
}
|
|
164
|
+
/** Close one workbook, releasing its engine resources. */
|
|
165
|
+
close(wb) {
|
|
166
|
+
this.open.delete(wb);
|
|
167
|
+
if (wb.path !== undefined)
|
|
168
|
+
this.byPath.delete(wb.path);
|
|
169
|
+
wb.release();
|
|
170
|
+
}
|
|
171
|
+
/** Close every open workbook. */
|
|
172
|
+
closeAll() {
|
|
173
|
+
for (const wb of [...this.open])
|
|
174
|
+
this.close(wb);
|
|
175
|
+
}
|
|
176
|
+
}
|
package/dist/rpc.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type IncomingMessage } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import type { SpreadsheetRuntime, Workbook } from './index.js';
|
|
4
|
+
export declare const RPC_PARSE_ERROR = -32700;
|
|
5
|
+
export declare const RPC_INVALID_REQUEST = -32600;
|
|
6
|
+
export declare const RPC_METHOD_NOT_FOUND = -32601;
|
|
7
|
+
export declare const RPC_INVALID_PARAMS = -32602;
|
|
8
|
+
export declare const RPC_INTERNAL_ERROR = -32603;
|
|
9
|
+
/** Context handed to every RPC method: the runtime that owns the workbooks. */
|
|
10
|
+
export interface RpcContext {
|
|
11
|
+
readonly runtime: SpreadsheetRuntime;
|
|
12
|
+
/** The raw HTTP request, for headers/auth if a method needs it. */
|
|
13
|
+
readonly request: IncomingMessage;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A developer-defined RPC method. Receives the request's `params` and a
|
|
17
|
+
* {@link RpcContext}, and returns (sync or async) the result to serialize back.
|
|
18
|
+
* Throw {@link RpcError} to return a structured JSON-RPC error.
|
|
19
|
+
*/
|
|
20
|
+
export type RpcMethod<P = any, R = unknown> = (params: P, ctx: RpcContext) => R | Promise<R>;
|
|
21
|
+
/** Picks (or creates/loads) the workbook a mutation method operates on. */
|
|
22
|
+
export type WorkbookResolver<P = any> = (params: P, ctx: RpcContext) => Workbook | Promise<Workbook>;
|
|
23
|
+
/** The body of a mutation method, run against the resolved workbook. */
|
|
24
|
+
export type MutationRun<P = any, R = unknown> = (workbook: Workbook, params: P, ctx: RpcContext) => R | Promise<R>;
|
|
25
|
+
export interface MutationOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Whether changes are persisted when the caller omits the `save` flag from
|
|
28
|
+
* the request params. Defaults to `true`.
|
|
29
|
+
*/
|
|
30
|
+
saveByDefault?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Reserved request param read by {@link RpcServer.registerMutation}: a boolean
|
|
34
|
+
* deciding whether the call's changes are kept (`true`) or rolled back
|
|
35
|
+
* (`false`). Either way the workbook's history is cleaned afterwards.
|
|
36
|
+
*/
|
|
37
|
+
export declare const SAVE_PARAM = "save";
|
|
38
|
+
/** Throw this from a method to return a specific JSON-RPC error to the caller. */
|
|
39
|
+
export declare class RpcError extends Error {
|
|
40
|
+
readonly code: number;
|
|
41
|
+
readonly data?: unknown;
|
|
42
|
+
constructor(code: number, message: string, data?: unknown);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* JSON-RPC 2.0 server over HTTP. Construct with a {@link SpreadsheetRuntime},
|
|
46
|
+
* {@link register} methods, then {@link listen}. All registered methods run
|
|
47
|
+
* against that one runtime, so they share its open workbooks.
|
|
48
|
+
*/
|
|
49
|
+
export declare class RpcServer {
|
|
50
|
+
readonly runtime: SpreadsheetRuntime;
|
|
51
|
+
private readonly methods;
|
|
52
|
+
private server?;
|
|
53
|
+
constructor(runtime: SpreadsheetRuntime);
|
|
54
|
+
/**
|
|
55
|
+
* Register an RPC method. The name must be unique. Returns `this` so
|
|
56
|
+
* registrations can be chained.
|
|
57
|
+
*/
|
|
58
|
+
register<P = any, R = unknown>(method: string, handler: RpcMethod<P, R>): this;
|
|
59
|
+
/**
|
|
60
|
+
* Register a *mutating* RPC method that reads/writes a workbook. The
|
|
61
|
+
* framework wraps the body with a save lifecycle:
|
|
62
|
+
*
|
|
63
|
+
* 1. resolve the target workbook from the params (`target`),
|
|
64
|
+
* 2. clear its history so the baseline is clean,
|
|
65
|
+
* 3. run the body (`run`),
|
|
66
|
+
* 4. read the boolean `{@link SAVE_PARAM}` from the params — if it is
|
|
67
|
+
* `false`, roll back every change the body made,
|
|
68
|
+
* 5. clear the workbook's history again.
|
|
69
|
+
*
|
|
70
|
+
* So callers control persistence per call via a `save` param (default
|
|
71
|
+
* {@link MutationOptions.saveByDefault}, itself defaulting to `true`), and
|
|
72
|
+
* the workbook never accumulates history across requests either way.
|
|
73
|
+
*/
|
|
74
|
+
registerMutation<P = any, R = unknown>(method: string, target: WorkbookResolver<P>, run: MutationRun<P, R>, options?: MutationOptions): this;
|
|
75
|
+
/** Whether a method name is registered. */
|
|
76
|
+
has(method: string): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Start listening. Resolves with the bound address once the socket is open.
|
|
79
|
+
* Defaults to loopback (`127.0.0.1`); pass `'0.0.0.0'` to accept external
|
|
80
|
+
* connections. Use port `0` for an OS-assigned ephemeral port.
|
|
81
|
+
*/
|
|
82
|
+
listen(port: number, host?: string): Promise<AddressInfo>;
|
|
83
|
+
/** Stop listening. Does not close the runtime's workbooks. */
|
|
84
|
+
close(): Promise<void>;
|
|
85
|
+
private onRequest;
|
|
86
|
+
private dispatch;
|
|
87
|
+
}
|
package/dist/rpc.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// A tiny, dependency-free JSON-RPC 2.0 server for the runtime.
|
|
3
|
+
//
|
|
4
|
+
// The framework here is generic: it owns the wire protocol (HTTP + JSON-RPC
|
|
5
|
+
// 2.0 envelope, dispatch, error mapping) but knows nothing about which methods
|
|
6
|
+
// exist. Developers define their own RPC methods — the params and the body are
|
|
7
|
+
// theirs — where the body reads/writes workbooks through the injected
|
|
8
|
+
// {@link SpreadsheetRuntime}. They `register()` each method, then `listen()`.
|
|
9
|
+
//
|
|
10
|
+
// const rt = new SpreadsheetRuntime()
|
|
11
|
+
// const server = new RpcServer(rt)
|
|
12
|
+
// server.register('openSheet', async ({path}, {runtime}) => {
|
|
13
|
+
// const wb = await runtime.loadWorkbook(path)
|
|
14
|
+
// return {sheets: await wb.client.getAllSheetInfo()}
|
|
15
|
+
// })
|
|
16
|
+
// server.register('readCell', (p, {runtime}) => {
|
|
17
|
+
// const wb = runtime.workbooks.find((w) => w.path === p.path)
|
|
18
|
+
// if (!wb) throw new RpcError(RPC_INVALID_PARAMS, 'workbook not loaded')
|
|
19
|
+
// return wb.getValue(p.sheet, p.row, p.col)
|
|
20
|
+
// })
|
|
21
|
+
// const addr = await server.listen(3000)
|
|
22
|
+
import { createServer, } from 'node:http';
|
|
23
|
+
// Standard JSON-RPC 2.0 error codes. Developers can also throw {@link RpcError}
|
|
24
|
+
// with their own (positive) application codes.
|
|
25
|
+
export const RPC_PARSE_ERROR = -32700;
|
|
26
|
+
export const RPC_INVALID_REQUEST = -32600;
|
|
27
|
+
export const RPC_METHOD_NOT_FOUND = -32601;
|
|
28
|
+
export const RPC_INVALID_PARAMS = -32602;
|
|
29
|
+
export const RPC_INTERNAL_ERROR = -32603;
|
|
30
|
+
/**
|
|
31
|
+
* Reserved request param read by {@link RpcServer.registerMutation}: a boolean
|
|
32
|
+
* deciding whether the call's changes are kept (`true`) or rolled back
|
|
33
|
+
* (`false`). Either way the workbook's history is cleaned afterwards.
|
|
34
|
+
*/
|
|
35
|
+
export const SAVE_PARAM = 'save';
|
|
36
|
+
/** Throw this from a method to return a specific JSON-RPC error to the caller. */
|
|
37
|
+
export class RpcError extends Error {
|
|
38
|
+
constructor(code, message, data) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'RpcError';
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.data = data;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* JSON-RPC 2.0 server over HTTP. Construct with a {@link SpreadsheetRuntime},
|
|
47
|
+
* {@link register} methods, then {@link listen}. All registered methods run
|
|
48
|
+
* against that one runtime, so they share its open workbooks.
|
|
49
|
+
*/
|
|
50
|
+
export class RpcServer {
|
|
51
|
+
constructor(runtime) {
|
|
52
|
+
this.methods = new Map();
|
|
53
|
+
this.runtime = runtime;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Register an RPC method. The name must be unique. Returns `this` so
|
|
57
|
+
* registrations can be chained.
|
|
58
|
+
*/
|
|
59
|
+
register(method, handler) {
|
|
60
|
+
if (this.methods.has(method))
|
|
61
|
+
throw new Error(`duplicate RPC method "${method}"`);
|
|
62
|
+
this.methods.set(method, handler);
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Register a *mutating* RPC method that reads/writes a workbook. The
|
|
67
|
+
* framework wraps the body with a save lifecycle:
|
|
68
|
+
*
|
|
69
|
+
* 1. resolve the target workbook from the params (`target`),
|
|
70
|
+
* 2. clear its history so the baseline is clean,
|
|
71
|
+
* 3. run the body (`run`),
|
|
72
|
+
* 4. read the boolean `{@link SAVE_PARAM}` from the params — if it is
|
|
73
|
+
* `false`, roll back every change the body made,
|
|
74
|
+
* 5. clear the workbook's history again.
|
|
75
|
+
*
|
|
76
|
+
* So callers control persistence per call via a `save` param (default
|
|
77
|
+
* {@link MutationOptions.saveByDefault}, itself defaulting to `true`), and
|
|
78
|
+
* the workbook never accumulates history across requests either way.
|
|
79
|
+
*/
|
|
80
|
+
registerMutation(method, target, run, options = {}) {
|
|
81
|
+
const saveByDefault = options.saveByDefault ?? true;
|
|
82
|
+
return this.register(method, async (params, ctx) => {
|
|
83
|
+
const wb = await target(params, ctx);
|
|
84
|
+
await wb.cleanHistory();
|
|
85
|
+
const result = await run(wb, params, ctx);
|
|
86
|
+
if (!readSaveFlag(params, saveByDefault))
|
|
87
|
+
await wb.discardChanges();
|
|
88
|
+
await wb.cleanHistory();
|
|
89
|
+
return result ?? null;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** Whether a method name is registered. */
|
|
93
|
+
has(method) {
|
|
94
|
+
return this.methods.has(method);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Start listening. Resolves with the bound address once the socket is open.
|
|
98
|
+
* Defaults to loopback (`127.0.0.1`); pass `'0.0.0.0'` to accept external
|
|
99
|
+
* connections. Use port `0` for an OS-assigned ephemeral port.
|
|
100
|
+
*/
|
|
101
|
+
listen(port, host = '127.0.0.1') {
|
|
102
|
+
if (this.server)
|
|
103
|
+
throw new Error('server already listening');
|
|
104
|
+
const server = createServer((req, res) => this.onRequest(req, res));
|
|
105
|
+
this.server = server;
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
server.once('error', reject);
|
|
108
|
+
server.listen(port, host, () => {
|
|
109
|
+
server.removeListener('error', reject);
|
|
110
|
+
resolve(server.address());
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** Stop listening. Does not close the runtime's workbooks. */
|
|
115
|
+
close() {
|
|
116
|
+
const server = this.server;
|
|
117
|
+
if (!server)
|
|
118
|
+
return Promise.resolve();
|
|
119
|
+
this.server = undefined;
|
|
120
|
+
return new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
|
121
|
+
}
|
|
122
|
+
async onRequest(req, res) {
|
|
123
|
+
if (req.method !== 'POST') {
|
|
124
|
+
res.writeHead(405, { Allow: 'POST' }).end();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let body;
|
|
128
|
+
try {
|
|
129
|
+
body = JSON.parse(await readBody(req));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return send(res, 200, {
|
|
133
|
+
jsonrpc: '2.0',
|
|
134
|
+
id: null,
|
|
135
|
+
error: { code: RPC_PARSE_ERROR, message: 'parse error' },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// A JSON-RPC batch is an array; a single call is an object.
|
|
139
|
+
if (Array.isArray(body)) {
|
|
140
|
+
if (body.length === 0)
|
|
141
|
+
return send(res, 200, invalidRequest(null));
|
|
142
|
+
const responses = (await Promise.all(body.map((m) => this.dispatch(m, req)))).filter((r) => r !== null);
|
|
143
|
+
// All-notification batch -> no content per spec.
|
|
144
|
+
if (responses.length === 0)
|
|
145
|
+
return void res.writeHead(204).end();
|
|
146
|
+
return send(res, 200, responses);
|
|
147
|
+
}
|
|
148
|
+
const response = await this.dispatch(body, req);
|
|
149
|
+
if (response === null)
|
|
150
|
+
return void res.writeHead(204).end();
|
|
151
|
+
send(res, 200, response);
|
|
152
|
+
}
|
|
153
|
+
// Returns null for notifications (no `id`), which get no response.
|
|
154
|
+
async dispatch(msg, req) {
|
|
155
|
+
if (!isObject(msg) ||
|
|
156
|
+
msg.jsonrpc !== '2.0' ||
|
|
157
|
+
typeof msg.method !== 'string') {
|
|
158
|
+
const id = isObject(msg) ? toId(msg.id) : null;
|
|
159
|
+
return invalidRequest(id);
|
|
160
|
+
}
|
|
161
|
+
const request = msg;
|
|
162
|
+
const isNotification = !('id' in msg) || msg.id === undefined;
|
|
163
|
+
const id = toId(request.id);
|
|
164
|
+
const handler = this.methods.get(request.method);
|
|
165
|
+
if (!handler) {
|
|
166
|
+
return isNotification
|
|
167
|
+
? null
|
|
168
|
+
: {
|
|
169
|
+
jsonrpc: '2.0',
|
|
170
|
+
id,
|
|
171
|
+
error: {
|
|
172
|
+
code: RPC_METHOD_NOT_FOUND,
|
|
173
|
+
message: `method not found: ${request.method}`,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const result = await handler(request.params, {
|
|
179
|
+
runtime: this.runtime,
|
|
180
|
+
request: req,
|
|
181
|
+
});
|
|
182
|
+
// JSON-RPC success responses must carry a `result` member;
|
|
183
|
+
// `void` handlers return undefined, so normalize it to null.
|
|
184
|
+
return isNotification
|
|
185
|
+
? null
|
|
186
|
+
: { jsonrpc: '2.0', id, result: result ?? null };
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
if (isNotification)
|
|
190
|
+
return null;
|
|
191
|
+
const error = e instanceof RpcError
|
|
192
|
+
? { code: e.code, message: e.message, data: e.data }
|
|
193
|
+
: {
|
|
194
|
+
code: RPC_INTERNAL_ERROR,
|
|
195
|
+
message: e instanceof Error ? e.message : String(e),
|
|
196
|
+
};
|
|
197
|
+
return { jsonrpc: '2.0', id, error };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function invalidRequest(id) {
|
|
202
|
+
return {
|
|
203
|
+
jsonrpc: '2.0',
|
|
204
|
+
id,
|
|
205
|
+
error: { code: RPC_INVALID_REQUEST, message: 'invalid request' },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function isObject(v) {
|
|
209
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
210
|
+
}
|
|
211
|
+
// Read the reserved `save` boolean from a mutation's params, falling back to
|
|
212
|
+
// the method's default when absent or not a boolean.
|
|
213
|
+
function readSaveFlag(params, dflt) {
|
|
214
|
+
if (isObject(params) && typeof params[SAVE_PARAM] === 'boolean')
|
|
215
|
+
return params[SAVE_PARAM];
|
|
216
|
+
return dflt;
|
|
217
|
+
}
|
|
218
|
+
function toId(id) {
|
|
219
|
+
return typeof id === 'string' || typeof id === 'number' ? id : null;
|
|
220
|
+
}
|
|
221
|
+
function readBody(req) {
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
const chunks = [];
|
|
224
|
+
req.on('data', (c) => chunks.push(c));
|
|
225
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
226
|
+
req.on('error', reject);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function send(res, status, payload) {
|
|
230
|
+
const body = JSON.stringify(payload);
|
|
231
|
+
res.writeHead(status, {
|
|
232
|
+
'Content-Type': 'application/json',
|
|
233
|
+
'Content-Length': Buffer.byteLength(body),
|
|
234
|
+
}).end(body);
|
|
235
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "logisheets-runtime",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Headless LogiSheets spreadsheet runtime for Node — logisheets-core wired to the Node WASM engine. The Node counterpart of the browser app.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -p tsconfig.build.json",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "vitest run"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"author": "Jeremy He",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"logisheets": "workspace:*",
|
|
21
|
+
"logisheets-core": "workspace:*"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"typescript": "^5.5.0",
|
|
25
|
+
"vitest": "^2.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|