├── .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 | [](https://stand-with-ukraine.pp.ua)
4 | [](https://discord.gg/cxSFkPZdsV)
5 |
6 | **JavaScript framework for visual programming**
7 |
8 | 
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 |
--------------------------------------------------------------------------------