├── .github ├── FUNDING.yml └── workflows │ ├── build-push.yml │ ├── ci.yml │ ├── codeql.yml │ ├── commit-linter.yml │ ├── release.yml │ ├── stale.yml │ └── update-docs.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── postinstall.js ├── rete.config.ts ├── src ├── editor.ts ├── index.ts ├── presets │ └── classic.ts ├── scope.ts ├── types.ts ├── utility-types.ts └── utils.ts ├── test ├── index.test.ts ├── mocks │ └── crypto.ts ├── presets │ └── classic.test.ts ├── scope.test.ts └── utils.test.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: ni55an 2 | open_collective: rete 3 | -------------------------------------------------------------------------------- /.github/workflows/build-push.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push 2 | run-name: Build and Push to dist/${{ github.ref_name }} 3 | 4 | on: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | push: 9 | uses: retejs/.github/.github/workflows/build-push.yml@main 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: [ "main", "beta" ] 7 | 8 | jobs: 9 | ci: 10 | uses: retejs/.github/.github/workflows/ci.yml@main 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "main", "beta" ] 7 | pull_request: 8 | branches: [ "main", "beta" ] 9 | 10 | jobs: 11 | codeql: 12 | uses: retejs/.github/.github/workflows/codeql.yml@main 13 | -------------------------------------------------------------------------------- /.github/workflows/commit-linter.yml: -------------------------------------------------------------------------------- 1 | name: Commit linter 2 | 3 | on: 4 | pull_request: 5 | branches: [ "main", "beta" ] 6 | 7 | jobs: 8 | lint: 9 | uses: retejs/.github/.github/workflows/commit-linter.yml@main 10 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "main", "beta" ] 7 | 8 | jobs: 9 | release: 10 | uses: retejs/.github/.github/workflows/release.yml@main 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close stale issues and PRs 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '30 1 * * 0' 7 | 8 | jobs: 9 | stale: 10 | uses: retejs/.github/.github/workflows/stale.yml@main 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /.github/workflows/update-docs.yml: -------------------------------------------------------------------------------- 1 | name: Update docs 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "main" ] 7 | 8 | jobs: 9 | pull: 10 | uses: retejs/.github/.github/workflows/update-docs.yml@main 11 | secrets: inherit 12 | with: 13 | filename: '1.rete' 14 | package: rete 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea/ 3 | .vscode/ 4 | .sonarlint 5 | .scannerwork 6 | .nyc_output 7 | /coverage 8 | npm-debug.log 9 | /dist 10 | docs 11 | .rete-cli 12 | .sonar 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.5](https://github.com/retejs/rete/compare/v2.0.4...v2.0.5) (2024-08-30) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * build ([1f852d9](https://github.com/retejs/rete/commit/1f852d9e491522264d97de396a30d5f0faf2a681)) 7 | 8 | ## [2.0.4](https://github.com/retejs/rete/compare/v2.0.3...v2.0.4) (2024-08-30) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * update cli and fix linting errors ([d219f95](https://github.com/retejs/rete/commit/d219f95cb0d46f79e8d7f5d70e4afcd578f35455)) 14 | 15 | ## [2.0.3](https://github.com/retejs/rete/compare/v2.0.2...v2.0.3) (2024-01-27) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * **build:** source maps ([121775c](https://github.com/retejs/rete/commit/121775c90aac1db449b30284ba996eed1da1a03c)) 21 | 22 | ## [2.0.2](https://github.com/retejs/rete/compare/v2.0.1...v2.0.2) (2023-07-24) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * **editor:** return copy of array in getNodes/getConnections ([369e85e](https://github.com/retejs/rete/commit/369e85e5d661cca5e9de86326c2245c0e2f38d5b)) 28 | 29 | ## v2.0.0-beta.8 30 | 31 | Improve Scope typing: validate signals in `use` method, infer return type in `emit` method 32 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Check out the [Code of Conduct](https://retejs.org/docs/code-of-conduct) 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Check out the [Contribution guide](https://retejs.org/docs/contribution) 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 "Ni55aN" Vitaliy Stoliarov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rete.js 2 | ==== 3 | [![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://stand-with-ukraine.pp.ua) 4 | [![Discord](https://img.shields.io/discord/1081223198055604244?color=%237289da&label=Discord)](https://discord.gg/cxSFkPZdsV) 5 | 6 | **JavaScript framework for visual programming** 7 | 8 | ![rete logo](https://raw.githubusercontent.com/retejs/rete/assets/preview.svg) 9 | 10 | #StandWithUkraine 💙💛 11 | ---- 12 | 13 | #RussiaInvadedUkraine on 24 of February 2022, at 5.00 AM the armed forces of the Russian Federation attacked Ukraine. Please, Stand with Ukraine, stay tuned for updates on Ukraine’s official sources and channels in English and support Ukraine in its fight for freedom and democracy in Europe. 14 | 15 | Help to defend Ukraine — donate to [Ukraine’s main charity fund](https://savelife.in.ua/en/donate/) 16 | 17 | Help to defend Ukraine — donate to the [fund of the National Bank of Ukraine](https://ukraine.ua/news/donate-to-the-nbu-fund/) 18 | 19 | 20 | Introduction [🎥](https://youtu.be/xqPLa6P194A) 21 | ---- 22 | 23 | **Rete.js** is a framework for creating visual interfaces and workflows. It provides out-of-the-box solutions for visualization using various libraries and frameworks, as well as solutions for processing graphs based on dataflow and control flow approaches. 24 | 25 | 26 | Getting started 27 | ---- 28 | 29 | Use [Rete Kit](https://retejs.org/docs/development/rete-kit) to quickly set up a Rete.js application. It lets you select a stack (React.js, Vue.js or Angular, Svelte) and the set of features 30 | 31 | ```bash 32 | npx rete-kit app 33 | ``` 34 | 35 | Alternatively, you can follow the [complete guide](https://retejs.org/docs/getting-started/) 36 | 37 | Documentation 38 | ---- 39 | 40 | - [Introduction](https://retejs.org/docs) 41 | - [Guides](https://retejs.org/docs/guides/basic) 42 | - [Examples](https://retejs.org/examples) 43 | 44 | ## Sponsors 45 | 46 | Thank you to all our sponsors! [Become a sponsor](https://opencollective.com/rete#sponsor) 47 | 48 | 49 | 50 | ## Backers 51 | 52 | Thank you to all our backers! [Become a backer](https://opencollective.com/rete#backer) 53 | 54 | 55 | 56 | 57 | ## Contributors 58 | 59 | This project exists thanks to all the people who contribute. [Contribute](https://retejs.org/docs/contribution). 60 | 61 | 62 | 63 | ## License 64 | 65 | [MIT](https://github.com/retejs/rete/blob/main/LICENSE) 66 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tseslint from 'typescript-eslint'; 2 | import configs from 'rete-cli/configs/eslint.mjs'; 3 | 4 | export default tseslint.config( 5 | ...configs 6 | ) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rete", 3 | "version": "2.0.5", 4 | "description": "JavaScript framework", 5 | "scripts": { 6 | "build": "rete build -c rete.config.ts", 7 | "postinstall": "node postinstall.js", 8 | "doc": "rete doc", 9 | "lint": "rete lint", 10 | "test": "rete test" 11 | }, 12 | "author": "Vitaliy Stoliarov", 13 | "license": "MIT", 14 | "keywords": [ 15 | "dataflow", 16 | "visual programming", 17 | "node editor", 18 | "rete", 19 | "Rete.js" 20 | ], 21 | "homepage": "https://retejs.org", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/retejs/rete.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/retejs/rete/issues" 28 | }, 29 | "devDependencies": { 30 | "jest-environment-jsdom": "^29.1.2", 31 | "rete-cli": "~2.0.2" 32 | }, 33 | "dependencies": { 34 | "@babel/runtime": "^7.21.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /postinstall.js: -------------------------------------------------------------------------------- 1 | function getRectangle(width, height, color) { 2 | const line = new Array(width).fill(' ').join('') 3 | 4 | return new Array(height).fill(color(line)).join('\n') 5 | } 6 | 7 | function drawText(x, y, text) { 8 | const save = '\033[s' 9 | const restore = '\033[u' 10 | const up = n => '\033['+n+'A' 11 | const right = n => '\033['+n+'C' 12 | 13 | return `${save}${up(y)}${right(x)}${text}${restore}` 14 | } 15 | 16 | function black(text) { 17 | return '\x1b[30m' + text + '\x1b[0m' 18 | } 19 | 20 | function white(text) { 21 | return '\x1b[37m' + text + '\x1b[0m' 22 | } 23 | 24 | function bgBlue(text) { 25 | return '\x1b[44m' + text + '\x1b[0m' 26 | } 27 | 28 | function bgYellow(text) { 29 | return '\x1b[43m' + text + '\x1b[0m' 30 | } 31 | 32 | const topText = 'Stand with Ukraine' 33 | const bottomText = 'Please check the Rete.js\'s README for details' 34 | 35 | const top = getRectangle(50, 5, bgBlue) 36 | const bottom = getRectangle(50, 5, bgYellow) 37 | 38 | // eslint-disable-next-line max-len, no-console 39 | console.log(`${top}\n${drawText(16, 3, white(bgBlue(topText)))}${bottom}\n${drawText(2, 3, black(bgYellow(bottomText)))}`) 40 | -------------------------------------------------------------------------------- /rete.config.ts: -------------------------------------------------------------------------------- 1 | import { ReteOptions } from 'rete-cli' 2 | import copy from 'rollup-plugin-copy' 3 | 4 | export default { 5 | input: 'src/index.ts', 6 | name: 'Rete', 7 | globals: { 8 | crypto: 'crypto' 9 | }, 10 | plugins: [ 11 | copy({ 12 | targets: [ 13 | { src: 'postinstall.js', dest: 'dist' } 14 | ] 15 | }) 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/editor.ts: -------------------------------------------------------------------------------- 1 | import { Scope } from './scope' 2 | import { BaseSchemes } from './types' 3 | 4 | /** 5 | * Signal types produced by NodeEditor instance 6 | * @typeParam Scheme - The scheme type 7 | * @priority 10 8 | * @group Primary 9 | */ 10 | export type Root = 11 | | { type: 'nodecreate', data: Scheme['Node'] } 12 | | { type: 'nodecreated', data: Scheme['Node'] } 13 | | { type: 'noderemove', data: Scheme['Node'] } 14 | | { type: 'noderemoved', data: Scheme['Node'] } 15 | | { type: 'connectioncreate', data: Scheme['Connection'] } 16 | | { type: 'connectioncreated', data: Scheme['Connection'] } 17 | | { type: 'connectionremove', data: Scheme['Connection'] } 18 | | { type: 'connectionremoved', data: Scheme['Connection'] } 19 | | { type: 'clear' } 20 | | { type: 'clearcancelled' } 21 | | { type: 'cleared' } 22 | 23 | /** 24 | * The NodeEditor class is the entry class. It is used to create and manage nodes and connections. 25 | * @typeParam Scheme - The scheme type 26 | * @priority 7 27 | * @group Primary 28 | */ 29 | export class NodeEditor extends Scope> { 30 | private nodes: Scheme['Node'][] = [] 31 | private connections: Scheme['Connection'][] = [] 32 | 33 | constructor() { 34 | super('NodeEditor') 35 | } 36 | 37 | /** 38 | * Get a node by id 39 | * @param id - The node id 40 | * @returns The node or undefined 41 | */ 42 | public getNode(id: Scheme['Node']['id']) { 43 | return this.nodes.find(node => node.id === id) 44 | } 45 | 46 | /** 47 | * Get all nodes 48 | * @returns Copy of array with nodes 49 | */ 50 | public getNodes() { 51 | return this.nodes.slice() 52 | } 53 | 54 | /** 55 | * Get all connections 56 | * @returns Copy of array with onnections 57 | */ 58 | public getConnections() { 59 | return this.connections.slice() 60 | } 61 | 62 | /** 63 | * Get a connection by id 64 | * @param id - The connection id 65 | * @returns The connection or undefined 66 | */ 67 | public getConnection(id: Scheme['Connection']['id']) { 68 | return this.connections.find(connection => connection.id === id) 69 | } 70 | 71 | /** 72 | * Add a node 73 | * @param data - The node data 74 | * @returns Whether the node was added 75 | * @throws If the node has already been added 76 | * @emits nodecreate 77 | * @emits nodecreated 78 | */ 79 | async addNode(data: Scheme['Node']) { 80 | if (this.getNode(data.id)) throw new Error('node has already been added') 81 | 82 | if (!await this.emit({ type: 'nodecreate', data })) return false 83 | 84 | this.nodes.push(data) 85 | 86 | await this.emit({ type: 'nodecreated', data }) 87 | return true 88 | } 89 | 90 | /** 91 | * Add a connection 92 | * @param data - The connection data 93 | * @returns Whether the connection was added 94 | * @throws If the connection has already been added 95 | * @emits connectioncreate 96 | * @emits connectioncreated 97 | */ 98 | async addConnection(data: Scheme['Connection']) { 99 | if (this.getConnection(data.id)) throw new Error('connection has already been added') 100 | 101 | if (!await this.emit({ type: 'connectioncreate', data })) return false 102 | 103 | this.connections.push(data) 104 | 105 | await this.emit({ type: 'connectioncreated', data }) 106 | return true 107 | } 108 | 109 | /** 110 | * Remove a node 111 | * @param id - The node id 112 | * @returns Whether the node was removed 113 | * @throws If the node cannot be found 114 | * @emits noderemove 115 | * @emits noderemoved 116 | */ 117 | async removeNode(id: Scheme['Node']['id']) { 118 | const index = this.nodes.findIndex(n => n.id === id) 119 | const node = this.nodes[index] 120 | 121 | if (index < 0) throw new Error('cannot find node') 122 | 123 | if (!await this.emit({ type: 'noderemove', data: node })) return false 124 | 125 | this.nodes.splice(index, 1) 126 | 127 | await this.emit({ type: 'noderemoved', data: node }) 128 | return true 129 | } 130 | 131 | /** 132 | * Remove a connection 133 | * @param id - The connection id 134 | * @returns Whether the connection was removed 135 | * @throws If the connection cannot be found 136 | * @emits connectionremove 137 | * @emits connectionremoved 138 | */ 139 | async removeConnection(id: Scheme['Connection']['id']) { 140 | const index = this.connections.findIndex(n => n.id === id) 141 | const connection = this.connections[index] 142 | 143 | if (index < 0) throw new Error('cannot find connection') 144 | 145 | if (!await this.emit({ type: 'connectionremove', data: connection })) return false 146 | 147 | this.connections.splice(index, 1) 148 | 149 | await this.emit({ type: 'connectionremoved', data: connection }) 150 | return true 151 | } 152 | 153 | /** 154 | * Clear all nodes and connections 155 | * @returns Whether the editor was cleared 156 | * @emits clear 157 | * @emits clearcancelled 158 | * @emits cleared 159 | */ 160 | async clear() { 161 | if (!await this.emit({ type: 'clear' })) { 162 | await this.emit({ type: 'clearcancelled' }) 163 | return false 164 | } 165 | 166 | for (const connection of this.connections.slice()) await this.removeConnection(connection.id) 167 | for (const node of this.nodes.slice()) await this.removeNode(node.id) 168 | 169 | await this.emit({ type: 'cleared' }) 170 | return true 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './editor' 2 | export * as ClassicPreset from './presets/classic' 3 | export type { CanAssignSignal, NestedScope, Pipe, ScopeAsParameter } from './scope' 4 | export { Scope, Signal } from './scope' 5 | export * from './types' 6 | export * from './utils' 7 | -------------------------------------------------------------------------------- /src/presets/classic.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Contains classes for classic scheme such as Node, Input, Output, Control, Socket, Connection 3 | * @module 4 | * @group Primary 5 | */ 6 | 7 | import { ConnectionBase, NodeBase } from '../types' 8 | import { getUID } from '../utils' 9 | 10 | type PortId = string 11 | 12 | /** 13 | * The socket class 14 | * @priority 7 15 | */ 16 | export class Socket { 17 | /** 18 | * @constructor 19 | * @param name Name of the socket 20 | */ 21 | constructor(public name: string) { 22 | 23 | } 24 | } 25 | 26 | /** 27 | * General port class 28 | */ 29 | export class Port { 30 | /** 31 | * Port id, unique string generated by `getUID` function 32 | */ 33 | id: PortId 34 | /** 35 | * Port index, used for sorting ports. Default is `0` 36 | */ 37 | index?: number 38 | 39 | /** 40 | * @constructor 41 | * @param socket Socket instance 42 | * @param label Label of the port 43 | * @param multipleConnections Whether the output port can have multiple connections 44 | */ 45 | constructor(public socket: S, public label?: string, public multipleConnections?: boolean) { 46 | this.id = getUID() 47 | } 48 | } 49 | 50 | /** 51 | * The input port class 52 | * @priority 6 53 | */ 54 | export class Input extends Port { 55 | /** 56 | * Control instance 57 | */ 58 | control: Control | null = null 59 | /** 60 | * Whether the control is visible. Can be managed dynamically by extensions. Default is `true` 61 | */ 62 | showControl = true 63 | 64 | /** 65 | * @constructor 66 | * @param socket Socket instance 67 | * @param label Label of the input port 68 | * @param multipleConnections Whether the output port can have multiple connections. Default is `false` 69 | */ 70 | constructor(public socket: S, public label?: string, public multipleConnections?: boolean) { 71 | super(socket, label, multipleConnections) 72 | } 73 | 74 | /** 75 | * Add control to the input port 76 | * @param control Control instance 77 | */ 78 | addControl(control: Control) { 79 | if (this.control) throw new Error('control already added for this input') 80 | this.control = control 81 | } 82 | 83 | /** 84 | * Remove control from the input port 85 | */ 86 | removeControl() { 87 | this.control = null 88 | } 89 | } 90 | 91 | /** 92 | * The output port class 93 | * @priority 5 94 | */ 95 | export class Output extends Port { 96 | /** 97 | * @constructor 98 | * @param socket Socket instance 99 | * @param label Label of the output port 100 | * @param multipleConnections Whether the output port can have multiple connections. Default is `true` 101 | */ 102 | constructor(socket: S, label?: string, multipleConnections?: boolean) { 103 | super(socket, label, multipleConnections !== false) 104 | } 105 | } 106 | 107 | /** 108 | * General control class 109 | * @priority 5 110 | */ 111 | export class Control { 112 | /** 113 | * Control id, unique string generated by `getUID` function 114 | */ 115 | id: string 116 | /** 117 | * Control index, used for sorting controls. Default is `0` 118 | */ 119 | index?: number 120 | 121 | constructor() { 122 | this.id = getUID() 123 | } 124 | } 125 | 126 | /** 127 | * Input control options 128 | */ 129 | type InputControlOptions = { 130 | /** Whether the control is readonly. Default is `false` */ 131 | readonly?: boolean 132 | /** Initial value of the control */ 133 | initial?: N 134 | /** Callback function that is called when the control value changes */ 135 | change?: (value: N) => void 136 | } 137 | /** 138 | * The input control class 139 | * @example new InputControl('text', { readonly: true, initial: 'hello' }) 140 | */ 141 | export class InputControl extends Control { 142 | value?: N 143 | readonly: boolean 144 | 145 | /** 146 | * @constructor 147 | * @param type Type of the control: `text` or `number` 148 | * @param options Control options 149 | */ 150 | constructor(public type: T, public options?: InputControlOptions) { 151 | super() 152 | this.id = getUID() 153 | this.readonly = options?.readonly ?? false 154 | 155 | if (typeof options?.initial !== 'undefined') this.value = options.initial 156 | } 157 | 158 | /** 159 | * Set control value 160 | * @param value Value to set 161 | */ 162 | setValue(value?: N) { 163 | this.value = value 164 | if (this.options?.change) this.options.change(value!) 165 | } 166 | } 167 | 168 | /** 169 | * The node class 170 | * @priority 10 171 | * @example new Node('math') 172 | */ 173 | export class Node< 174 | Inputs extends { [key in string]?: Socket } = { [key in string]?: Socket }, 175 | Outputs extends { [key in string]?: Socket } = { [key in string]?: Socket }, 176 | Controls extends { [key in string]?: Control } = { [key in string]?: Control } 177 | > implements NodeBase { 178 | /** 179 | * Node id, unique string generated by `getUID` function 180 | */ 181 | id: NodeBase['id'] 182 | /** 183 | * Node inputs 184 | */ 185 | inputs: { [key in keyof Inputs]?: Input> } = {} 186 | /** 187 | * Node outputs 188 | */ 189 | outputs: { [key in keyof Outputs]?: Output> } = {} 190 | /** 191 | * Node controls 192 | */ 193 | controls: Controls = {} as Controls 194 | /** 195 | * Whether the node is selected. Default is `false` 196 | */ 197 | selected?: boolean 198 | 199 | constructor(public label: string) { 200 | this.id = getUID() 201 | } 202 | 203 | hasInput(key: K) { 204 | return Object.prototype.hasOwnProperty.call(this.inputs, key) 205 | } 206 | 207 | addInput(key: K, input: Input>) { 208 | if (this.hasInput(key)) throw new Error(`input with key '${String(key)}' already added`) 209 | 210 | Object.defineProperty(this.inputs, key, { value: input, enumerable: true, configurable: true }) 211 | } 212 | 213 | removeInput(key: keyof Inputs) { 214 | delete this.inputs[key] 215 | } 216 | 217 | hasOutput(key: K) { 218 | return Object.prototype.hasOwnProperty.call(this.outputs, key) 219 | } 220 | 221 | addOutput(key: K, output: Output>) { 222 | if (this.hasOutput(key)) throw new Error(`output with key '${String(key)}' already added`) 223 | 224 | Object.defineProperty(this.outputs, key, { value: output, enumerable: true, configurable: true }) 225 | } 226 | 227 | removeOutput(key: keyof Outputs) { 228 | delete this.outputs[key] 229 | } 230 | 231 | hasControl(key: K) { 232 | return Object.prototype.hasOwnProperty.call(this.controls, key) 233 | } 234 | 235 | addControl(key: K, control: Controls[K]) { 236 | if (this.hasControl(key)) throw new Error(`control with key '${String(key)}' already added`) 237 | 238 | Object.defineProperty(this.controls, key, { value: control, enumerable: true, configurable: true }) 239 | } 240 | 241 | removeControl(key: keyof Controls) { 242 | delete this.controls[key] 243 | } 244 | } 245 | 246 | /** 247 | * The connection class 248 | * @priority 9 249 | */ 250 | export class Connection< 251 | Source extends Node, 252 | Target extends Node 253 | > implements ConnectionBase { 254 | /** 255 | * Connection id, unique string generated by `getUID` function 256 | */ 257 | id: ConnectionBase['id'] 258 | /** 259 | * Source node id 260 | */ 261 | source: NodeBase['id'] 262 | /** 263 | * Target node id 264 | */ 265 | target: NodeBase['id'] 266 | 267 | /** 268 | * @constructor 269 | * @param source Source node instance 270 | * @param sourceOutput Source node output key 271 | * @param target Target node instance 272 | * @param targetInput Target node input key 273 | */ 274 | constructor( 275 | source: Source, 276 | public sourceOutput: keyof Source['outputs'], 277 | target: Target, 278 | public targetInput: keyof Target['inputs'] 279 | ) { 280 | if (!source.outputs[sourceOutput as string]) { 281 | throw new Error(`source node doesn't have output with a key ${String(sourceOutput)}`) 282 | } 283 | if (!target.inputs[targetInput as string]) { 284 | throw new Error(`target node doesn't have input with a key ${String(targetInput)}`) 285 | } 286 | 287 | this.id = getUID() 288 | this.source = source.id 289 | this.target = target.id 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/scope.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/naming-convention */ 3 | import { 4 | AcceptPartialUnion, CanAssignSignal, GetAssignmentReferences, GetNonAssignableElements, Tail 5 | } from './utility-types' 6 | 7 | export type { CanAssignSignal } 8 | 9 | /** 10 | * A middleware type that can modify the data 11 | * @typeParam T - The data type 12 | * @param data - The data to be modified 13 | * @returns The modified data or undefined 14 | * @example (data) => data + 1 15 | * @example (data) => undefined // will stop the execution 16 | * @internal 17 | */ 18 | export type Pipe = (data: T) => Promise | undefined | T 19 | 20 | export type CanAssignEach = D extends [infer H1, ...infer Tail1] 21 | ? ( 22 | F extends [infer H2, ...infer Tail2] ? 23 | [CanAssignSignal, ...CanAssignEach] 24 | : [] 25 | ) : [] 26 | 27 | export type ScopeAsParameter, Current extends any[]> = (CanAssignEach<[S['__scope']['produces'], ...S['__scope']['parents']], Current>[number] extends true 28 | ? S 29 | : 'Argument Scope does not provide expected signals' 30 | ) 31 | 32 | /** 33 | * Validate the Scope signals and replace the parameter type with an error message if they are not assignable 34 | * @internal 35 | */ 36 | export type NestedScope, Current extends any[]> = (CanAssignEach[number] extends true 37 | ? S 38 | : 'Parent signals do not satisfy the connected scope. Please use `.debug($ => $) for detailed assignment error' 39 | ) 40 | 41 | /** 42 | * Provides 'debug' method to check the detailed assignment error message 43 | * @example .debug($ => $) 44 | * @internal 45 | */ 46 | export function useHelper, Signals>() { 47 | type T1 = S['__scope']['parents'][number] 48 | return { 49 | debug>(_f: (p: GetAssignmentReferences) => T) { 50 | /* placeholder */ 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * A signal is a middleware chain that can be used to modify the data 57 | * @typeParam T - The data type 58 | * @internal 59 | */ 60 | export class Signal { 61 | pipes: Pipe[] = [] 62 | 63 | addPipe(pipe: Pipe) { 64 | this.pipes.push(pipe) 65 | } 66 | 67 | async emit(context: Context): Promise { 68 | let current: Context | undefined = context 69 | 70 | for (const pipe of this.pipes) { 71 | current = await pipe(current) as Context 72 | 73 | if (typeof current === 'undefined') return 74 | } 75 | return current 76 | } 77 | } 78 | 79 | type Type = (new(...args: any[]) => T) | (abstract new (...args: any[]) => T) 80 | 81 | /** 82 | * Base class for all plugins and the core. Provides a signals mechanism to modify the data 83 | */ 84 | export class Scope { 85 | signal = new Signal>() 86 | parent?: any // Parents['length'] extends 0 ? undefined : Scope> 87 | __scope!: { 88 | produces: Produces 89 | parents: Parents 90 | } 91 | 92 | constructor(public name: string) { } 93 | 94 | addPipe(middleware: Pipe) { 95 | this.signal.addPipe(middleware) 96 | } 97 | 98 | use>(scope: NestedScope) { 99 | if (!(scope instanceof Scope)) throw new Error('cannot use non-Scope instance') 100 | 101 | scope.setParent(this) 102 | this.addPipe(context => { 103 | return scope.signal.emit(context) 104 | }) 105 | 106 | return useHelper() 107 | } 108 | 109 | setParent(scope: Scope>) { 110 | this.parent = scope 111 | } 112 | 113 | emit(context: C): Promise | undefined> { 114 | return this.signal.emit(context) as Promise> 115 | } 116 | 117 | hasParent(): boolean { 118 | return Boolean(this.parent) 119 | } 120 | 121 | parentScope>(): Scope 122 | parentScope(type: Type): T 123 | parentScope(type?: Type): T { 124 | if (!this.parent) throw new Error('cannot find parent') 125 | if (type && this.parent instanceof type) return this.parent 126 | if (type) throw new Error('actual parent is not instance of type') 127 | return this.parent 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Node id type 3 | */ 4 | export type NodeId = string 5 | /** 6 | * Connection id type 7 | * @group Primary 8 | */ 9 | export type ConnectionId = string 10 | 11 | /** 12 | * The base node type 13 | * @group Primary 14 | */ 15 | export type NodeBase = { id: NodeId } 16 | /** 17 | * The base connection type 18 | * @group Primary 19 | */ 20 | export type ConnectionBase = { id: ConnectionId, source: NodeId, target: NodeId } 21 | 22 | /** 23 | * Get the schemes 24 | * @example GetSchemes 25 | * @group Primary 26 | */ 27 | export type GetSchemes = { Node: NodeData, Connection: ConnectionData } 28 | 29 | /** 30 | * The base schemes 31 | * @group Primary 32 | */ 33 | export type BaseSchemes = GetSchemes 34 | -------------------------------------------------------------------------------- /src/utility-types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents 4 | export type AcceptPartialUnion = T | any 5 | 6 | export type Tail = ((...args: T) => void) extends (head: any, ...tail: infer U) => any ? U : never 7 | 8 | export type UnionToIntersection = ( 9 | U extends never ? never : (arg: U) => never 10 | ) extends (arg: infer I) => void 11 | ? I 12 | : never 13 | 14 | type StrictExcludeInner = 0 extends ( 15 | U extends T ? [T] extends [U] ? 0 : never : never 16 | ) ? never : T 17 | export type StrictExclude = T extends unknown ? StrictExcludeInner : never 18 | 19 | export type UnionToTuple = UnionToIntersection< 20 | T extends never ? never : (t: T) => T 21 | > extends (_: never) => infer W 22 | ? [...UnionToTuple>, W] 23 | : [] 24 | 25 | export type FilterMatch = T extends [infer Head, ...infer _Tail] 26 | ? ([Head] extends [V] 27 | ? [Head, ...FilterMatch<_Tail, V>] 28 | : FilterMatch<_Tail, V> 29 | ) : [] 30 | 31 | export type CanAssignToAnyOf = FilterMatch, Requires> extends [] ? false : true 32 | 33 | export type CanAssignEachTupleElemmentToAnyOf = Requires extends [infer Head, ...infer _Tail] 34 | ? CanAssignToAnyOf extends true ? 35 | (_Tail extends [] 36 | ? true 37 | : CanAssignEachTupleElemmentToAnyOf 38 | ) : false 39 | : false 40 | 41 | export type CanAssignEachToAnyOf = CanAssignEachTupleElemmentToAnyOf> 42 | 43 | export type CanAssignSignal = CanAssignEachToAnyOf 44 | 45 | type ReplaceTupleTypes = { [K in keyof T]: U } 46 | export type FilterNever = T extends [infer Head, ...infer _Tail] 47 | ? ([Head] extends [never] ? FilterNever<_Tail> : [Head, ...FilterNever<_Tail>]) 48 | : [] 49 | 50 | type KeepIfNonAssignable = CanAssignToAnyOf extends false ? T : never 51 | 52 | export type GetAllNonValidElements = T extends [infer Head, ...infer _Tail] 53 | ? ([KeepIfNonAssignable, ...GetAllNonValidElements<_Tail, Signals>]) 54 | : [] 55 | 56 | export type GetNonAssignableElements 57 | = FilterNever, Signals>> 58 | export type GetAssignmentReferences = ReplaceTupleTypes 59 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | const crypto = globalThis.crypto as (typeof globalThis.crypto | typeof import('node:crypto')) 2 | 3 | /** 4 | * @returns A unique id 5 | */ 6 | export function getUID(): string { 7 | if ('randomBytes' in crypto) { 8 | return crypto.randomBytes(8).toString('hex') 9 | } 10 | 11 | const bytes = crypto.getRandomValues(new Uint8Array(8)) 12 | const array = Array.from(bytes) 13 | const hexPairs = array.map(b => b.toString(16).padStart(2, '0')) 14 | 15 | return hexPairs.join('') 16 | } 17 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from '@jest/globals' 2 | 3 | import { NodeEditor } from '../src/editor' 4 | 5 | describe('NodeEditor', () => { 6 | it('NodeEditor is instantiable', () => { 7 | expect(new NodeEditor()).toBeInstanceOf(NodeEditor) 8 | }) 9 | 10 | it('addNode should add a node', async () => { 11 | const editor = new NodeEditor() 12 | const nodeData = { id: '1', label: 'Node 1' } 13 | const result = await editor.addNode(nodeData) 14 | const nodes = editor.getNodes() 15 | 16 | expect(result).toBe(true) 17 | expect(nodes).toHaveLength(1) 18 | expect(nodes[0]).toEqual(nodeData) 19 | }) 20 | 21 | it('addNode should not add a node with duplicate id', async () => { 22 | const editor = new NodeEditor() 23 | const nodeData = { id: '1', label: 'Node 1' } 24 | 25 | await editor.addNode(nodeData) 26 | 27 | await expect(() => editor.addNode(nodeData)).rejects.toThrowError() 28 | }) 29 | 30 | it('addConnection should add a connection', async () => { 31 | const editor = new NodeEditor() 32 | const connectionData = { id: '1', source: '1', target: '2' } 33 | 34 | await editor.addNode({ id: '1' }) 35 | await editor.addNode({ id: '2' }) 36 | const result = await editor.addConnection(connectionData) 37 | const connections = editor.getConnections() 38 | 39 | expect(result).toBe(true) 40 | expect(connections).toHaveLength(1) 41 | expect(connections[0]).toEqual(connectionData) 42 | }) 43 | 44 | it('addConnection should not add a connection with duplicate id', async () => { 45 | const editor = new NodeEditor() 46 | const connectionData = { id: '1', source: '1', target: '2' } 47 | 48 | await editor.addNode({ id: '1' }) 49 | await editor.addNode({ id: '2' }) 50 | await editor.addConnection(connectionData) 51 | 52 | await expect(() => editor.addConnection(connectionData)).rejects.toThrowError() 53 | }) 54 | 55 | it('removeNode should remove a node', async () => { 56 | const editor = new NodeEditor() 57 | const nodeData = { id: '1', label: 'Node 1' } 58 | 59 | await editor.addNode(nodeData) 60 | await editor.removeNode('1') 61 | const nodes = editor.getNodes() 62 | 63 | expect(nodes).toHaveLength(0) 64 | }) 65 | 66 | it('removeConnection should remove a connection', async () => { 67 | const editor = new NodeEditor() 68 | const connectionData = { id: '1', source: '1', target: '2' } 69 | 70 | await editor.addNode({ id: '1' }) 71 | await editor.addNode({ id: '2' }) 72 | await editor.addConnection(connectionData) 73 | await editor.removeConnection('1') 74 | const connections = editor.getConnections() 75 | 76 | expect(connections).toHaveLength(0) 77 | }) 78 | 79 | it('should clear all nodes and connections', async () => { 80 | const editor = new NodeEditor() 81 | 82 | await editor.addNode({ id: '1' }) 83 | await editor.addNode({ id: '2' }) 84 | await editor.addConnection({ id: '1', source: '1', target: '2' }) 85 | await editor.clear() 86 | const nodes = editor.getNodes() 87 | const connections = editor.getConnections() 88 | 89 | expect(nodes).toHaveLength(0) 90 | expect(connections).toHaveLength(0) 91 | }) 92 | }) 93 | 94 | -------------------------------------------------------------------------------- /test/mocks/crypto.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import { Buffer } from 'buffer' 3 | 4 | export function mockCrypto(object: Record) { 5 | // eslint-disable-next-line no-undef 6 | Object.defineProperty(globalThis, 'crypto', { 7 | value: object, 8 | writable: true 9 | }) 10 | } 11 | 12 | export function mockCryptoFromArray(array: Uint8Array) { 13 | mockCrypto({ 14 | getRandomValues: jest.fn().mockReturnValue(array) 15 | }) 16 | } 17 | 18 | export function mockCryptoFromBuffer(buffer: Buffer) { 19 | mockCrypto({ 20 | randomBytes: jest.fn().mockReturnValue(buffer) 21 | }) 22 | } 23 | 24 | export function resetCrypto() { 25 | // eslint-disable-next-line no-undef 26 | Object.defineProperty(globalThis, 'crypto', { 27 | // eslint-disable-next-line no-undefined 28 | value: undefined, 29 | writable: true 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /test/presets/classic.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from '@jest/globals' 2 | 3 | import { mockCryptoFromArray, resetCrypto } from '../mocks/crypto' 4 | 5 | describe('ClassicPreset', () => { 6 | // eslint-disable-next-line init-declarations 7 | let preset!: typeof import('../../src/presets/classic') 8 | 9 | beforeEach(async () => { 10 | mockCryptoFromArray(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])) 11 | preset = await import('../../src/presets/classic') 12 | }) 13 | 14 | afterEach(() => { 15 | resetCrypto() 16 | }) 17 | 18 | describe('Node', () => { 19 | it('is instantiable', () => { 20 | expect(new preset.Node('A')).toBeInstanceOf(preset.Node) 21 | }) 22 | 23 | it('should have an id', () => { 24 | const node = new preset.Node('A') 25 | 26 | expect(node.id).toBeDefined() 27 | }) 28 | 29 | it('should have a label', () => { 30 | const node = new preset.Node('A') 31 | 32 | expect(node.label).toBe('A') 33 | }) 34 | 35 | it('adds Input', () => { 36 | const node = new preset.Node('A') 37 | const input = new preset.Input(new preset.Socket('a')) 38 | 39 | node.addInput('a', input) 40 | 41 | expect(node.hasInput('a')).toBeTruthy() 42 | expect(node.inputs.a).toBe(input) 43 | }) 44 | 45 | it('throws error if Input already exists', () => { 46 | const node = new preset.Node('A') 47 | 48 | node.addInput('a', new preset.Input(new preset.Socket('a'))) 49 | 50 | expect(() => node.addInput('a', new preset.Input(new preset.Socket('a')))).toThrow() 51 | }) 52 | 53 | it('removes Input', () => { 54 | const node = new preset.Node('A') 55 | 56 | node.addInput('a', new preset.Input(new preset.Socket('a'))) 57 | node.removeInput('a') 58 | 59 | expect(node.hasInput('a')).toBeFalsy() 60 | }) 61 | 62 | it('adds Output', () => { 63 | const node = new preset.Node('A') 64 | const output = new preset.Output(new preset.Socket('a')) 65 | 66 | node.addOutput('a', output) 67 | 68 | expect(node.hasOutput('a')).toBeTruthy() 69 | expect(node.outputs.a).toBe(output) 70 | }) 71 | 72 | it('throws error if Output already exists', () => { 73 | const node = new preset.Node('A') 74 | 75 | node.addOutput('a', new preset.Output(new preset.Socket('a'))) 76 | 77 | expect(() => node.addOutput('a', new preset.Output(new preset.Socket('a')))).toThrow() 78 | }) 79 | 80 | it('removes Output', () => { 81 | const node = new preset.Node('A') 82 | 83 | node.addOutput('a', new preset.Output(new preset.Socket('a'))) 84 | node.removeOutput('a') 85 | 86 | expect(node.hasOutput('a')).toBeFalsy() 87 | }) 88 | }) 89 | 90 | describe('Connection', () => { 91 | it('Connection throws error if input not found', () => { 92 | const a = new preset.Node('A') 93 | const b = new preset.Node('B') 94 | 95 | a.addOutput('a', new preset.Output(new preset.Socket('a'))) 96 | 97 | expect(() => new preset.Connection(a, 'a', b, 'b')).toThrow() 98 | }) 99 | 100 | it('Connection throws error if output not found', () => { 101 | const a = new preset.Node('A') 102 | const b = new preset.Node('B') 103 | 104 | b.addInput('b', new preset.Input(new preset.Socket('b'))) 105 | 106 | expect(() => new preset.Connection(a, 'a', b, 'b')).toThrow() 107 | }) 108 | 109 | it('Connection is instantiable', () => { 110 | const a = new preset.Node('A') 111 | const b = new preset.Node('B') 112 | const output = new preset.Output(new preset.Socket('b')) 113 | const input = new preset.Input(new preset.Socket('a')) 114 | 115 | a.addOutput('a', output) 116 | b.addInput('b', input) 117 | 118 | expect(new preset.Connection(a, 'a', b, 'b')).toBeInstanceOf(preset.Connection) 119 | }) 120 | }) 121 | 122 | describe('Control', () => { 123 | it('adds Control to Node', () => { 124 | const node = new preset.Node('A') 125 | 126 | node.addControl('ctrl', new preset.Control()) 127 | 128 | expect(node.hasControl('ctrl')).toBeTruthy() 129 | }) 130 | 131 | it('throws error if Control already exists', () => { 132 | const node = new preset.Node('A') 133 | 134 | node.addControl('ctrl', new preset.Control()) 135 | 136 | expect(() => node.addControl('ctrl', new preset.Control())).toThrow() 137 | }) 138 | 139 | it('removes Control from Node', () => { 140 | const node = new preset.Node('A') 141 | 142 | node.addControl('ctrl', new preset.Control()) 143 | node.removeControl('ctrl') 144 | 145 | expect(node.hasControl('ctrl')).toBeFalsy() 146 | }) 147 | 148 | it('adds Control to Input', () => { 149 | const input = new preset.Input(new preset.Socket('a')) 150 | 151 | input.addControl(new preset.Control()) 152 | 153 | expect(input.control).toBeTruthy() 154 | }) 155 | 156 | it('throws error if Control in Input already exists', () => { 157 | const input = new preset.Input(new preset.Socket('a')) 158 | 159 | input.addControl(new preset.Control()) 160 | 161 | expect(() => input.addControl(new preset.Control())).toThrow() 162 | }) 163 | 164 | it('removes Control from Input', () => { 165 | const input = new preset.Input(new preset.Socket('a')) 166 | 167 | input.addControl(new preset.Control()) 168 | input.removeControl() 169 | 170 | expect(input.control).toBeFalsy() 171 | }) 172 | }) 173 | }) 174 | -------------------------------------------------------------------------------- /test/scope.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, jest } from '@jest/globals' 2 | 3 | import { Scope } from '../src/scope' 4 | 5 | type Parent = { parent: string } 6 | type Child = { child: number } 7 | 8 | describe('Scope', () => { 9 | it('should create a new Scope instance', () => { 10 | const scope = new Scope('test') 11 | 12 | expect(scope).toBeInstanceOf(Scope) 13 | }) 14 | 15 | it('doesnt have a parent by default', () => { 16 | const scope = new Scope('test') 17 | 18 | expect(scope.hasParent()).toBeFalsy() 19 | }) 20 | 21 | describe('parent-child', () => { 22 | it('should set a parent scope', () => { 23 | const parent = new Scope('parent') 24 | const child = new Scope('child') 25 | 26 | child.setParent(parent) 27 | 28 | expect(child.parentScope()).toBe(parent) 29 | }) 30 | 31 | it('should use a nested scope', () => { 32 | const parent = new Scope('parent') 33 | const child = new Scope('child') 34 | 35 | parent.use(child) 36 | expect(child.hasParent()).toBeTruthy() 37 | expect(child.parentScope()).toBe(parent) 38 | }) 39 | 40 | it('should throw an error when using a non-Scope instance', () => { 41 | const parent = new Scope('parent') 42 | const child = { signal: { emit: jest.fn() } } 43 | 44 | expect(() => parent.use(child as any)).toThrowError('cannot use non-Scope instance') 45 | }) 46 | 47 | it('should throw an error when trying to access a parent without one', () => { 48 | const scope = new Scope('test') 49 | 50 | expect(() => scope.parentScope()).toThrowError('cannot find parent') 51 | }) 52 | 53 | it('should throw an error when trying to access a parent with the wrong type', () => { 54 | class WrongScope extends Scope { } 55 | const parent = new Scope('parent') 56 | const child = new Scope('child') 57 | 58 | parent.use(child) 59 | 60 | expect(() => child.parentScope(WrongScope)).toThrowError('actual parent is not instance of type') 61 | }) 62 | }) 63 | 64 | describe('addPipe', () => { 65 | it('should emit a signal', async () => { 66 | const scope = new Scope('test') 67 | const pipe = jest.fn<() => Parent>() 68 | 69 | scope.addPipe(pipe) 70 | await scope.emit({ parent: 'test' }) 71 | 72 | expect(pipe).toHaveBeenCalledWith({ parent: 'test' }) 73 | }) 74 | 75 | it('should return a promise from emit', () => { 76 | const scope = new Scope('test') 77 | const signal = jest.fn<() => Parent>() 78 | 79 | scope.addPipe(signal) 80 | const result = scope.emit({ parent: 'test' }) 81 | 82 | expect(result).toBeInstanceOf(Promise) 83 | }) 84 | 85 | it('should return the result of the signal', async () => { 86 | const scope = new Scope('test') 87 | const signal = jest.fn<() => Parent>().mockReturnValue({ parent: 'test-result' }) 88 | 89 | scope.addPipe(signal) 90 | const result = await scope.emit({ parent: 'test' }) 91 | 92 | expect(result).toEqual({ parent: 'test-result' }) 93 | }) 94 | 95 | it('should return undefined if the signal returns undefined', async () => { 96 | const scope = new Scope('test') 97 | // eslint-disable-next-line no-undefined 98 | const signal = jest.fn().mockReturnValue(undefined) 99 | 100 | scope.addPipe(signal) 101 | const result = await scope.emit('test') 102 | 103 | expect(result).toBeUndefined() 104 | }) 105 | 106 | it('should return the result of the signal with a parent', async () => { 107 | const parent = new Scope('parent') 108 | const child = new Scope('child') 109 | const signal = jest.fn<() => Parent>().mockReturnValue({ parent: 'test-parent' }) 110 | 111 | parent.addPipe(signal) 112 | parent.use(child) 113 | const result = await child.emit({ child: 1 }) 114 | 115 | expect(result).toEqual({ child: 1 }) 116 | }) 117 | 118 | it('should return the result of the signal with a parent and child', async () => { 119 | const parent = new Scope('parent') 120 | const child = new Scope('child') 121 | const signal = jest.fn<() => Child>().mockReturnValue({ child: 1 }) 122 | 123 | parent.use(child) 124 | child.addPipe(signal) 125 | const result = await child.emit({ child: 2 }) 126 | 127 | expect(result).toEqual({ child: 1 }) 128 | }) 129 | 130 | it('should transfer signals from parent to child', async () => { 131 | const parent = new Scope('parent') 132 | const child = new Scope('child') 133 | const parentSignal = jest.fn<() => Parent>().mockReturnValue({ parent: 'test-parent' }) 134 | const childSignal = jest.fn<() => Child>() 135 | 136 | parent.addPipe(parentSignal) 137 | child.addPipe(childSignal) 138 | parent.use(child) 139 | 140 | await parent.emit({ parent: 'test-parent' }) 141 | 142 | expect(childSignal).toHaveBeenCalledWith({ parent: 'test-parent' }) 143 | }) 144 | 145 | it('should prevent execution of child signal if parent signal returns undefined', async () => { 146 | const parent = new Scope('parent') 147 | const child = new Scope('child') 148 | // eslint-disable-next-line no-undefined 149 | const parentSignal = jest.fn<() => Parent | undefined>().mockReturnValue(undefined) 150 | const childSignal = jest.fn<() => Child>() 151 | 152 | parent.addPipe(parentSignal) 153 | child.addPipe(childSignal) 154 | parent.use(child) 155 | 156 | await parent.emit({ parent: 'test-parent' }) 157 | 158 | expect(childSignal).not.toHaveBeenCalled() 159 | }) 160 | }) 161 | }) 162 | 163 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' 2 | import { Buffer } from 'buffer' 3 | 4 | import { mockCryptoFromArray, mockCryptoFromBuffer, resetCrypto } from './mocks/crypto' 5 | 6 | describe('getUID', () => { 7 | beforeEach(() => { 8 | jest.resetModules() 9 | }) 10 | 11 | afterEach(() => { 12 | resetCrypto() 13 | }) 14 | 15 | it('should return a unique id based on crypto.getRandomValues', async () => { 16 | mockCryptoFromArray(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])) 17 | 18 | const { getUID } = await import('../src/utils') 19 | const uid = getUID() 20 | 21 | expect(uid).toHaveLength(16) 22 | }) 23 | 24 | it('should return a unique id based on crypto.randomBytes', async () => { 25 | mockCryptoFromBuffer(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8])) 26 | 27 | const { getUID } = await import('../src/utils') 28 | const uid = getUID() 29 | 30 | expect(uid).toHaveLength(16) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "rete-cli/configs/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "downlevelIteration": true, 6 | "isolatedModules": false, 7 | "lib": [] 8 | }, 9 | "include": ["src", "test"] 10 | } 11 | --------------------------------------------------------------------------------