@plexspaces/sdk 0.1.1
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/README.md +225 -0
- package/dist/actor.d.ts +81 -0
- package/dist/actor.js +484 -0
- package/dist/actor.test.d.ts +1 -0
- package/dist/actor.test.js +171 -0
- package/dist/actor_id.d.ts +46 -0
- package/dist/actor_id.js +85 -0
- package/dist/decorators.d.ts +40 -0
- package/dist/decorators.js +97 -0
- package/dist/host.d.ts +312 -0
- package/dist/host.js +828 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +14 -0
- package/dist/leader_worker.d.ts +36 -0
- package/dist/leader_worker.js +119 -0
- package/dist/process_groups.d.ts +4 -0
- package/dist/process_groups.js +14 -0
- package/dist/proto.d.ts +33 -0
- package/dist/proto.js +10 -0
- package/dist/router.d.ts +37 -0
- package/dist/router.js +78 -0
- package/dist/wire/http-fetch-proto-wire.d.ts +7 -0
- package/dist/wire/http-fetch-proto-wire.js +101 -0
- package/dist/wire/proto-wire-common.d.ts +18 -0
- package/dist/wire/proto-wire-common.js +91 -0
- package/dist/wire/registry-proto-wire.d.ts +41 -0
- package/dist/wire/registry-proto-wire.js +280 -0
- package/dist/wire/shard-group-proto-wire.d.ts +7 -0
- package/dist/wire/shard-group-proto-wire.js +723 -0
- package/dist/wire/tuplespace-proto-wire.d.ts +8 -0
- package/dist/wire/tuplespace-proto-wire.js +183 -0
- package/dist/wit-payload.d.ts +23 -0
- package/dist/wit-payload.js +42 -0
- package/dist/workflow.d.ts +14 -0
- package/dist/workflow.js +47 -0
- package/package.json +36 -0
package/dist/actor.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2025 PlexSpaces Contributors
|
|
3
|
+
//
|
|
4
|
+
// PlexSpaces Actor base class (TypeScript SDK)
|
|
5
|
+
//
|
|
6
|
+
// Use inheritance to define actors with minimal boilerplate. Override
|
|
7
|
+
// getDefaultState() and onOpName(payload) methods; the base class handles
|
|
8
|
+
// init/handle/getState/setState and dispatch by payload.op.
|
|
9
|
+
/**
|
|
10
|
+
* WIT host log function (imported from plexspaces:actor/host).
|
|
11
|
+
*
|
|
12
|
+
* jco componentize uses virtual imports for WIT host interfaces.
|
|
13
|
+
* Pattern: import { functionName } from 'namespace:package/interface@version'
|
|
14
|
+
*
|
|
15
|
+
* The import is provided by the runtime when the component is instantiated.
|
|
16
|
+
* In non-WASM environments (Node.js verification), this import will be undefined.
|
|
17
|
+
*
|
|
18
|
+
* Type definitions are generated in src/generated/ but are not imported here
|
|
19
|
+
* to keep the SDK simple. The virtual import works at runtime without types.
|
|
20
|
+
*/
|
|
21
|
+
// @ts-ignore - Virtual import provided by jco componentize at runtime
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
23
|
+
// @ts-expect-error - Virtual import - types are optional (generated in src/generated/)
|
|
24
|
+
import { log as hostLog } from 'plexspaces:actor/host@0.1.0';
|
|
25
|
+
import { getActorDefinition } from './decorators.js';
|
|
26
|
+
import { decodeWitPayloadUtf8, encodeWitPayloadUtf8 } from './wit-payload.js';
|
|
27
|
+
/**
|
|
28
|
+
* Safe logging helper that uses WIT host.log function.
|
|
29
|
+
*
|
|
30
|
+
* Logging must never throw - if host.log is unavailable or fails,
|
|
31
|
+
* we silently ignore (critical for WASM safety).
|
|
32
|
+
*
|
|
33
|
+
* @param level Log level (trace, debug, info, warn, error)
|
|
34
|
+
* @param location Code location (file:line)
|
|
35
|
+
* @param message Log message
|
|
36
|
+
* @param extra Optional extra string (already serialized, no recursion)
|
|
37
|
+
*/
|
|
38
|
+
function actorLog(level, location, message, extra) {
|
|
39
|
+
try {
|
|
40
|
+
// Call host log function if available (provided by jco componentize/runtime)
|
|
41
|
+
if (typeof hostLog === 'function') {
|
|
42
|
+
const entry = extra ? `[${location}] ${message} ${extra}` : `[${location}] ${message}`;
|
|
43
|
+
// Call WIT host log function (virtual import provided by jco componentize)
|
|
44
|
+
hostLog(level, entry);
|
|
45
|
+
}
|
|
46
|
+
// If host.log is not available, silently ignore (no-op)
|
|
47
|
+
// This allows the code to work in non-WASM environments (e.g., Node.js verification)
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Logging must never throw - swallow all errors silently
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Base class for PlexSpaces actors (actor-world WIT: init, handle, get-state, set-state).
|
|
55
|
+
*
|
|
56
|
+
* Subclass and override:
|
|
57
|
+
* - getDefaultState(): initial state
|
|
58
|
+
* - onInit(config): optional, called from init() with parsed config
|
|
59
|
+
* - on<Op>(payload): handler for message op (e.g. onDeposit, onBalance).
|
|
60
|
+
* Op is derived from payload.op or payload; method name is "on" + capitalized op.
|
|
61
|
+
*
|
|
62
|
+
* Example:
|
|
63
|
+
* class BankAccountActor extends PlexSpacesActor<BankAccountState> {
|
|
64
|
+
* getDefaultState() { return { account_id: '', balance: 0, transactions: [] }; }
|
|
65
|
+
* onDeposit(p: { amount: number }) { ...; return { status: 'ok', balance: this.state.balance }; }
|
|
66
|
+
* onBalance() { return { account: this.state.account_id, balance: this.state.balance }; }
|
|
67
|
+
* }
|
|
68
|
+
*/
|
|
69
|
+
export class PlexSpacesActor {
|
|
70
|
+
constructor() {
|
|
71
|
+
// Cache serialized state to avoid WASM traps when getState() is called after handle()
|
|
72
|
+
this.cachedStateJson = null;
|
|
73
|
+
this.state = this.getDefaultState();
|
|
74
|
+
// Don't serialize state in constructor — defer to getState()
|
|
75
|
+
// The framework re-instantiates after every handle(), so caching here is wasted
|
|
76
|
+
this.cachedStateJson = null;
|
|
77
|
+
}
|
|
78
|
+
/** Optional: called from init() with parsed config. Override to apply config to state. */
|
|
79
|
+
onInit(_config) {
|
|
80
|
+
// default: no-op; subclass can set state from config
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* WIT `init(config: payload) -> result<_, actor-error>`.
|
|
84
|
+
* Success: return (unit). Failure: throw (jco maps throws to `err` for function-return `result`).
|
|
85
|
+
*/
|
|
86
|
+
init(configJson) {
|
|
87
|
+
try {
|
|
88
|
+
const text = decodeWitPayloadUtf8(configJson);
|
|
89
|
+
const config = text.trim() ? JSON.parse(text) : {};
|
|
90
|
+
this.onInit(config);
|
|
91
|
+
// Cache state after init (state is typically small/flat here)
|
|
92
|
+
this.cachedStateJson = null; // Invalidate; lazy-serialize in getState()
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new Error('ERROR:init failed');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* WIT `handle(...) -> result<payload, actor-error>` (`payload` is `list<u8>` → `Uint8Array` in jco).
|
|
100
|
+
* Dispatches by msgType for Workflow behavior (workflow_run, workflow_signal:name, workflow_query:name),
|
|
101
|
+
* then by payload.op (or payload) to on<Op>(payload). Returns UTF-8 JSON bytes.
|
|
102
|
+
* Uses iterative serializer to avoid WASM recursion.
|
|
103
|
+
*
|
|
104
|
+
* Workflow behavior (aligned with Rust Workflow trait and Python @workflow_actor):
|
|
105
|
+
* - msgType "workflow_run" -> run(payload)
|
|
106
|
+
* - msgType "workflow_signal:name" -> signal(name, payload)
|
|
107
|
+
* - msgType "workflow_query:name" -> query(name, payload)
|
|
108
|
+
*/
|
|
109
|
+
handle(_fromActor, msgType, payloadJson) {
|
|
110
|
+
try {
|
|
111
|
+
const text = decodeWitPayloadUtf8(payloadJson);
|
|
112
|
+
const payload = text.trim() ? JSON.parse(text) : {};
|
|
113
|
+
const definition = getActorDefinition(this);
|
|
114
|
+
// Workflow behavior: route by msgType when actor implements run/signal/query (aligned with crates/behavior Workflow trait)
|
|
115
|
+
if (msgType === "workflow_run") {
|
|
116
|
+
const runMethod = definition?.runHandler;
|
|
117
|
+
const runFn = runMethod
|
|
118
|
+
? this[runMethod]
|
|
119
|
+
: this.run;
|
|
120
|
+
if (typeof runFn === "function") {
|
|
121
|
+
const result = runFn.call(this, payload);
|
|
122
|
+
this.cachedStateJson = null;
|
|
123
|
+
return encodeWitPayloadUtf8(iterativeStringify(result ?? {}));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (msgType.startsWith("workflow_signal:")) {
|
|
127
|
+
const name = msgType.slice("workflow_signal:".length).trim();
|
|
128
|
+
const signalMethod = definition?.signalHandlers?.[name];
|
|
129
|
+
const signalFn = signalMethod
|
|
130
|
+
? this[signalMethod]
|
|
131
|
+
: this.signal;
|
|
132
|
+
if (typeof signalFn === "function") {
|
|
133
|
+
if (signalMethod) {
|
|
134
|
+
signalFn.call(this, payload);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
signalFn.call(this, name, payload);
|
|
138
|
+
}
|
|
139
|
+
this.cachedStateJson = null;
|
|
140
|
+
return encodeWitPayloadUtf8('{}');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (msgType.startsWith("workflow_query:")) {
|
|
144
|
+
const name = msgType.slice("workflow_query:".length).trim();
|
|
145
|
+
const queryMethod = definition?.queryHandlers?.[name];
|
|
146
|
+
const queryFn = queryMethod
|
|
147
|
+
? this[queryMethod]
|
|
148
|
+
: this.query;
|
|
149
|
+
if (typeof queryFn === "function") {
|
|
150
|
+
const result = queryMethod
|
|
151
|
+
? queryFn.call(this, payload)
|
|
152
|
+
: queryFn.call(this, name, payload);
|
|
153
|
+
return encodeWitPayloadUtf8(iterativeStringify(result ?? {}));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Payload key order: message_type -> op -> msg_type; fallback to msgType so data-only payloads route by message type (e.g. tasks_ready)
|
|
157
|
+
const opRaw = payload.message_type ?? payload.op ?? payload.msg_type;
|
|
158
|
+
const op = (typeof opRaw === "string" && opRaw ? opRaw : msgType);
|
|
159
|
+
const decoratedMethod = this.resolveDecoratedHandler(op, definition);
|
|
160
|
+
const opKey = typeof op === "string" ? this.capitalize(op) : "";
|
|
161
|
+
const methodName = decoratedMethod ?? (opKey ? `on${opKey}` : "");
|
|
162
|
+
const method = methodName && typeof this[methodName] === "function"
|
|
163
|
+
? this[methodName]
|
|
164
|
+
: null;
|
|
165
|
+
if (method) {
|
|
166
|
+
let result;
|
|
167
|
+
try {
|
|
168
|
+
result = method.call(this, payload);
|
|
169
|
+
}
|
|
170
|
+
catch (handlerError) {
|
|
171
|
+
const errorMsg = handlerError instanceof Error ? handlerError.message : String(handlerError);
|
|
172
|
+
actorLog('error', 'actor.ts:handle', `Handler ${methodName} failed`, errorMsg);
|
|
173
|
+
throw new Error('ERROR:' + errorMsg);
|
|
174
|
+
}
|
|
175
|
+
// Do not cache state here. The framework re-instantiates after every handle()
|
|
176
|
+
// (wasmtime#8943 workaround), so caching is wasted work that risks stack overflow.
|
|
177
|
+
// State will be serialized lazily in getState() if needed.
|
|
178
|
+
this.cachedStateJson = null;
|
|
179
|
+
// Use iterative serializer to avoid WASM recursion.
|
|
180
|
+
// The iterative serializer uses an explicit work stack instead of recursive function calls.
|
|
181
|
+
try {
|
|
182
|
+
return encodeWitPayloadUtf8(iterativeStringify(result ?? {}));
|
|
183
|
+
}
|
|
184
|
+
catch (jsonError) {
|
|
185
|
+
const errorMsg = jsonError instanceof Error ? jsonError.message : String(jsonError);
|
|
186
|
+
actorLog('error', 'actor.ts:handle', 'JSON serialization failed', errorMsg);
|
|
187
|
+
throw new Error('ERROR:JSON serialization failed: ' + errorMsg);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
actorLog('warn', 'actor.ts:handle', 'Unknown operation', String(op));
|
|
191
|
+
return encodeWitPayloadUtf8(iterativeStringify({ error: 'unknown_op', op: String(op) }));
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
195
|
+
actorLog('error', 'actor.ts:handle', 'Handle failed', errorMsg);
|
|
196
|
+
if (e instanceof Error && errorMsg.startsWith('ERROR:')) {
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
throw new Error('ERROR:' + errorMsg);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** WIT `get-state() -> result<payload, actor-error>`. Returns JSON state as UTF-8 bytes. */
|
|
203
|
+
getState() {
|
|
204
|
+
if (this.cachedStateJson !== null) {
|
|
205
|
+
return encodeWitPayloadUtf8(this.cachedStateJson);
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const serialized = iterativeStringify(this.state);
|
|
209
|
+
this.cachedStateJson = serialized;
|
|
210
|
+
return encodeWitPayloadUtf8(serialized);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return encodeWitPayloadUtf8('{}');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** WIT `set-state(state: payload) -> result<_, actor-error>`. */
|
|
217
|
+
setState(stateJson) {
|
|
218
|
+
try {
|
|
219
|
+
const text = decodeWitPayloadUtf8(stateJson);
|
|
220
|
+
if (text.trim()) {
|
|
221
|
+
this.state = JSON.parse(text);
|
|
222
|
+
this.cachedStateJson = null; // Invalidate
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
throw new Error('ERROR:set_state failed');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
capitalize(s) {
|
|
230
|
+
if (!s)
|
|
231
|
+
return "";
|
|
232
|
+
// Handle snake_case: "load_model" -> "Load_model" (preserve underscores, only capitalize first char)
|
|
233
|
+
// This matches handler naming: onLoad_model, onPredict_batch, etc.
|
|
234
|
+
// Only capitalize the first character, keep the rest as-is (preserves underscores and case)
|
|
235
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
236
|
+
}
|
|
237
|
+
resolveDecoratedHandler(op, definition = getActorDefinition(this)) {
|
|
238
|
+
if (!definition)
|
|
239
|
+
return null;
|
|
240
|
+
return definition.handlers[op]?.methodName ?? null;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Serialize object to JSON string using fully iterative approach (zero recursion).
|
|
244
|
+
*
|
|
245
|
+
* jco componentize compiles JS to WASM (StarlingMonkey) with a tiny call stack.
|
|
246
|
+
* Native JSON.stringify recurses per-element and per-nesting-level, hitting
|
|
247
|
+
* stack limits with arrays of 2+ items. This iterative serializer uses a work
|
|
248
|
+
* stack instead of recursive function calls.
|
|
249
|
+
*/
|
|
250
|
+
json(obj) {
|
|
251
|
+
return iterativeStringify(obj);
|
|
252
|
+
}
|
|
253
|
+
error(message) {
|
|
254
|
+
return "ERROR:" + message;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Base class for Workflow behavior actors (aligned with Rust Workflow trait and Python @workflow_actor).
|
|
259
|
+
*
|
|
260
|
+
* Implement run(), signal(), and query() for durable workflows (Temporal/Restate-style).
|
|
261
|
+
* Message routing: framework sends msgType "workflow_run" | "workflow_signal:name" | "workflow_query:name";
|
|
262
|
+
* PlexSpacesActor.handle() dispatches to these methods when present.
|
|
263
|
+
*
|
|
264
|
+
* Example:
|
|
265
|
+
* class OrderFulfillmentActor extends WorkflowActor<OrderState> {
|
|
266
|
+
* getDefaultState() { return { orderId: '', status: 'pending', steps: [] }; }
|
|
267
|
+
* run(payload: Record<string, unknown>) { ...; return { status: 'completed' }; }
|
|
268
|
+
* signal(name: string, data: Record<string, unknown>) { if (name === 'cancel') this.state.status = 'cancelled'; }
|
|
269
|
+
* query(name: string, params: Record<string, unknown>) { if (name === 'status') return this.state; return {}; }
|
|
270
|
+
* }
|
|
271
|
+
*/
|
|
272
|
+
export class WorkflowActor extends PlexSpacesActor {
|
|
273
|
+
}
|
|
274
|
+
// ─── Fully Iterative JSON Serializer (ZERO recursion, ZERO method calls in loop) ───
|
|
275
|
+
// Character codes for escaping (pre-computed, avoids method calls)
|
|
276
|
+
const CHAR_QUOTE = 34; // "
|
|
277
|
+
const CHAR_BACKSLASH = 92; // \
|
|
278
|
+
const CHAR_NEWLINE = 10; // \n
|
|
279
|
+
const CHAR_CR = 13; // \r
|
|
280
|
+
const CHAR_TAB = 9; // \t
|
|
281
|
+
const CHAR_SPACE = 32; // space
|
|
282
|
+
// Escape table: for chars 0-31, pre-build the escape sequence
|
|
283
|
+
// This avoids ANY computation during string escaping
|
|
284
|
+
const ESCAPE_TABLE = [];
|
|
285
|
+
for (let i = 0; i < 128; i++) {
|
|
286
|
+
if (i === CHAR_QUOTE)
|
|
287
|
+
ESCAPE_TABLE[i] = '\\"';
|
|
288
|
+
else if (i === CHAR_BACKSLASH)
|
|
289
|
+
ESCAPE_TABLE[i] = '\\\\';
|
|
290
|
+
else if (i === CHAR_NEWLINE)
|
|
291
|
+
ESCAPE_TABLE[i] = '\\n';
|
|
292
|
+
else if (i === CHAR_CR)
|
|
293
|
+
ESCAPE_TABLE[i] = '\\r';
|
|
294
|
+
else if (i === CHAR_TAB)
|
|
295
|
+
ESCAPE_TABLE[i] = '\\t';
|
|
296
|
+
else if (i < CHAR_SPACE) {
|
|
297
|
+
// \u00XX
|
|
298
|
+
const h1 = (i >> 4) & 0xf;
|
|
299
|
+
const h0 = i & 0xf;
|
|
300
|
+
ESCAPE_TABLE[i] =
|
|
301
|
+
'\\u00' +
|
|
302
|
+
String.fromCharCode(h1 < 10 ? 48 + h1 : 87 + h1) +
|
|
303
|
+
String.fromCharCode(h0 < 10 ? 48 + h0 : 87 + h0);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
ESCAPE_TABLE[i] = ''; // No escape needed
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Escape a string for JSON. Uses pre-computed escape table.
|
|
311
|
+
* No method calls in the hot loop (only charCodeAt and fromCharCode).
|
|
312
|
+
*/
|
|
313
|
+
function escapeStr(s) {
|
|
314
|
+
let out = '"';
|
|
315
|
+
for (let i = 0, len = s.length; i < len; i++) {
|
|
316
|
+
const c = s.charCodeAt(i);
|
|
317
|
+
if (c < 128) {
|
|
318
|
+
const esc = ESCAPE_TABLE[c];
|
|
319
|
+
if (esc) {
|
|
320
|
+
out += esc;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
out += String.fromCharCode(c);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
// Non-ASCII: pass through (valid UTF-8 in JSON)
|
|
328
|
+
out += String.fromCharCode(c);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
out += '"';
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Fully iterative JSON serializer. ZERO recursion.
|
|
336
|
+
*
|
|
337
|
+
* Instead of recursive calls, we use an explicit work stack of "tokens" to emit.
|
|
338
|
+
* The stack contains either literal strings to output, or values to process.
|
|
339
|
+
* We process the stack in a single while loop with no function calls except
|
|
340
|
+
* escapeStr() for string values (which itself has no recursion).
|
|
341
|
+
*
|
|
342
|
+
* This design keeps WASM call stack depth constant regardless of data size.
|
|
343
|
+
*/
|
|
344
|
+
// Work item types — using number tags (not strings) to avoid allocation
|
|
345
|
+
const TAG_VALUE = 0;
|
|
346
|
+
const TAG_LITERAL = 1;
|
|
347
|
+
/**
|
|
348
|
+
* Fully iterative JSON serializer with zero recursion.
|
|
349
|
+
* Uses a work stack instead of recursive function calls.
|
|
350
|
+
*/
|
|
351
|
+
function iterativeStringify(root) {
|
|
352
|
+
// Stack of work items: [tag, payload]
|
|
353
|
+
// We use two parallel arrays instead of objects to minimize allocation
|
|
354
|
+
const stackTags = [];
|
|
355
|
+
const stackPayloads = [];
|
|
356
|
+
let sp = 0; // stack pointer
|
|
357
|
+
// Push root value
|
|
358
|
+
stackTags[0] = TAG_VALUE;
|
|
359
|
+
stackPayloads[0] = root;
|
|
360
|
+
sp = 1;
|
|
361
|
+
// Output fragments
|
|
362
|
+
const fragments = [];
|
|
363
|
+
let fragCount = 0;
|
|
364
|
+
while (sp > 0) {
|
|
365
|
+
// Pop
|
|
366
|
+
sp--;
|
|
367
|
+
const tag = stackTags[sp];
|
|
368
|
+
const payload = stackPayloads[sp];
|
|
369
|
+
// Help GC
|
|
370
|
+
stackPayloads[sp] = null;
|
|
371
|
+
if (tag === TAG_LITERAL) {
|
|
372
|
+
// Literal string — just emit
|
|
373
|
+
fragments[fragCount++] = payload;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
// TAG_VALUE — process the value
|
|
377
|
+
if (payload === null || payload === undefined) {
|
|
378
|
+
fragments[fragCount++] = 'null';
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const t = typeof payload;
|
|
382
|
+
if (t === 'string') {
|
|
383
|
+
fragments[fragCount++] = escapeStr(payload);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (t === 'number') {
|
|
387
|
+
fragments[fragCount++] = '' + payload;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (t === 'boolean') {
|
|
391
|
+
fragments[fragCount++] = payload ? 'true' : 'false';
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (t === 'function') {
|
|
395
|
+
fragments[fragCount++] = 'null';
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
// Object or Array
|
|
399
|
+
// Duck-type array detection (avoids Array.isArray which may recurse)
|
|
400
|
+
const obj = payload;
|
|
401
|
+
const len = obj['length'];
|
|
402
|
+
const isArr = typeof len === 'number' && len >= 0 && (len >>> 0) === len;
|
|
403
|
+
if (isArr) {
|
|
404
|
+
const arr = payload;
|
|
405
|
+
const arrLen = arr.length;
|
|
406
|
+
if (arrLen === 0) {
|
|
407
|
+
fragments[fragCount++] = '[]';
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
// Push tokens in REVERSE order (stack is LIFO):
|
|
411
|
+
// [ elem0 , elem1 , elem2 ]
|
|
412
|
+
// Push: ] elemN , elemN-1 , ... , elem0 [
|
|
413
|
+
stackTags[sp] = TAG_LITERAL;
|
|
414
|
+
stackPayloads[sp] = ']';
|
|
415
|
+
sp++;
|
|
416
|
+
for (let i = arrLen - 1; i >= 0; i--) {
|
|
417
|
+
stackTags[sp] = TAG_VALUE;
|
|
418
|
+
stackPayloads[sp] = arr[i];
|
|
419
|
+
sp++;
|
|
420
|
+
if (i > 0) {
|
|
421
|
+
stackTags[sp] = TAG_LITERAL;
|
|
422
|
+
stackPayloads[sp] = ',';
|
|
423
|
+
sp++;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
stackTags[sp] = TAG_LITERAL;
|
|
427
|
+
stackPayloads[sp] = '[';
|
|
428
|
+
sp++;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
// Plain object
|
|
432
|
+
// Use Object.getOwnPropertyNames() which is safer than for...in in StarlingMonkey
|
|
433
|
+
// This avoids recursion while still iterating all properties
|
|
434
|
+
let keys = [];
|
|
435
|
+
try {
|
|
436
|
+
// Object.getOwnPropertyNames() returns all own properties (non-enumerable too)
|
|
437
|
+
// This is safer than for...in which may trigger recursion in StarlingMonkey
|
|
438
|
+
const allProps = Object.getOwnPropertyNames(obj);
|
|
439
|
+
for (let i = 0; i < allProps.length; i++) {
|
|
440
|
+
const k = allProps[i];
|
|
441
|
+
// Skip function properties and undefined values
|
|
442
|
+
const v = obj[k];
|
|
443
|
+
if (v !== undefined && typeof v !== 'function') {
|
|
444
|
+
keys.push(k);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// If Object.getOwnPropertyNames fails, return empty object
|
|
450
|
+
fragments[fragCount++] = '{}';
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (keys.length === 0) {
|
|
454
|
+
fragments[fragCount++] = '{}';
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
// Push in reverse: } valN "keyN": , ... , val0 "key0": {
|
|
458
|
+
stackTags[sp] = TAG_LITERAL;
|
|
459
|
+
stackPayloads[sp] = '}';
|
|
460
|
+
sp++;
|
|
461
|
+
for (let i = keys.length - 1; i >= 0; i--) {
|
|
462
|
+
stackTags[sp] = TAG_VALUE;
|
|
463
|
+
stackPayloads[sp] = obj[keys[i]];
|
|
464
|
+
sp++;
|
|
465
|
+
stackTags[sp] = TAG_LITERAL;
|
|
466
|
+
stackPayloads[sp] = escapeStr(keys[i]) + ':';
|
|
467
|
+
sp++;
|
|
468
|
+
if (i > 0) {
|
|
469
|
+
stackTags[sp] = TAG_LITERAL;
|
|
470
|
+
stackPayloads[sp] = ',';
|
|
471
|
+
sp++;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
stackTags[sp] = TAG_LITERAL;
|
|
475
|
+
stackPayloads[sp] = '{';
|
|
476
|
+
sp++;
|
|
477
|
+
}
|
|
478
|
+
// Join fragments without Array.join()
|
|
479
|
+
let result = '';
|
|
480
|
+
for (let i = 0; i < fragCount; i++) {
|
|
481
|
+
result += fragments[i];
|
|
482
|
+
}
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2025 PlexSpaces Contributors
|
|
3
|
+
//
|
|
4
|
+
// Unit tests for PlexSpacesActor base class
|
|
5
|
+
import { PlexSpacesActor } from './actor';
|
|
6
|
+
// Mock host.log for testing
|
|
7
|
+
let mockHostLog;
|
|
8
|
+
// Override the import for testing (in real WASM, jco provides this)
|
|
9
|
+
// @ts-ignore - Test override
|
|
10
|
+
global.hostLog = undefined;
|
|
11
|
+
class TestActor extends PlexSpacesActor {
|
|
12
|
+
getDefaultState() {
|
|
13
|
+
return { count: 0, name: '' };
|
|
14
|
+
}
|
|
15
|
+
onIncrement(payload) {
|
|
16
|
+
const amount = Number(payload.amount ?? 1);
|
|
17
|
+
this.state.count += amount;
|
|
18
|
+
return { count: this.state.count };
|
|
19
|
+
}
|
|
20
|
+
onSetName(payload) {
|
|
21
|
+
this.state.name = String(payload.name ?? '');
|
|
22
|
+
return { name: this.state.name };
|
|
23
|
+
}
|
|
24
|
+
onGet() {
|
|
25
|
+
return { count: this.state.count, name: this.state.name };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
describe('PlexSpacesActor', () => {
|
|
29
|
+
let actor;
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
actor = new TestActor();
|
|
32
|
+
mockHostLog = undefined;
|
|
33
|
+
});
|
|
34
|
+
describe('init', () => {
|
|
35
|
+
it('should initialize with default state', () => {
|
|
36
|
+
const result = actor.init('{}');
|
|
37
|
+
expect(result).toBe('');
|
|
38
|
+
expect(actor.state.count).toBe(0);
|
|
39
|
+
expect(actor.state.name).toBe('');
|
|
40
|
+
});
|
|
41
|
+
it('should call onInit with parsed config', () => {
|
|
42
|
+
const config = JSON.stringify({ name: 'test-actor' });
|
|
43
|
+
const result = actor.init(config);
|
|
44
|
+
expect(result).toBe('');
|
|
45
|
+
});
|
|
46
|
+
it('should return error on invalid JSON', () => {
|
|
47
|
+
const result = actor.init('invalid json');
|
|
48
|
+
expect(result).toContain('ERROR');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('handle', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
actor.init('{}');
|
|
54
|
+
});
|
|
55
|
+
it('should dispatch to onIncrement handler', () => {
|
|
56
|
+
const payload = JSON.stringify({ op: 'increment', amount: 5 });
|
|
57
|
+
const result = actor.handle('sender', 'call', payload);
|
|
58
|
+
const parsed = JSON.parse(result);
|
|
59
|
+
expect(parsed.count).toBe(5);
|
|
60
|
+
expect(actor.state.count).toBe(5);
|
|
61
|
+
});
|
|
62
|
+
it('should dispatch to onSetName handler', () => {
|
|
63
|
+
const payload = JSON.stringify({ op: 'set_name', name: 'test' });
|
|
64
|
+
const result = actor.handle('sender', 'call', payload);
|
|
65
|
+
const parsed = JSON.parse(result);
|
|
66
|
+
expect(parsed.name).toBe('test');
|
|
67
|
+
expect(actor.state.name).toBe('test');
|
|
68
|
+
});
|
|
69
|
+
it('should return error for unknown operation', () => {
|
|
70
|
+
const payload = JSON.stringify({ op: 'unknown' });
|
|
71
|
+
const result = actor.handle('sender', 'call', payload);
|
|
72
|
+
const parsed = JSON.parse(result);
|
|
73
|
+
expect(parsed.error).toBe('unknown_op');
|
|
74
|
+
});
|
|
75
|
+
it('should handle handler errors gracefully', () => {
|
|
76
|
+
class ErrorActor extends PlexSpacesActor {
|
|
77
|
+
getDefaultState() {
|
|
78
|
+
return { count: 0, name: '' };
|
|
79
|
+
}
|
|
80
|
+
onError() {
|
|
81
|
+
throw new Error('Handler error');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const errorActor = new ErrorActor();
|
|
85
|
+
errorActor.init('{}');
|
|
86
|
+
const payload = JSON.stringify({ op: 'error' });
|
|
87
|
+
const result = errorActor.handle('sender', 'call', payload);
|
|
88
|
+
expect(result).toContain('ERROR');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe('getState', () => {
|
|
92
|
+
it('should return serialized state', () => {
|
|
93
|
+
actor.init('{}');
|
|
94
|
+
actor.state.count = 42;
|
|
95
|
+
actor.state.name = 'test';
|
|
96
|
+
const stateJson = actor.getState();
|
|
97
|
+
const parsed = JSON.parse(stateJson);
|
|
98
|
+
expect(parsed.count).toBe(42);
|
|
99
|
+
expect(parsed.name).toBe('test');
|
|
100
|
+
});
|
|
101
|
+
it('should cache serialized state', () => {
|
|
102
|
+
actor.init('{}');
|
|
103
|
+
const state1 = actor.getState();
|
|
104
|
+
const state2 = actor.getState();
|
|
105
|
+
expect(state1).toBe(state2);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
describe('setState', () => {
|
|
109
|
+
it('should restore state from JSON', () => {
|
|
110
|
+
const stateJson = JSON.stringify({ count: 100, name: 'restored' });
|
|
111
|
+
const result = actor.setState(stateJson);
|
|
112
|
+
expect(result).toBe('');
|
|
113
|
+
expect(actor.state.count).toBe(100);
|
|
114
|
+
expect(actor.state.name).toBe('restored');
|
|
115
|
+
});
|
|
116
|
+
it('should return error on invalid JSON', () => {
|
|
117
|
+
const result = actor.setState('invalid json');
|
|
118
|
+
expect(result).toContain('ERROR');
|
|
119
|
+
});
|
|
120
|
+
it('should handle empty state', () => {
|
|
121
|
+
const result = actor.setState('');
|
|
122
|
+
expect(result).toBe('');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('logging (actorLog)', () => {
|
|
126
|
+
// Note: actorLog is not exported, so we test it indirectly through error handling
|
|
127
|
+
// In a real WASM environment, hostLog would be provided by jco componentize
|
|
128
|
+
it('should handle errors without throwing', () => {
|
|
129
|
+
// This test verifies that logging failures don't crash the actor
|
|
130
|
+
class LoggingActor extends PlexSpacesActor {
|
|
131
|
+
getDefaultState() {
|
|
132
|
+
return { count: 0, name: '' };
|
|
133
|
+
}
|
|
134
|
+
onTest() {
|
|
135
|
+
// This will trigger actorLog internally on error
|
|
136
|
+
return { status: 'ok' };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const loggingActor = new LoggingActor();
|
|
140
|
+
loggingActor.init('{}');
|
|
141
|
+
const payload = JSON.stringify({ op: 'test' });
|
|
142
|
+
// Should not throw even if hostLog is unavailable
|
|
143
|
+
expect(() => {
|
|
144
|
+
loggingActor.handle('sender', 'call', payload);
|
|
145
|
+
}).not.toThrow();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('safeStringify', () => {
|
|
149
|
+
it('should serialize simple objects', () => {
|
|
150
|
+
actor.init('{}');
|
|
151
|
+
const obj = { a: 1, b: 'test' };
|
|
152
|
+
const result = actor.safeStringify(obj);
|
|
153
|
+
expect(result).toBe(JSON.stringify(obj));
|
|
154
|
+
});
|
|
155
|
+
it('should handle circular references', () => {
|
|
156
|
+
actor.init('{}');
|
|
157
|
+
const obj = { a: 1 };
|
|
158
|
+
obj.self = obj;
|
|
159
|
+
// Should not throw
|
|
160
|
+
expect(() => {
|
|
161
|
+
actor.safeStringify(obj);
|
|
162
|
+
}).not.toThrow();
|
|
163
|
+
});
|
|
164
|
+
it('should handle large arrays', () => {
|
|
165
|
+
actor.init('{}');
|
|
166
|
+
const largeArray = Array.from({ length: 1000 }, (_, i) => i);
|
|
167
|
+
const result = actor.safeStringify(largeArray);
|
|
168
|
+
expect(result).toBe(JSON.stringify(largeArray));
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|