xblox 1/2

This commit is contained in:
lovebird 2026-04-07 19:28:35 +02:00
parent efeafbb725
commit 968268d7ca
36 changed files with 6430 additions and 0 deletions

5
packages/xblox/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
dist-in/
coverage/
*.log

13
packages/xblox/.npmignore Normal file
View File

@ -0,0 +1,13 @@
# This package uses package.json "files" as an allowlist (dist-in, README, LICENSE).
# These patterns apply if "files" is ever removed or relaxed — keep junk out of the tarball.
ref-control-freak/
tests/
coverage/
src/
.vscode/
*.test.ts
vitest.config.ts
webpack.config.js
tsconfig.json
tsconfig.*.json

21
packages/xblox/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) Polymech
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

89
packages/xblox/README.md Normal file
View File

@ -0,0 +1,89 @@
# @polymech/xblox
Block-based control flow for Polymech: **if / else-if**, **for / while**, **switch / case / default**, **runScript**, and **break**, with a **nested JSON** graph format, **Zod** validation, and a small **Node** CLI.
This package is a modern take on ideas from the legacy **xblox** AMD stack (`ref-control-freak/` in this repo). It does **not** reimplement the full legacy `Expression.js` pipeline (`[variables]`, `{block}` substitution, block-store UI); it focuses on **execution** and **schema**.
## Install
From the monorepo, using **npm** in this package:
```bash
cd packages/xblox
npm install
npm run build
```
## Package exports
| Import | Use |
|--------|-----|
| `@polymech/xblox` | Types, enums, `ModelBase`, `RunScriptBlock`, engine entrypoints you re-export from app code. **No `vm2`.** |
| `@polymech/xblox/runtime` | `runScriptLegacyVm`, `evalExpression`, `runRunScriptBlock`**Node only**, uses **`vm2`**. Do not import this path in browser bundles if you must avoid `vm2`. |
## CLI: `pm-xblox`
After `npm run build`, the `pm-xblox` binary runs validated graphs from disk.
```bash
npx pm-xblox run --source=./graph.json
npx pm-xblox run --source=./graph.json --output=./out.json
npx pm-xblox run --source=./graph.json --loglevel=info
```
| Option | Default | Description |
|--------|---------|-------------|
| `--source` | (required) | Path to a blocks JSON file (`version: 1`). |
| `--output` | — | If set, writes `{ "result": … }` as JSON. |
| `--loglevel` | `info` | `debug` · `info` · `warn` · `error` |
## Blocks file format
Top-level shape (see `src/schema/blocks-file.ts`):
```json
{
"version": 1,
"context": {},
"root": { "kind": "…", … }
}
```
- **`context`** — plain object merged into the execution `this` (expressions and scripts use **`this`** like the legacy `apply(ctx, args)` model).
- **`root`** — a discriminated union on **`kind`**: `if`, `runScript`, `for`, `while`, `switch`, `break`, plus `case` / `switchDefault` inside `switch.items`.
**Legacy note:** older UIs often stored a **flat array** of blocks with `id`, `parentId`, `declaredClass`. This package currently executes the **nested** tree; you can add an adapter layer (flat → tree) for editors without changing the executor.
## Expressions and scripts
- Conditions and `runScript.method` bodies are evaluated via **`vm2`** in **`@polymech/xblox/runtime`**, following the same **`new Function("{ … }").apply(ctx, args)`** idea as legacy Run Script blocks.
- This is **not** a port of legacy `Expression.js` (no `[var]` substitution, `{block}` calls, or expression cache). For full parity with that layer, plan a separate module or bridge.
**Security:** `vm2` is unmaintained; treat user scripts as trusted only in controlled environments, or replace the runtime with a stricter evaluator later.
## Development
| Script | Description |
|--------|-------------|
| `npm run build` | `tsc``dist-in/` |
| `npm run dev` | `tsc --watch` |
| `npm run clean` | Remove `dist/` and `dist-in/` |
| `npm test` | Run Vitest once |
| `npm run test:watch` | Vitest watch mode |
| `npm run test:coverage` | Vitest with V8 coverage |
| `npm run test:ui` | Vitest browser UI |
| `npm run lint` | ESLint on `src/` |
| `npm run webpack` | After `build`, emits `dist/main_node.js` from `dist-in/main.js` (default package exports only; **not** `@polymech/xblox/runtime` / vm2). |
Tests live under `tests/`; shared paths use `tests/test-commons.ts`. Fixtures are in `tests/fixtures/`.
## Publishing to npm
- **`package.json``files`** is an **allowlist**: only `dist-in/`, `README.md`, and `LICENSE` are packed. **`ref-control-freak/`**, **`tests/`**, **`src/`**, webpack/vitest configs, etc. are **not** published.
- **`prepack`** runs **`npm run build`**, so `dist-in/` exists when you `npm publish` or `npm pack`.
- **Dry-run the tarball:** `npm pack --dry-run` (see “Files included” in the log) or `npm pack` and inspect the `.tgz`.
- **`dist/`** (webpack output) is intentionally **not** in `files`; consumers use `dist-in/` from `tsc`.
## License
MIT — see [`LICENSE`](./LICENSE).

5037
packages/xblox/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,79 @@
{
"name": "@polymech/xblox",
"version": "0.1.0",
"description": "Block control-flow engine (if/for/while/switch/runScript) with Zod schemas, vm2-backed Node runtime, and pm-xblox CLI.",
"type": "module",
"publishConfig": {
"access": "public"
},
"author": "Polymech",
"license": "MIT",
"files": [
"dist-in",
"README.md",
"LICENSE"
],
"repository": {
"type": "git",
"url": "git+https://git.polymech.io/polymech/mono.git",
"directory": "packages/xblox"
},
"exports": {
".": {
"types": "./dist-in/index.d.ts",
"import": "./dist-in/index.js"
},
"./runtime": {
"types": "./dist-in/runtime/index.d.ts",
"import": "./dist-in/runtime/index.js"
}
},
"types": "./dist-in/index.d.ts",
"main": "./dist-in/index.js",
"sideEffects": false,
"scripts": {
"build": "tsc -p .",
"dev": "tsc -p . --watch",
"clean": "rimraf dist dist-in",
"lint": "eslint src --ext .ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"prepack": "npm run build",
"webpack": "webpack --config webpack.config.js --stats-error-details"
},
"bin": {
"pm-xblox": "./dist-in/cli.js"
},
"dependencies": {
"vm2": "^3.9.19",
"yargs": "17.7.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@repo/typescript-config": "file:../typescript-config",
"@types/node": "22.10.2",
"@types/yargs": "^17.0.33",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@vitest/coverage-v8": "4.1.0",
"@vitest/ui": "4.1.0",
"eslint": "^8.57.1",
"rimraf": "6.0.1",
"ts-loader": "9.5.1",
"typescript": "^5.7.2",
"vitest": "4.1.0",
"webpack": "5.105.4",
"webpack-cli": "6.0.1"
},
"keywords": [
"xblox",
"visual-programming",
"blocks",
"flow",
"control-flow",
"zod",
"vitest"
]
}

View File

@ -0,0 +1,24 @@
/**
* Data model for legacy `xblox.model.code.RunScript` no UI, no execution here.
*/
import { ModelBase, type ModelBaseArgs } from '../model/model-base.js';
export interface RunScriptBlockArgs extends ModelBaseArgs {
/** Script body (legacy field name `method`). */
method?: string;
}
export class RunScriptBlock extends ModelBase {
readonly declaredClass = 'xblox.model.code.RunScript' as const;
name = 'Run Script';
/** User script placed inside `new Function("{" + method + "}")` in the original Dojo code. */
method = '';
constructor(args: RunScriptBlockArgs = {}) {
super(args);
if (args.method !== undefined) {
this.method = args.method;
}
}
}

46
packages/xblox/src/cli.ts Normal file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env node
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { runCommand } from './cli/run-command.js';
async function main(): Promise<void> {
await yargs(hideBin(process.argv))
.scriptName('pm-xblox')
.command(
'run',
'Execute a blocks JSON file (if / else-if / runScript)',
(y) =>
y
.option('source', {
type: 'string',
demandOption: true,
describe: 'Path to blocks JSON file',
})
.option('output', {
type: 'string',
describe: 'Optional path to write JSON { result }',
})
.option('loglevel', {
type: 'string',
default: 'info',
describe: 'debug | info | warn | error',
}),
async (argv) => {
await runCommand({
source: argv.source,
output: argv.output,
loglevel: argv.loglevel,
});
},
)
.demandCommand(1, 'Specify a command (e.g. run)')
.strict()
.help()
.parseAsync();
}
main().catch((err) => {
console.error('[pm-xblox]', err);
process.exit(1);
});

View File

@ -0,0 +1,50 @@
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const rank: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
function emit(level: LogLevel, args: unknown[]): void {
const prefix = '[pm-xblox]';
if (level === 'debug') {
console.debug(prefix, ...args);
} else if (level === 'info') {
console.info(prefix, ...args);
} else if (level === 'warn') {
console.warn(prefix, ...args);
} else {
console.error(prefix, ...args);
}
}
/** Messages at or above `minLevel` are printed. Default CLI min is `info`. */
export function createLogger(minLevel: LogLevel) {
const min = rank[minLevel];
const go = (msgLevel: LogLevel) => rank[msgLevel] >= min;
return {
debug: (...a: unknown[]) => {
if (go('debug')) {
emit('debug', a);
}
},
info: (...a: unknown[]) => {
if (go('info')) {
emit('info', a);
}
},
warn: (...a: unknown[]) => {
if (go('warn')) {
emit('warn', a);
}
},
error: (...a: unknown[]) => {
if (go('error')) {
emit('error', a);
}
},
};
}

View File

@ -0,0 +1,11 @@
import { z } from 'zod';
export const logLevelSchema = z.enum(['debug', 'info', 'warn', 'error']);
export const runArgsSchema = z.object({
source: z.string().min(1),
output: z.string().optional(),
loglevel: logLevelSchema.default('info'),
});
export type RunArgs = z.infer<typeof runArgsSchema>;

View File

@ -0,0 +1,39 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { executeBlock } from '../engine/execute-block.js';
import { blocksFileSchema } from '../schema/blocks-file.js';
import { createLogger } from './logger.js';
import { runArgsSchema } from './run-args.js';
export async function runCommand(raw: Record<string, unknown>): Promise<{ result: unknown }> {
const args = runArgsSchema.parse({
source: raw.source,
output: raw.output,
loglevel: raw.loglevel ?? 'info',
});
const log = createLogger(args.loglevel);
const sourcePath = path.resolve(args.source);
log.debug('Reading blocks file:', sourcePath);
const text = await readFile(sourcePath, 'utf8');
const json: unknown = JSON.parse(text);
const file = blocksFileSchema.parse(json);
const ctx = Object.assign(Object.create(null), file.context) as object;
const result = executeBlock(file.root, ctx);
log.info('Execution finished.');
const payload = { result };
if (args.output) {
const outPath = path.resolve(args.output);
await writeFile(outPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
log.info('Wrote', outPath);
}
return payload;
}

View File

@ -0,0 +1,10 @@
export function coerceNumber(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
const n = Number(value);
if (!Number.isFinite(n)) {
return NaN;
}
return n;
}

View File

@ -0,0 +1,154 @@
import { RunScriptBlock } from '../blocks/run-script-block.js';
import type { BlockNode, CaseBlockNode } from '../schema/blocks-file.js';
import { evalExpression, runRunScriptBlock } from '../runtime/run-script-vm.js';
import { coerceNumber } from './coerce.js';
export type ExecState = {
/** Legacy ForBlock `settings.override.args[0]` — loop index. */
loopIndex?: number;
/** Set by `BreakBlock` for parent `SwitchBlock` (legacy `switchblock.stop()`). */
switchCtl?: { stopped: boolean };
};
function isTruthy(value: unknown): boolean {
return Boolean(value);
}
function forCondition(counter: number | string, comparator: string, finalStr: string, ctx: object): boolean {
const condStr = `${counter}${comparator}${finalStr}`;
return isTruthy(evalExpression(condStr, ctx));
}
function forStep(counter: number | string, modifier: string, ctx: object): unknown {
const expr = `${counter}${modifier}`;
return evalExpression(expr, ctx);
}
function executeFor(node: Extract<BlockNode, { kind: 'for' }>, ctx: object, state?: ExecState): unknown {
let counter = coerceNumber(evalExpression(node.initial, ctx));
if (Number.isNaN(counter)) {
counter = 0;
}
let last: unknown;
while (forCondition(counter, node.comparator, node.final, ctx)) {
const childState: ExecState = { ...state, loopIndex: counter };
last = runBlockList(node.items, ctx, childState);
const nextRaw = forStep(counter, node.modifier, ctx);
const next = coerceNumber(nextRaw);
if (Number.isNaN(next)) {
break;
}
if (next === counter) {
break;
}
counter = next;
}
return last;
}
function executeWhile(node: Extract<BlockNode, { kind: 'while' }>, ctx: object, state?: ExecState): unknown {
const limit = node.loopLimit ?? 1500;
let iterations = 0;
let last: unknown;
while (isTruthy(evalExpression(node.condition, ctx)) && iterations < limit) {
last = runBlockList(node.items, ctx, state);
iterations++;
}
return last;
}
/** Legacy CaseBlock.solve comparison: `switchValue + comparator + expression` evaluated as one expression. */
function matchCase(ctx: object, variable: string, item: Pick<CaseBlockNode, 'comparator' | 'expression'>): boolean {
const sv = evalExpression(`this.${variable}`, ctx);
const rhs = evalExpression(item.expression, ctx);
const combined = `${String(sv)}${item.comparator}${String(rhs)}`;
return isTruthy(evalExpression(combined, ctx));
}
function executeSwitch(node: Extract<BlockNode, { kind: 'switch' }>, ctx: object, state?: ExecState): unknown {
const switchCtl = state?.switchCtl ?? { stopped: false };
const childState: ExecState = { ...state, switchCtl };
let anyMatched = false;
let last: unknown;
for (const item of node.items) {
if (item.kind === 'case') {
if (matchCase(ctx, node.variable, item)) {
last = runBlockList(item.consequent, ctx, childState);
anyMatched = true;
break;
}
}
if (switchCtl.stopped) {
break;
}
}
if (!anyMatched) {
for (const item of node.items) {
if (item.kind === 'switchDefault') {
last = runBlockList(item.consequent, ctx, childState);
}
}
}
return last;
}
export function runBlockList(blocks: BlockNode[], ctx: object, state?: ExecState): unknown {
let last: unknown;
for (const b of blocks) {
last = executeBlock(b, ctx, state);
}
return last;
}
/**
* Execute a tree starting at `root` with `ctx` as `this` in expressions and runScript blocks.
*/
export function executeBlock(root: BlockNode, ctx: object, state?: ExecState): unknown {
if (root.kind === 'runScript') {
return runRunScriptBlock(new RunScriptBlock({ method: root.method }), ctx, []);
}
if (root.kind === 'break') {
if (state?.switchCtl) {
state.switchCtl.stopped = true;
}
return undefined;
}
if (root.kind === 'for') {
return executeFor(root, ctx, state);
}
if (root.kind === 'while') {
return executeWhile(root, ctx, state);
}
if (root.kind === 'switch') {
return executeSwitch(root, ctx, state);
}
if (root.kind === 'if') {
if (isTruthy(evalExpression(root.condition, ctx))) {
return runBlockList(root.consequent, ctx, state);
}
if (root.elseIfBlocks) {
for (const branch of root.elseIfBlocks) {
if (isTruthy(evalExpression(branch.condition, ctx))) {
return runBlockList(branch.consequent, ctx, state);
}
}
}
if (root.alternate?.length) {
return runBlockList(root.alternate, ctx, state);
}
return undefined;
}
return undefined;
}

183
packages/xblox/src/enums.ts Normal file
View File

@ -0,0 +1,183 @@
/**
* Ported from ref-control-freak/xblox-ts (legacy xblox). Bitmasks and event names
* shared by browser UI and server/runtime.
*/
/**
* The block's capabilities evaluated in the interface and at run-time.
*/
export enum BLOCK_CAPABILITIES {
TOPMOST = 0x00004000,
TARGET = 0x00040000,
VARIABLE_INPUTS = 0x00000080,
VARIABLE_OUTPUTS = 0x00000100,
VARIABLE_OUTPUT_PARAMETERS = 0x00000200,
VARIABLE_INPUT_PARAMETERS = 0x00000400,
CHILDREN = 0x00000020,
/** Same bitmask as VARIABLE_INPUTS in legacy sources. */
SIGNALS = 0x00000080,
}
/** Flags describing a block's execution behavior. */
export enum RUN_FLAGS {
CHILDREN = 0x00000020,
/** Legacy literal preserved (see ref-control-freak/xblox/types/Types.js). */
WAIT = 0x000008000,
}
/** Flags describing a block's execution state. */
export enum EXECUTION_STATE {
NONE = 0x00000000,
RUNNING = 0x00000001,
ERROR = 0x00000002,
PAUSED = 0x00000004,
FINISH = 0x00000008,
STOPPED = 0x00000010,
ONCE = 0x80000000,
RESET_NEXT_FRAME = 0x00800000,
LOCKED = 0x20000000,
}
export enum BLOCK_MODE {
NORMAL = 0,
UPDATE_WIDGET_PROPERTY = 1,
}
/** Standard signal / outlet bitmask. */
export enum BLOCK_OUTLET {
NONE = 0x00000000,
PROGRESS = 0x00000001,
ERROR = 0x00000002,
PAUSED = 0x00000004,
FINISH = 0x00000008,
STOPPED = 0x00000010,
}
/** Inner state / optimization flags (legacy xide/xblox). */
export enum BLOCK_FLAGS {
NONE = 0x00000000,
ACTIVE = 0x00000001,
SCRIPT = 0x00000002,
RESERVED1 = 0x00000004,
USEFUNCTION = 0x00000008,
RESERVED2 = 0x00000010,
SINGLE = 0x00000020,
WAITSFORMESSAGE = 0x00000040,
VARIABLEINPUTS = 0x00000080,
VARIABLEOUTPUTS = 0x00000100,
VARIABLEPARAMETERINPUTS = 0x00000200,
VARIABLEPARAMETEROUTPUTS = 0x00000400,
TOPMOST = 0x00004000,
BUILDINGBLOCK = 0x00008000,
MESSAGESENDER = 0x00010000,
MESSAGERECEIVER = 0x00020000,
TARGETABLE = 0x00040000,
CUSTOMEDITDIALOG = 0x00080000,
RESERVED0 = 0x00100000,
EXECUTEDLASTFRAME = 0x00200000,
DEACTIVATENEXTFRAME = 0x00400000,
RESETNEXTFRAME = 0x00800000,
INTERNALLYCREATEDINPUTS = 0x01000000,
INTERNALLYCREATEDOUTPUTS = 0x02000000,
INTERNALLYCREATEDINPUTPARAMS = 0x04000000,
INTERNALLYCREATEDOUTPUTPARAMS = 0x08000000,
INTERNALLYCREATEDLOCALPARAMS = 0x40000000,
ACTIVATENEXTFRAME = 0x10000000,
LOCKED = 0x20000000,
LAUNCHEDONCE = 0x80000000,
}
/**
* Callback mask for block lifecycle (legacy EventedMixin `emits` chain).
* Note: legacy sources duplicate 0x00001000 for RESET and READSTATE.
*/
export enum BLOCK_CALLBACKMASK {
PRESAVE = 0x00000001,
DELETE = 0x00000002,
ATTACH = 0x00000004,
DETACH = 0x00000008,
PAUSE = 0x00000010,
RESUME = 0x00000020,
CREATE = 0x00000040,
POSTSAVE = 0x00000100,
LOAD = 0x00000200,
EDITED = 0x00000400,
SETTINGSEDITED = 0x00000800,
RESET = 0x00001000,
READSTATE = 0x00001000,
NEWSCENE = 0x00002000,
ACTIVATESCRIPT = 0x00004000,
DEACTIVATESCRIPT = 0x00008000,
RESETINBREAKPOINT = 0x00010000,
RENAME = 0x00020000,
BASE = 0x0000000e,
SAVELOAD = 0x00000301,
PPR = 0x00000130,
EDITIONS = 0x00000c00,
ALL = 0xffffffff,
}
/** String event names (topic-style), from legacy `types.EVENTS`. */
export const EVENTS = {
ON_RUN_BLOCK: 'onRunBlock',
ON_RUN_BLOCK_FAILED: 'onRunBlockFailed',
ON_RUN_BLOCK_SUCCESS: 'onRunBlockSuccess',
ON_BLOCK_SELECTED: 'onItemSelected',
ON_BLOCK_UNSELECTED: 'onBlockUnSelected',
ON_BLOCK_EXPRESSION_FAILED: 'onExpressionFailed',
ON_BUILD_BLOCK_INFO_LIST: 'onBuildBlockInfoList',
ON_BUILD_BLOCK_INFO_LIST_END: 'onBuildBlockInfoListEnd',
ON_BLOCK_PROPERTY_CHANGED: 'onBlockPropertyChanged',
ON_SCOPE_CREATED: 'onScopeCreated',
ON_VARIABLE_CHANGED: 'onVariableChanged',
ON_CREATE_VARIABLE_CI: 'onCreateVariableCI',
} as const;
export type XbloxEventName = (typeof EVENTS)[keyof typeof EVENTS];
/**
* ESTree-style node type strings used by the legacy block expression layer.
* Renamed from `Type` in xblox-ts to avoid clashing with TypeScript's `Type`.
*/
export enum BlockAstType {
AssignmentExpression = 'AssignmentExpression',
ArrayExpression = 'ArrayExpression',
BlockStatement = 'BlockStatement',
BinaryExpression = 'BinaryExpression',
BreakStatement = 'BreakStatement',
CallExpression = 'CallExpression',
CatchClause = 'CatchClause',
ConditionalExpression = 'ConditionalExpression',
ContinueStatement = 'ContinueStatement',
DoWhileStatement = 'DoWhileStatement',
DebuggerStatement = 'DebuggerStatement',
EmptyStatement = 'EmptyStatement',
ExpressionStatement = 'ExpressionStatement',
ForStatement = 'ForStatement',
ForInStatement = 'ForInStatement',
FunctionDeclaration = 'FunctionDeclaration',
FunctionExpression = 'FunctionExpression',
Identifier = 'Identifier',
IfStatement = 'IfStatement',
Literal = 'Literal',
LabeledStatement = 'LabeledStatement',
LogicalExpression = 'LogicalExpression',
MemberExpression = 'MemberExpression',
NewExpression = 'NewExpression',
ObjectExpression = 'ObjectExpression',
Program = 'Program',
Property = 'Property',
ReturnStatement = 'ReturnStatement',
SequenceExpression = 'SequenceExpression',
SwitchStatement = 'SwitchStatement',
SwitchCase = 'SwitchCase',
ThisExpression = 'ThisExpression',
ThrowStatement = 'ThrowStatement',
TryStatement = 'TryStatement',
UnaryExpression = 'UnaryExpression',
UpdateExpression = 'UpdateExpression',
VariableDeclaration = 'VariableDeclaration',
VariableDeclarator = 'VariableDeclarator',
WhileStatement = 'WhileStatement',
WithStatement = 'WithStatement',
}

View File

@ -0,0 +1,5 @@
export * from './enums.js';
export * from './types.js';
export * from './uuid.js';
export * from './model/model-base.js';
export * from './blocks/run-script-block.js';

View File

@ -0,0 +1,4 @@
/**
* Optional Node bundle entry (see webpack.config.js). Re-exports the public API.
*/
export * from './index.js';

View File

@ -0,0 +1,48 @@
/**
* Minimal port of `xblox/model/ModelBase` eventing and store wiring come later.
*/
import type { PortDescriptor } from '../types.js';
import { randomUUID } from '../uuid.js';
export interface ModelBaseArgs {
id?: string;
description?: string;
parent?: unknown;
parentId?: string | null;
group?: string | null;
order?: number;
}
export abstract class ModelBase {
id: string;
description: string;
parent: unknown;
parentId: string | null;
group: string | null;
order: number;
protected constructor(args: ModelBaseArgs = {}) {
this.description = args.description ?? '';
this.parent = args.parent ?? null;
this.parentId = args.parentId ?? null;
this.group = args.group ?? null;
this.order = args.order ?? 0;
this.id = args.id ?? randomUUID();
}
/** Full input signature (Dojo SMDstyle array). */
takes(): PortDescriptor[] {
return [];
}
/** Required inputs — default: same as `takes()`. */
needs(): PortDescriptor[] {
return this.takes();
}
/** Output signature. */
outputs(): PortDescriptor[] {
return [];
}
}

View File

@ -0,0 +1,6 @@
export {
evalExpression,
runRunScriptBlock,
runScriptLegacyVm,
type RunScriptVmOptions,
} from './run-script-vm.js';

View File

@ -0,0 +1,71 @@
/**
* Node-only: run legacy Run Script blocks inside vm2 (replaces raw `new Function` in the browser build).
* Import from `@polymech/xblox/runtime` so browser bundles do not pull vm2.
*/
import { NodeVM } from 'vm2';
import type { RunScriptBlock } from '../blocks/run-script-block.js';
export interface RunScriptVmOptions {
/** Extra globals merged into the vm sandbox (in addition to `ctx` / `args`). */
sandbox?: Record<string, unknown>;
/** vm2 NodeVM options (merged over safe defaults). */
vm?: Partial<import('vm2').NodeVMOptions>;
}
const defaultVmOptions = (): Partial<import('vm2').NodeVMOptions> => ({
console: 'off',
sandbox: {},
require: false,
nesting: false,
wrapper: 'commonjs',
});
/**
* Matches legacy `xblox/model/code/RunScript`: `new Function("{" + script + "}").apply(ctx, args)`.
*/
export function runScriptLegacyVm(
script: string,
ctx: object,
args: unknown[],
options: RunScriptVmOptions = {},
): unknown {
const body = `{${script}}`;
const sandbox = {
ctx,
args,
...(options.sandbox ?? {}),
...(options.vm?.sandbox as Record<string, unknown> | undefined),
};
const vm = new NodeVM({
...defaultVmOptions(),
...options.vm,
sandbox,
});
const code = `
const fn = new Function(${JSON.stringify(body)});
module.exports = fn.apply(ctx, args);
`;
return vm.run(code, 'xblox-run-script.js');
}
export function runRunScriptBlock(
block: RunScriptBlock,
ctx: object,
args: unknown[] = [],
options?: RunScriptVmOptions,
): unknown {
return runScriptLegacyVm(block.method, ctx, args, options);
}
/** Evaluate a single expression with `this` bound to `ctx` (legacy `parseExpression`style). */
export function evalExpression(
expr: string,
ctx: object,
args: unknown[] = [],
options?: RunScriptVmOptions,
): unknown {
return runScriptLegacyVm(`return (${expr});`, ctx, args, options);
}

View File

@ -0,0 +1,131 @@
import { z } from 'zod';
export type ElseIfBlockNode = {
condition: string;
consequent: BlockNode[];
};
/** Legacy `xblox.model.logic.CaseBlock` — one branch inside a switch. */
export type CaseBlockNode = {
kind: 'case';
comparator: string;
expression: string;
consequent: BlockNode[];
};
/** Legacy `xblox.model.logic.DefaultBlock`. */
export type SwitchDefaultNode = {
kind: 'switchDefault';
consequent: BlockNode[];
};
export type SwitchItemNode = CaseBlockNode | SwitchDefaultNode;
export type BlockNode =
| {
kind: 'if';
condition: string;
consequent: BlockNode[];
elseIfBlocks?: ElseIfBlockNode[];
alternate?: BlockNode[];
}
| {
kind: 'runScript';
method: string;
id?: string;
}
/** Legacy `xblox.model.loops.ForBlock` — synchronous numeric loop. */
| {
kind: 'for';
initial: string;
final: string;
comparator: string;
modifier: string;
items: BlockNode[];
ignoreErrors?: boolean;
}
/** Legacy `xblox.model.loops.WhileBlock`. */
| {
kind: 'while';
condition: string;
items: BlockNode[];
loopLimit?: number;
}
/** Legacy `xblox.model.logic.SwitchBlock`. */
| {
kind: 'switch';
variable: string;
items: SwitchItemNode[];
}
/** Legacy `xblox.model.logic.BreakBlock`. */
| {
kind: 'break';
};
export const blockNodeSchema: z.ZodType<BlockNode> = z.lazy(() =>
z.discriminatedUnion('kind', [
z.object({
kind: z.literal('if'),
condition: z.string(),
consequent: z.array(blockNodeSchema),
elseIfBlocks: z
.array(
z.object({
condition: z.string(),
consequent: z.array(blockNodeSchema),
}),
)
.optional(),
alternate: z.array(blockNodeSchema).optional(),
}),
z.object({
kind: z.literal('runScript'),
method: z.string(),
id: z.string().optional(),
}),
z.object({
kind: z.literal('for'),
initial: z.string(),
final: z.string(),
comparator: z.string(),
modifier: z.string(),
items: z.array(blockNodeSchema),
ignoreErrors: z.boolean().optional(),
}),
z.object({
kind: z.literal('while'),
condition: z.string(),
items: z.array(blockNodeSchema),
loopLimit: z.number().int().positive().optional(),
}),
z.object({
kind: z.literal('switch'),
variable: z.string(),
items: z.array(
z.discriminatedUnion('kind', [
z.object({
kind: z.literal('case'),
comparator: z.string(),
expression: z.string(),
consequent: z.array(blockNodeSchema),
}),
z.object({
kind: z.literal('switchDefault'),
consequent: z.array(blockNodeSchema),
}),
]),
),
}),
z.object({
kind: z.literal('break'),
}),
]),
);
export const blocksFileSchema = z.object({
version: z.literal(1),
context: z.record(z.string(), z.unknown()).optional().default({}),
root: blockNodeSchema,
});
export type BlocksFile = z.infer<typeof blocksFileSchema>;

View File

@ -0,0 +1,35 @@
/**
* Portable types for block graphs no DOM / Node imports.
*/
import type { EXECUTION_STATE } from './enums.js';
/** Dojo-SMDstyle port / parameter descriptor (minimal). */
export interface PortDescriptor {
name?: string;
type?: string | string[];
optional?: boolean;
[key: string]: unknown;
}
/** Serialized block instance (subset of legacy `simple.json` and Block model). */
export interface BlockSnapshot {
id: string;
declaredClass?: string;
name?: string;
group?: string;
order?: number;
enabled?: boolean;
serializeMe?: boolean;
parentId?: string | null;
parent?: unknown;
_containsChildrenIds?: string[];
method?: string;
args?: string;
renderBlockIcon?: boolean;
[key: string]: unknown;
}
export interface BlockRuntimeState {
executionState: EXECUTION_STATE;
}

View File

@ -0,0 +1,17 @@
/** RFC4122 v4 — works in modern browsers (`crypto.randomUUID`) and Node 19+. */
function fallbackRandomUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export function randomUUID(): string {
const c = globalThis.crypto;
if (c?.randomUUID) {
return c.randomUUID();
}
return fallbackRandomUUID();
}

View File

@ -0,0 +1,17 @@
{
"version": 1,
"context": { "x": 0 },
"root": {
"kind": "for",
"initial": "0",
"final": "3",
"comparator": "<",
"modifier": "+1",
"items": [
{
"kind": "runScript",
"method": "this.x = (this.x || 0) + 1; return this.x;"
}
]
}
}

View File

@ -0,0 +1,16 @@
{
"version": 1,
"context": { "n": 0 },
"root": {
"kind": "if",
"condition": "this.n > 0",
"consequent": [{ "kind": "runScript", "method": "return 'pos';" }],
"elseIfBlocks": [
{
"condition": "this.n < 0",
"consequent": [{ "kind": "runScript", "method": "return 'neg';" }]
}
],
"alternate": [{ "kind": "runScript", "method": "return 'zero';" }]
}
}

View File

@ -0,0 +1,16 @@
{
"version": 1,
"context": { "n": -3 },
"root": {
"kind": "if",
"condition": "this.n > 0",
"consequent": [{ "kind": "runScript", "method": "return 'pos';" }],
"elseIfBlocks": [
{
"condition": "this.n < 0",
"consequent": [{ "kind": "runScript", "method": "return 'neg';" }]
}
],
"alternate": [{ "kind": "runScript", "method": "return 'zero';" }]
}
}

View File

@ -0,0 +1,26 @@
{
"version": 1,
"context": { "mode": 2 },
"root": {
"kind": "switch",
"variable": "mode",
"items": [
{
"kind": "case",
"comparator": "==",
"expression": "1",
"consequent": [{ "kind": "runScript", "method": "return 'one';" }]
},
{
"kind": "case",
"comparator": "==",
"expression": "2",
"consequent": [{ "kind": "runScript", "method": "return 'two';" }]
},
{
"kind": "switchDefault",
"consequent": [{ "kind": "runScript", "method": "return 'def';" }]
}
]
}
}

View File

@ -0,0 +1,20 @@
{
"version": 1,
"context": { "mode": 99 },
"root": {
"kind": "switch",
"variable": "mode",
"items": [
{
"kind": "case",
"comparator": "==",
"expression": "1",
"consequent": [{ "kind": "runScript", "method": "return 'one';" }]
},
{
"kind": "switchDefault",
"consequent": [{ "kind": "runScript", "method": "return 'def';" }]
}
]
}
}

View File

@ -0,0 +1,14 @@
{
"version": 1,
"context": { "n": 0 },
"root": {
"kind": "while",
"condition": "this.n < 3",
"items": [
{
"kind": "runScript",
"method": "this.n++; return this.n;"
}
]
}
}

View File

@ -0,0 +1,17 @@
/**
* Shared paths and helpers for vitest (import from tests only).
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** Directory of this file (`tests/`). */
export const testsDir = __dirname;
export const fixturesDir = path.join(testsDir, 'fixtures');
export function fixture(...parts: string[]): string {
return path.join(fixturesDir, ...parts);
}

View File

@ -0,0 +1,38 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFile, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { runCommand } from '../../src/cli/run-command.js';
import { fixture } from '../test-commons.js';
describe('pm-xblox run (runCommand)', () => {
let outDir: string;
beforeAll(async () => {
outDir = await mkdtemp(path.join(tmpdir(), 'xblox-cli-'));
});
afterAll(async () => {
await rm(outDir, { recursive: true, force: true });
});
it('runs fixture and returns neg', async () => {
const { result } = await runCommand({
source: fixture('if-else.json'),
loglevel: 'error',
});
expect(result).toBe('neg');
});
it('writes --output JSON', async () => {
const outPath = path.join(outDir, 'out.json');
await runCommand({
source: fixture('if-else-zero.json'),
output: outPath,
loglevel: 'error',
});
const text = await readFile(outPath, 'utf8');
expect(JSON.parse(text)).toEqual({ result: 'zero' });
});
});

View File

@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { executeBlock } from '../../src/engine/execute-block.js';
import { blocksFileSchema } from '../../src/schema/blocks-file.js';
import { readFile } from 'node:fs/promises';
import { fixture } from '../test-commons.js';
describe('if / else-if / alternate engine', () => {
it('evaluates else-if branch (negative n)', async () => {
const raw = JSON.parse(await readFile(fixture('if-else.json'), 'utf8'));
const file = blocksFileSchema.parse(raw);
const ctx = Object.assign(Object.create(null), file.context) as object;
expect(executeBlock(file.root, ctx)).toBe('neg');
});
it('evaluates alternate when no branch matches', async () => {
const raw = JSON.parse(await readFile(fixture('if-else-zero.json'), 'utf8'));
const file = blocksFileSchema.parse(raw);
const ctx = Object.assign(Object.create(null), file.context) as object;
expect(executeBlock(file.root, ctx)).toBe('zero');
});
});

View File

@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { readFile } from 'node:fs/promises';
import { executeBlock } from '../../src/engine/execute-block.js';
import { blocksFileSchema } from '../../src/schema/blocks-file.js';
import { fixture } from '../test-commons.js';
describe('ForBlock (legacy xblox.model.loops.ForBlock)', () => {
it('runs body while counter < final and applies modifier', async () => {
const raw = JSON.parse(await readFile(fixture('for-sum.json'), 'utf8'));
const file = blocksFileSchema.parse(raw);
const ctx = Object.assign(Object.create(null), file.context) as object;
expect(executeBlock(file.root, ctx)).toBe(3);
expect((ctx as { x: number }).x).toBe(3);
});
});
describe('WhileBlock (legacy xblox.model.loops.WhileBlock)', () => {
it('repeats until condition is false (loopLimit)', async () => {
const raw = JSON.parse(await readFile(fixture('while-count.json'), 'utf8'));
const file = blocksFileSchema.parse(raw);
const ctx = Object.assign(Object.create(null), file.context) as object;
expect(executeBlock(file.root, ctx)).toBe(3);
expect((ctx as { n: number }).n).toBe(3);
});
});
describe('SwitchBlock / CaseBlock / DefaultBlock (legacy logic)', () => {
it('runs first matching case', async () => {
const raw = JSON.parse(await readFile(fixture('switch-case.json'), 'utf8'));
const file = blocksFileSchema.parse(raw);
const ctx = Object.assign(Object.create(null), file.context) as object;
expect(executeBlock(file.root, ctx)).toBe('two');
});
it('runs switchDefault when no case matches', async () => {
const raw = JSON.parse(await readFile(fixture('switch-default-only.json'), 'utf8'));
const file = blocksFileSchema.parse(raw);
const ctx = Object.assign(Object.create(null), file.context) as object;
expect(executeBlock(file.root, ctx)).toBe('def');
});
});
describe('BreakBlock', () => {
it('sets switchCtl when executed inside a case consequent', async () => {
const file = blocksFileSchema.parse({
version: 1,
context: { mode: 1 },
root: {
kind: 'switch',
variable: 'mode',
items: [
{
kind: 'case',
comparator: '==',
expression: '1',
consequent: [
{ kind: 'runScript', method: "return 'hit';" },
{ kind: 'break' },
{ kind: 'runScript', method: "return 'after';" },
],
},
{
kind: 'switchDefault',
consequent: [{ kind: 'runScript', method: "return 'def';" }],
},
],
},
});
const ctx = Object.assign(Object.create(null), file.context) as object;
expect(executeBlock(file.root, ctx)).toBe('after');
});
});

View File

@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { RunScriptBlock } from '../../src/blocks/run-script-block.js';
import { runRunScriptBlock, runScriptLegacyVm } from '../../src/runtime/run-script-vm.js';
describe('runScriptLegacyVm', () => {
it('matches legacy Function("{" + script + "}").apply(ctx, args)', () => {
const ctx = { x: 2 };
const out = runScriptLegacyVm('return this.x * arguments[0];', ctx as object, [21]);
expect(out).toBe(42);
});
it('does not let scripts reach real require (sandbox)', () => {
expect(() =>
runScriptLegacyVm('return typeof require;', {}, [], {
vm: { require: false },
}),
).not.toThrow();
const t = runScriptLegacyVm('return typeof require;', {}, [], { vm: { require: false } });
expect(t).toBe('undefined');
});
});
describe('RunScriptBlock + runRunScriptBlock', () => {
it('runs block.method', () => {
const b = new RunScriptBlock({ method: 'return 1 + 1;' });
expect(runRunScriptBlock(b, {})).toBe(2);
});
});

View File

@ -0,0 +1,19 @@
{
"extends": "../typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist-in",
"rootDir": "src",
"baseUrl": ".",
"allowJs": true,
"esModuleInterop": true,
"composite": false,
"importHelpers": false,
"inlineSourceMap": true,
"declaration": true,
"declarationMap": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/**/*.ts'],
},
},
});

View File

@ -0,0 +1,32 @@
import path from 'path';
import { fileURLToPath } from 'url';
import webpack from 'webpack';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
export default {
devtool: false,
plugins: [
new webpack.BannerPlugin({ banner: '#!/usr/bin/env node', raw: true }),
],
entry: './dist-in/main.js',
target: 'node',
mode: 'production',
module: {
rules: [],
},
optimization: {
minimize: false,
// Entry is `export * from './index.js'`; without this, production tree-shaking
// removes the whole graph and emits an empty bundle.
usedExports: false,
},
resolve: {
extensions: ['.js', '.ts'],
},
output: {
filename: 'main_node.js',
path: path.resolve(__dirname, 'dist'),
},
externals: {},
};