diff --git a/packages/fbp/.dockerignore b/packages/fbp/.dockerignore new file mode 100644 index 000000000..e57036f4f --- /dev/null +++ b/packages/fbp/.dockerignore @@ -0,0 +1,4 @@ +dist +docs +jest-coverage +node_modules \ No newline at end of file diff --git a/packages/fbp/.eslintignore b/packages/fbp/.eslintignore new file mode 100644 index 000000000..d65961a57 --- /dev/null +++ b/packages/fbp/.eslintignore @@ -0,0 +1,9 @@ +dist/** +coverage/** +storybook-static/** +docs +site +cypress +cli +.rollup.cache +.turbo \ No newline at end of file diff --git a/packages/fbp/.eslintrc.cjs b/packages/fbp/.eslintrc.cjs new file mode 100644 index 000000000..685937d28 --- /dev/null +++ b/packages/fbp/.eslintrc.cjs @@ -0,0 +1,9 @@ +/* eslint-disable no-undef */ +/** @type {import("eslint").Linter.Config} */ + +module.exports = { + root:true, + extends: [require.resolve('@tokens-studio/eslint-custom-config/index.js')], + rules:{ + } +}; \ No newline at end of file diff --git a/packages/fbp/.prettierignore b/packages/fbp/.prettierignore new file mode 100644 index 000000000..5a8394163 --- /dev/null +++ b/packages/fbp/.prettierignore @@ -0,0 +1,10 @@ +package.json +package-lock.json +dist +coverage +docs +jest-coverage +site +cli +.turbo +.rollup.cache \ No newline at end of file diff --git a/packages/fbp/CHANGELOG.md b/packages/fbp/CHANGELOG.md new file mode 100644 index 000000000..c14f852dd --- /dev/null +++ b/packages/fbp/CHANGELOG.md @@ -0,0 +1,246 @@ +# @tokens-studio/graph-engine + +## 0.17.4 + +### Patch Changes + +- 1160ca8: Fixed an issue with the dropPanel height overflow. Fixed the resolve Tokens node to properly adhere to the existing typography and boxshadow structure + +## 0.17.3 + +### Patch Changes + +- 08de54a: Fixed Remap Node and updated usability + +## 0.17.2 + +### Patch Changes + +- ba4bb9c: Update miniizeFlowGraph to be quiet if disconnected edges are detected + +## 0.17.1 + +### Patch Changes + +- 473c0a3: Update miniizeFlowGraph to be quiet if disconnected edges are detected +- e068662: Remove console.log message from join string + +## 0.17.0 + +### Minor Changes + +- 248400c: Added new set color value node, added color as constant input type, added new array name node for incremental naming +- 6e58e27: Add CSS function node + +### Patch Changes + +- 69ea7b7: extract functions and presort items + +## 0.16.0 + +### Minor Changes + +- 22c6c01: Add Array Pass Unit Node +- 990b5a6: Update Series nodes to be more aligned +- 1c8fa1c: Added precision to arithmetic, geometric and harmonic series + +## 0.15.3 + +### Patch Changes + +- be2fe56: Bump engine for latest changes + +## 0.15.2 + +### Patch Changes + +- 1270113: Fix error on empty state for contrast node + +## 0.15.1 + +### Patch Changes + +- f52e0d7: Removed apca-w3 and replaced it with colorjs.io + +## 0.15.0 + +### Minor Changes + +- 612bc38: Exposes an extra output in token sets to allow users to interact with the set as an object + +### Patch Changes + +- 612bc38: Fixes how tokens are exposed in the token set to follow scope naming + +## 0.14.0 + +### Minor Changes + +- bd2346b: Exposes an extra output in token sets to allow users to interact with the set as an object + +## 0.13.1 + +### Patch Changes + +- cbc7ab3: Fixes the extract single token node which was never extracting the token + +## 0.13.0 + +### Minor Changes + +- c0fcbd6: Add Node Nearest to Color +- 4296c47: Add color name node +- bf4f5a1: Add regex support to select tokens node +- 53b29d3: Add split string node + +## 0.12.0 + +### Minor Changes + +- a7baf6d: add ungroup node, add select single token node, fix input issue on group and extract tokens + +### Patch Changes + +- 91da25d: Fix an issue with ESM loading not working correctly. Converts the input of the extract Tokens and Extract Single Token Node to use Regex + +## 0.11.0 + +### Minor Changes + +- de6a6f0: add ungroup node, add select single token node, fix input issue on group and extract tokens + +### Patch Changes + +- de6a6f0: Fix an issue with ESM loading not working correctly. Converts the input of the extract Tokens and Extract Single Token Node to use Regex + +## 0.10.0 + +### Minor Changes + +- eefa966: add ungroup node, add select single token node, fix input issue on group and extract tokens +- eaf05cd: Add Contrasting from Set node to return the first element of an array that has sufficient contrast + +## 0.9.0 + +### Minor Changes + +- ca1ed6d: Adds in a group and extract node for set manipulation + +### Patch Changes + +- ca1ed6d: Fix an issue with the editor where clicking on edges did not work as expected. Fixed an issue where the code was not being set correctly during the load of the initial graph + +## 0.8.0 + +### Minor Changes + +- d2096c1: Adds in a group and extract node for set manipulation + +## 0.7.0 + +### Minor Changes + +- 3a38bfe: Adds a base font node based on german DIN 1450 and calculates the min required font size for readability +- ed80a0b: Add a sort array node +- 4e19200: Adds a string join node + +### Patch Changes + +- e04601d: Trig should throw an error +- ab797ce: Fixed an issue with the basefont node + +## 0.6.1 + +### Patch Changes + +- f077533: Fixed input validation for tokenset input + +## 0.6.0 + +### Minor Changes + +- 732f6ee: - Adds a parse Unit node. + - Adds `align-items` to the exposed UI + - Adds native supports for tokenSets in input + - Adds Json node (alpha) +- 732f6ee: Added new nodes for array concatenation and css box models + +## 0.5.0 + +### Minor Changes + +- 7644d05: - Adds a parse Unit node. + - Adds `align-items` to the exposed UI + - Adds native supports for tokenSets in input + - Adds Json node (alpha) +- 7644d05: Added new nodes for array concatenation and css box models + +## 0.4.0 + +### Minor Changes + +- 745e1a2: - Adds a parse Unit node. + - Adds `align-items` to the exposed UI + - Adds native supports for tokenSets in input + - Adds Json node (alpha) + +## 0.3.0 + +### Minor Changes + +- be38194: Add new node for contrasting color supporting wcag 2.1 and 3.0 +- b1a09fd: Add Color Wheel node +- 823ac1e: Added Objectify and Dotprop nodes + +### Patch Changes + +- 122c050: Fixed issue with slider not working as expected + +## 0.2.2 + +### Patch Changes + +- a67b830: Some nodes were not exported, namely the convert node from color + +## 0.2.1 + +### Patch Changes + +- ec4a20a: Fix exposure of graph controls + +## 0.2.0 + +### Minor Changes + +- c95ee97: Removed graphlib dependency and swapped it out for an internal representation + +## 0.1.0 + +### Minor Changes + +- 4e36db5: Add more test nodes and fix culori problem +- d30d954: Added Advanced blend node, fixe bugs in remap and added step down in the harmonic + +## 0.0.4 + +### Patch Changes + +- 16716d0: Fixed issue with remap not respecting the input key and rather using the index + +## 0.0.3 + +### Patch Changes + +- a4f784f: Remove husky as postinstall script. It was affecting downstream users + +## 0.0.2 + +### Patch Changes + +- f5bff7c: Adds support for external load side effect with ephemeral data as well as protecting against dangling edges + +## 0.0.1 + +### Patch Changes + +- ed3e391: Add extra docs diff --git a/packages/fbp/LICENCE b/packages/fbp/LICENCE new file mode 100644 index 000000000..d71a8cfeb --- /dev/null +++ b/packages/fbp/LICENCE @@ -0,0 +1,37 @@ +“Commons Clause” License Condition v1.0 + +The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition. + +Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software. + +For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/ support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice. + +Software: Graph Engine + +License: MIT + +Licensor: Hyma BV + +--- + +MIT License + +Copyright (c) 2023 Hyma BV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/fbp/jest.config.ts b/packages/fbp/jest.config.ts new file mode 100644 index 000000000..88f5adf66 --- /dev/null +++ b/packages/fbp/jest.config.ts @@ -0,0 +1,51 @@ +/* eslint-disable import/no-anonymous-default-export */ +export default { + coverageDirectory: "/jest-coverage", + collectCoverage: true, + collectCoverageFrom: ["/src/**.{js,jsx,ts,tsx}"], + coverageReporters: ["json"], + coveragePathIgnorePatterns: [ + "!/dist/", + "!/jest-coverage", + "!/types/", + "!*.d.ts", + ], + // preset: 'ts-jest/presets/js-with-ts', + moduleFileExtensions: ["js", "jsx", "ts", "tsx", "json"], + moduleNameMapper: { + // Yes it should be an array to ensure consistency, but jest does not offer this + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": + "/tests/__mocks__/file-mock.js", + ".+\\.(css|less|scss|sass|styl)$": "identity-obj-proxy" + }, + transform: { + "^.+\\.(js|jsx)$": "babel-jest", + "^.+\\.(ts|tsx)$": [ + "ts-jest", + { + tsconfig: "tsconfig.test.json", + }, + ], + }, + transformIgnorePatterns: [ + // Change MODULE_NAME_HERE to your module that isn't being compiled + "/node_modules/(?!(apca-w3|colorparsley|dot-prop|culori)).+\\.js$", + ], + resolver: "ts-jest-resolver", + reporters: [ + "default", + [ + "jest-junit", + { + outputDirectory: "/jest-coverage", + outputName: "junit.xml", + }, + ], + ], + testPathIgnorePatterns: [], + globals: { + __PATH_PREFIX__: "", + }, + roots: [""], + rootDir: ".", +}; diff --git a/packages/fbp/package.json b/packages/fbp/package.json new file mode 100644 index 000000000..1ffde53dc --- /dev/null +++ b/packages/fbp/package.json @@ -0,0 +1,73 @@ +{ + "name": "@tokens-studio/fbp-server-base", + "version": "0.0.1", + "description": "FBP wrapper for the graph engine", + "license": "MIT", + "author": "andrew@hyma.io", + "type": "module", + "exports": { + "./*": { + "import": "./dist/*", + "require": "./dist/*", + "types": "./dist/*" + }, + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "docs": "typedoc", + "format": "npm run format:eslint && npm run format:prettier", + "format:eslint": "eslint --ext .tsx,.ts,.js,.html . --fix", + "format:prettier": "prettier \"**/*.{ts,js,md,json}\" --write", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "release": "npm run build && changeset publish", + "test": "jest --passWithNoTests" + }, + "sideEffects": false, + "types": "./dist/index.d.ts", + "dependencies": { + "debounce": "^2.1.0", + "fbp-client": "^0.4.3", + "flowtrace": "^0.1.10", + "mobx": "^6.12.5" + }, + "peerDependencies": { + "@tokens-studio/graph-engine": "*" + }, + "devDependencies": { + "@babel/preset-env": "^7.21.5", + "@changesets/cli": "2.26.0", + "@jest/globals": "^29.5.0", + "@tokens-studio/eslint-custom-config": "*", + "@tokens-studio/graph-editor": "*", + "@tokens-studio/graph-engine": "*", + "@types/jest": "28.1.0", + "@types/node": "^18.15.11", + "eslint": "8.57.0", + "jest": "28.1.0", + "jest-junit": "^15.0.0", + "ts-jest": "28.0.3", + "ts-jest-resolver": "^2.0.1", + "ts-node": "^10.9.1", + "tsup": "^8.0.2", + "typedoc": "^0.24.7", + "typescript": "4.8.4" + }, + "keywords": [ + "studio", + "tokens", + "ui" + ], + "jsnext:main": "./dist/index.js" +} diff --git a/packages/fbp/readme.md b/packages/fbp/readme.md new file mode 100644 index 000000000..f57a006e4 --- /dev/null +++ b/packages/fbp/readme.md @@ -0,0 +1,92 @@ +# Graph Execution Engine + +![NPM version badge](https://img.shields.io/npm/v/@tokens-studio/graph-engine) ![License badge](https://img.shields.io/github/license/tokens-studio/graph-engine) + +> This project is currently in ALPHA + +This is the graph execution engine used for resolvers and generators within Tokens Studio. It relies on an internal system of nodes and edges to execute graph definitions. + +![](./assets/resolver-eg.png) + +## Installation + +With [NPM](https://www.npmjs.com/): + +```sh +npm install @tokens-studio/graph-engine +``` + +## Example + +```ts +import { + execute, + nodes, + MinimizedFlowGraph, +} from "@tokens-studio/graph-engine"; + +const output = await execute({ + graph: myGraph, + inputValues: { + foo: "bar", + }, + nodes, +}); +``` + +## Examples + +Provide token sets as part of your input + +```ts +import { + execute, + nodes, + MinimizedFlowGraph, +} from "@tokens-studio/graph-engine"; + +//Note that the references are not resolved automatically. The graph is responsible for resolving if it wants to. +const tokens = { + dimension: { + scale: { + value: "2", + type: "dimension", + }, + xs: { + value: "4", + type: "dimension", + }, + sm: { + value: "{dimension.xs} * {dimension.scale}", + type: "dimension", + }, + md: { + value: "{dimension.sm} * {dimension.scale}", + type: "dimension", + }, + lg: { + value: "{dimension.md} * {dimension.scale}", + type: "dimension", + }, + xl: { + value: "{dimension.lg} * {dimension.scale}", + type: "dimension", + }, + }, +}; + +const output = await execute({ + graph: myGraph, + inputValues: { + myTokens: { + name: "My tokens", + tokens, + }, + }, + nodes, +}); +``` + +## Documentation + +See our documentation site [here](https://tokens-studio.github.io/graph-engine/) diff --git a/packages/fbp/src/annotations/index.ts b/packages/fbp/src/annotations/index.ts new file mode 100644 index 000000000..01c6a0219 --- /dev/null +++ b/packages/fbp/src/annotations/index.ts @@ -0,0 +1 @@ +export const graphID = 'engine.id'; \ No newline at end of file diff --git a/packages/fbp/src/base.ts b/packages/fbp/src/base.ts new file mode 100644 index 000000000..14f505752 --- /dev/null +++ b/packages/fbp/src/base.ts @@ -0,0 +1,272 @@ +import { ComponentProtocol } from './protocol/component'; +import { + EventEmitter, +} from 'events'; +import { GraphProtocol } from './protocol/graph'; +import { Network, NetworkProtocol } from './protocol/network'; +import { RuntimeProtocol } from './protocol/runtime'; +import { TraceProtocol } from './protocol/trace'; +import { Transport, TransportConfig } from './interfaces/transport'; + + +import { Context } from './interfaces/context'; +import { Graph } from '@tokens-studio/graph-engine'; +import { Loader } from './interfaces/loader'; + + + +export enum Protocol { + GRAPH = 'graph', + NETWORK = 'network', + COMPONENT = 'component', + TRACE = 'trace', + RUNTIME = 'runtime' +} + +export interface PayloadWithSecret { + secret?: string +} + +export type BaseTransportOptions = { + capabilities?: string[], + defaultPermissions?: string[], + /** + * A lookup of permissions for each user + */ + permissions?: Record, + defaultGraph?: Graph + loader: Loader + +} + +export type BaseEvents = { + ready: (network?: Network) => void, + error: (err:Error) => void, +} + +export interface BaseTransportEvents { + //Declare a strongly typed event emitter handler for 'on' + on(event: T, listener: BaseEvents[T]): this; + + //Declare a strongly typed event emitter handler for 'emit' + emit(event: T, ...args: Parameters): boolean; + +} + + +export class BaseTransport extends EventEmitter implements Transport, BaseTransportEvents { + + component: ComponentProtocol; + graph: GraphProtocol; + network: NetworkProtocol; + runtime: RuntimeProtocol; + trace: TraceProtocol; + options: BaseTransportOptions; + version: string; + context: Context; + + constructor(options: BaseTransportOptions) { + super(); + this.options = options ; + + //This is important, as we currently are running on a slightly modified version than outlined in https://flowbased.github.io/fbp-protocol/#component-source + this.version = '0.8'; + this.component = new ComponentProtocol(this); + this.graph = new GraphProtocol(this); + this.network = new NetworkProtocol(this); + this.runtime = new RuntimeProtocol(this); + this.trace = new TraceProtocol(this); + this.context = null; + + + if (!this.options.capabilities) { + this.options.capabilities = [ + 'protocol:graph', + 'protocol:component', + 'protocol:network', + 'protocol:runtime', + 'protocol:trace', + 'component:setsource', + 'component:getsource', + 'graph:readonly', + 'network:data', + 'network:control', + 'network:status', + ]; + } + + if (!this.options.defaultPermissions) { + // Default: no capabilities granted for anonymous users + this.options.defaultPermissions = []; + } + + if (!this.options.permissions) { + this.options.permissions = {}; + } + + setTimeout(() => { + this._startDefaultGraph(); + }, 0); + } + + // Generate a name for the main graph + getGraphName(graph: Graph): string { + return (graph.annotations['engine.title'] || 'unknown') as string; + } + + async _startDefaultGraph() { + if (!this.options.defaultGraph) { + this.emit('ready', null); + return; + } + + const graphId = this.options.defaultGraph.annotations['engine.id'] as string; + try { + await this.graph.registerGraph(graphId, this.options.defaultGraph, false); + const network = await this.network.startNetwork(this.options.defaultGraph, { + graph: graphId, + }, this.context); + this.runtime.setMainGraph(this.options.defaultGraph); + this.emit('ready', network); + + } catch (err) { + this.emit('error', err); + } + + + } + + // Check if a given user is authorized for a given capability + // + // @param [Array] Capabilities to check + // @param [String] Secret provided by user + canDo(capability, secret) { + let checkCapabilities; + if (typeof capability === 'string') { + checkCapabilities = [capability]; + } else { + checkCapabilities = capability; + } + const userCapabilities = this.getPermitted(secret); + const permitted = checkCapabilities.filter((perm) => userCapabilities.includes(perm)); + if (permitted.length > 0) { + return true; + } + return false; + } + + // Check if a given user is authorized to send a given message + canInput(protocol, topic, secret) { + if (protocol === 'graph') { + // All graph messages are under the same capability + return this.canDo(['protocol:graph'], secret); + } + if (protocol === 'trace') { + // All trace messages are under the same capability + return this.canDo(['protocol:trace'], secret); + } + const message = `${protocol}:${topic}`; + switch (message) { + case 'component:list': return this.canDo(['protocol:component'], secret); + case 'component:getsource': return this.canDo(['component:getsource'], secret); + case 'component:source': return this.canDo(['component:setsource'], secret); + case 'network:edges': return this.canDo(['network:data', 'protocol:network'], secret); + case 'network:start': return this.canDo(['network:control', 'protocol:network'], secret); + case 'network:stop': return this.canDo(['network:control', 'protocol:network'], secret); + case 'network:debug': return this.canDo(['network:control', 'protocol:network'], secret); + case 'network:getstatus': return this.canDo(['network:status', 'network:control', 'protocol:network'], secret); + case 'runtime:getruntime': return true; + case 'runtime:packet': return this.canDo(['protocol:runtime'], secret); + default: return false; + } + } + + // Get enabled capabilities for a user + // + // @param [String] Secret provided by user + getPermitted(secret) { + if (!secret) { + return this.options.defaultPermissions; + } + return this.options.permissions[secret] || []; + } + + // Send a message back to the user via the transport protocol. + // + // Each transport implementation should provide their own implementation + // of this method. + // + // The context is usually the context originally received from the + // transport with the request. This could be an iframe origin or a + // specific WebSocket connection. + // + // @param [String] Name of the protocol + // @param [String] Topic of the message + // @param [Object] Message payload + // @param [Object] Message context, dependent on the transport + send(protocol: Protocol, topic, payload, context: Context) { + console.log(`${protocol}:${topic}`, payload, context); + } + + // Send a message to *all users* via the transport protocol + // + // The transport should verify that the recipients are authorized to receive + // the message by using the `canDo` method. + // + // Like send() only it sends to all. + // + // @param [String] Name of the protocol + // @param [String] Topic of the message + // @param [Object] Message payload + // @param [Object] Message context, dependent on the transport + // eslint-disable-next-line @typescript-eslint/no-unused-vars + sendAll(protocol: Protocol,topic: string, payload:unknown,context:Context) { } + + // This is the entry-point to actual protocol handlers. When receiving + // a message, the runtime should call this to make the requested actions + // happen + // + // The context is originally received from the transport. This could be + // an iframe origin or a specific WebSocket connection. The context will + // be utilized when sending messages back to the requester. + // + // @param [String] Name of the protocol + // @param [String] Topic of the message + // @param [Object] Message payload + // @param [Object] Message context, dependent on the transport + receive(protocol: Protocol, topic, payload: PayloadWithSecret = {}, context: Context) { + + const secret = payload ? payload.secret : null; + if (!this.canInput(protocol, topic, secret)) { + this.send(protocol, 'error', new Error(`${protocol}:${topic} is not permitted`), context); + return; + } + + this.context = context; + switch (protocol) { + case 'runtime': { + this.runtime.receive(topic, payload, context); + return; + } + case 'graph': { + this.graph.receive(topic, payload, context); + return; + } + case 'network': { + this.network.receive(topic, payload, context); + return; + } + case 'component': { + this.component.receive(topic, payload, context); + return; + } + case 'trace': { + this.trace.receive(topic, payload, context); + return; + } + default: { + this.send(protocol, 'error', new Error(`Protocol ${protocol} is not supported`), context); + } + } + } +} \ No newline at end of file diff --git a/packages/fbp/src/index.ts b/packages/fbp/src/index.ts new file mode 100644 index 000000000..bc2928247 --- /dev/null +++ b/packages/fbp/src/index.ts @@ -0,0 +1,2 @@ +export * from './runtime'; +export * from './base'; \ No newline at end of file diff --git a/packages/fbp/src/interfaces/context.ts b/packages/fbp/src/interfaces/context.ts new file mode 100644 index 000000000..36f00514f --- /dev/null +++ b/packages/fbp/src/interfaces/context.ts @@ -0,0 +1,3 @@ +export interface Context{ + +} \ No newline at end of file diff --git a/packages/fbp/src/interfaces/loader.ts b/packages/fbp/src/interfaces/loader.ts new file mode 100644 index 000000000..76bce1793 --- /dev/null +++ b/packages/fbp/src/interfaces/loader.ts @@ -0,0 +1,14 @@ +import { NodeFactory } from "@tokens-studio/graph-engine"; +import { SourceRequest } from "src/protocol/component"; + +export type Component = { + version: string +} + + +export interface Loader { + listComponents: () => Promise>; + registerComponent: (library: string, componentName: string) => void; + setSource: (library: string, componentName: string, code: string, language: string)=> Promise; + load: (opts: SourceRequest) => Promise; +} \ No newline at end of file diff --git a/packages/fbp/src/interfaces/metadata.ts b/packages/fbp/src/interfaces/metadata.ts new file mode 100644 index 000000000..f0b4bd367 --- /dev/null +++ b/packages/fbp/src/interfaces/metadata.ts @@ -0,0 +1,4 @@ +export type PositionMetadata = { + x: number, + y:number +} \ No newline at end of file diff --git a/packages/fbp/src/interfaces/port.ts b/packages/fbp/src/interfaces/port.ts new file mode 100644 index 000000000..469564403 --- /dev/null +++ b/packages/fbp/src/interfaces/port.ts @@ -0,0 +1,12 @@ +export type PortDefinition = { + id: string, + type: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + schema?: any, + required?: boolean, + addressable?: boolean, + description?: string, + values?: string[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default?: any +} diff --git a/packages/fbp/src/interfaces/transport.ts b/packages/fbp/src/interfaces/transport.ts new file mode 100644 index 000000000..e6280d83e --- /dev/null +++ b/packages/fbp/src/interfaces/transport.ts @@ -0,0 +1,62 @@ +import { ComponentProtocol } from "../protocol/component" +import { Context } from "./context" +import { Graph } from "@tokens-studio/graph-engine" +import { GraphProtocol } from "../protocol/graph" +import { Loader } from "./loader" +import { NetworkProtocol } from "../protocol/network" +import { RuntimeProtocol } from "../protocol/runtime" +import { TraceProtocol } from "../protocol/trace" + + + +export interface TransportConfig { + /** + * A lookup of permissions for each user + */ + permissions?: Record, + defaultGraph?: Graph + + defaultPermissions?: string[], + capabilities?: string[], + loader: Loader + /** + * Runtime ID + */ + id?: string, + /** + * Type of Transport + */ + type?: string, + /** + * Human readable label + */ + label?: string, + /** + * Optional namespace + */ + namespace?: string, + /** + * Optional repository + */ + repository?: string, + + repositoryVersion?: string + +} + +export interface Transport { + + options: TransportConfig, + component: ComponentProtocol, + runtime: RuntimeProtocol, + trace: TraceProtocol, + network: NetworkProtocol, + graph: GraphProtocol, + + version: string, + + send(type: string, topic: string, data: unknown, context: Context): void + sendAll(type: string, topic: string, data: unknown, context?: Context): void + + canDo(capability: string, secret?: string) +} \ No newline at end of file diff --git a/packages/fbp/src/messages/component.ts b/packages/fbp/src/messages/component.ts new file mode 100644 index 000000000..0865d27d2 --- /dev/null +++ b/packages/fbp/src/messages/component.ts @@ -0,0 +1,75 @@ +export type Error = { + message: string +} + +/** + * Request for the source code of a given component. Will be responded with a `source` message. + */ +export type GetSource = { + /** + * Name of the component to for which to get source code.Should contain the library prefix + * @example studio.tokens.generic.input + */ + name: string, + secret?: string +} + +/** + * Source code for a component. In cases where a runtime receives a `source` message, it should do whatever operations are needed for making that component available for graphs, including possible compilation. + */ +export type Source = { + name: string, + /** + * The programming language used for the component code + */ + language: string, + /** + * Component library identifier + * + * Note that we diverge from the initial FBP spec here. This is the name of the npm package that contains the component. + */ + library: string, + + code: string, + tests: string +} + + +export type Component = { + + name: string, + description: string, + icon?: string, + subgraph?: boolean, + inPorts: { + id: string, + type: string, + schema: string, + required: boolean, + addressable: boolean, + description: string, + /** + * @deprecated + */ + values?: unknown[], + default:unknown + }[], + outPorts: { + id: string, + type: string, + schema: string, + required: boolean, + addressable: boolean, + description: string, + /** + * @deprecated + */ + values?: unknown[], + default: unknown + }[] +} + + +export type ComponentsReady ={ + //Nothing at the moment +} \ No newline at end of file diff --git a/packages/fbp/src/messages/graph.ts b/packages/fbp/src/messages/graph.ts new file mode 100644 index 000000000..dff32b50a --- /dev/null +++ b/packages/fbp/src/messages/graph.ts @@ -0,0 +1,232 @@ +import { PositionMetadata } from "../interfaces/metadata" +import { TypeDefinition } from "@tokens-studio/graph-engine" + +/** + * Initialize an empty graph. + */ +export type Clear = { + /** + * identifier for the graph being created. Used for all subsequent messages related to the graph instance + */ + id: string, + /** + * Human-readable label for the graph + */ + name: string, + /** + * Component library identifier + */ + library: string, + /** + * Identifies the graph as a main graph of a project that should not be registered as a component Graphs registered in this way should also be available for use as subgraphs in other graphs. Therefore a graph registration and later changes to it may cause component messages of the Component protocol to be sent back to the client informing of possible changes in the ports of the subgraph component. + */ + main: boolean, + icon?: string, + description?: string, + secret?: string + +} + +/** + * Add node to a graph. + */ +export type AddNode = { + /** + * identifier for the node being created. Used for all subsequent messages related to the node instance + */ + id: string, + /** + * Component identifier + */ + component: string, + /** + * graph the action targets + */ + graph: string, + /** + * Metadata object + */ + metadata: PositionMetadata, + /** + * access token to authorize the client + */ + secret?: string +} + +export type RemoveEdge = { + graph: string, + secret?: string, + src: { + node: string, + port: string, + index?: number + }, + tgt: { + node: string, + port: string, + index?: number + } +} + +/** + * Remove a node from a graph. + */ +export type RemoveNode = { + /** + * identifier for the node being removed + */ + id: string, + /** + * graph the action targets + */ + graph: string, + /** + * access token to authorize the client + */ + secret?: string +} + +/** + * Change the ID of a node in the graph + */ +export type RenameNode = { + /** + * original identifier for the node + */ + from: string, + /** + * new identifier for the node + */ + to: string, + /** + * graph the action targets + */ + graph: string, + /** + * access token to authorize the client + */ + secret?: string +} + +export type ChangeNode = { + id: string, + graph: string, + secret?: string, + /** + * All the metadata to be applied to the node. This needs to be atomic, meaning that all metadata should be applied at once + */ + metadata: object + +} + +export type Error = { + message: string +} + +export type AddInport = { + /** + * The exported name of the port + */ + public: string, + /** + * The node ID + */ + node: string, + /** + * Internal port name + */ + port: string, + metadata: TypeDefinition, + + graph: string, + secret?: string +} + +export type AddEdge = { + graph: string, + secret?: string, + + src: { + node: string, + port: string, + index?: number + }, + tgt: { + node: string, + port: string, + index?: number + }, + metadata: unknown +} + +/** + * Remove an exported port from the graph + */ +export type RemoveInport = { + public: string, + graph: string, + secret?: string +} + +/** + * Add an exported outport to the graph + */ +export type AddOutport ={ + public: string, + /** + * @deprecated + * Not in our implementation + */ + node: string, + port: string, + metadata: TypeDefinition, + graph:string, + secret?: string +} + +export type RemoveOutport = { + public:string, + graph: string, + secret?: string +} + +/** + * Adds an initial data packet to the graph + */ +export type AddInitial = { + graph: string, + metadata:{ + /** + * Route identifier of a graph entity + * @deprecated + */ + route: number, + /** + * JSON schema associated with a graph entity. + * Note that the original FPB spec specifies this as a string, but we pass through the json object + * @overload + */ + schema: object, + /** + * @deprecated + * Whether graph entity data should be treated as secure + */ + secure: boolean + }, + /** + * source data for the edge + */ + src:{ + data:unknown, + }, + /** + * target node/port for the edge + */ + tgt:{ + node:string, + port:string + index?:number + }, + secret?: string + +} \ No newline at end of file diff --git a/packages/fbp/src/messages/network.ts b/packages/fbp/src/messages/network.ts new file mode 100644 index 000000000..e2dfe60f4 --- /dev/null +++ b/packages/fbp/src/messages/network.ts @@ -0,0 +1,207 @@ +export type Start = { + /** + * id of the graph to start + */ + graph: string, + /** + * access token to authorize user + */ + secret?: string, +} + +export type Started = { + /** + * time when the network was stopped + */ + time: string, + /** + * graph the action targets + */ + graph: string + + started: boolean + running: boolean + debug: boolean +} + +/** + * Response to a getstatus message. + */ +export type Status = { + /** + * graph the action targets + */ + graph: string, + /** + * time the network has been running, in seconds + */ + uptime: number, + /** + * whether or not network has started running + */ + started: boolean, + /** + * boolean tells whether the network is running or not + */ + running: boolean, + /** + * whether or not network is in debug mode + */ + debug: boolean, +} + +/** + * Sets a networks debug mode + */ +export type Debug = { + enable: boolean + /** + * access token to authorize user + */ + secret?: string, + /** + * id of the graph + */ + graph: string, + +} + +export type Error = { + message: string +} + +/** + * Inform that a given network has stopped. + */ +export type Stopped = { + + /** + * time when the network was stopped + */ + time: string, + /** + * time the network was running, in seconds + */ + uptime: number + /** + * graph the action targets + */ + graph: string, + /** + * whether or not network is currently running + */ + running: boolean, + /** + * whether or not network has been started + */ + started: boolean, + /** + * whether or not network is in debug mode + */ + debug: boolean +} + +/** + * Tells the runtime to persist the current state of graphs and components so that they are available between restarts. Requires the network:persist capability. + */ +export type Persist = { + secret?: string +} + +/** + * List of edges user has selected for inspection in a user interface or debugger, sent from UI to a runtime. + */ +export type Edges = { + graph: string, + secret?: string, + edges: { + src: { + node: string, + port: string, + /** + * Connection index, for addressable ports + * Note that we use this for variadic ports + */ + index: number + }, + tgt: { + node: string, + port: string, + /** + * Connection index, for addressable ports + * Note that we use this for variadic ports + */ + index: number + } + }[] +} + + +/** + * An output message from a running network, roughly similar to STDOUT output of a Unix process, or a line of console.log in JavaScript. Output can also be used for passing images from the runtime to the UI.' + */ +export type Output = { + /** + * contents of the output line + */ + message: string, + /** + * type of output, either message or previewurl + */ + type: string, + /** + * URL for an image generated by the runtime + */ + url?: string +} + +/** + * When in debug mode, a network can signal an error happening inside a process. + */ +export type ProcessError = { + /** + * identifier of the node + */ + id: string, + /** + * error from the component + */ + error: string, + /** + * graph the action targets + */ + graph: string +} + +/** + * Data transmission on an edge. + */ +export type Data = { + /** + * Edge ID + */ + id: string, + src: { + node: string, + port: string, + /** + * Connection index, for addressable ports + * Note that we use this for variadic ports + */ + index?: number + }, + tgt: { + node: string, + port: string, + /** + * Connection index, for addressable ports + * Note that we use this for variadic ports + */ + index?: number + }, + graph: string, + /** + * Subgraph identifier for the event. An array of node IDs. + */ + subgraph: string[] +} \ No newline at end of file diff --git a/packages/fbp/src/messages/runtime.ts b/packages/fbp/src/messages/runtime.ts new file mode 100644 index 000000000..7843da3a1 --- /dev/null +++ b/packages/fbp/src/messages/runtime.ts @@ -0,0 +1,134 @@ +/** + * Runtimes that can be used as remote subgraphs (i.e. ones that have reported supporting the protocol:runtime capability) need to be able to receive and transmit information packets at their exposed ports. These packets can be send from the client to the runtimes input ports, or from runtimes output ports to the client. + */ +export type Packet = { + port: string, + event: string, + /** + * The data type of the packet + */ + type: string, + /** + * Link to JSON schema describing the format of the data +"https://example.net/schemas/person.json" + */ + schema: string, + graph: string, + /** + * payload for the packet. Used only with begingroup (for group names) and data packets + */ + + payload: unknown, + secret?: string +} + +export type GetRuntime = { + secret?: string +} + +export type Runtime = { + /** + * unique runtime ID. Must be a UUID, version 4 "f18a4924-9d4f-414d-a37c-cd24b39bba10" + */ + id: string, + /** + * Human-readable description of the runtime + */ + label?: string, + /** + * version of the runtime protocol that the runtime supports + */ + version: string, + /** + * capability strings for things the runtime is able to do. May include things not permitted for this client. + */ + allCapabilities?: string[], + /** + * capability strings for things the runtime is able to do for this client. + */ + capabilities?: string[], + /** + * type of the runtime + * @example "microflo" + */ + type: string, + /** + * ID of the currently configured main graph running on the runtime, if any + */ + graph: string, + /** + * Library namespace of the project running on the runtime, if any. Must match that of components belonging to the (top-level) of project. + * @example "myproject" + */ + namespace: string, + /** + * Source-code repository URL of the project running on the runtime, if any + * @example "https://github.com/flowbased/fbp-protocol.git" + */ + repository: string, + /** + * Unique version identifier of the source code of the project, if known. The version should be available in @repository. + * @example "0.6.3-8-g90edcfc" + */ + repositoryVersion: string +} + +export type Ports = { + graph: string, + inPorts:{ + id:string, + /** + * Port data type + */ + type:string, + + schema: string, + required?: boolean, + addressable?: boolean, + description?: string, + values?: unknown[] + /** + * default value for the port + */ + default?: unknown + + }[], + outPorts: { + id: string, + /** + * Port data type + */ + type: string, + + schema: string, + required?: boolean, + addressable?: boolean, + description?: string, + values?: unknown[] + /** + * default value for the port + */ + default?: unknown + + }[] +} + +export type Error = { + message: string +} + + +/** + * Confirmation that a packet has been sent + */ +export type PacketSent = { + port: string, + event: string, + /** + * The basic data type + */ + type?: string, + schema?: string, + graph:string, + payload: unknown +} \ No newline at end of file diff --git a/packages/fbp/src/messages/trace.ts b/packages/fbp/src/messages/trace.ts new file mode 100644 index 000000000..11aec62d4 --- /dev/null +++ b/packages/fbp/src/messages/trace.ts @@ -0,0 +1,66 @@ +import { FlowtraceJson } from "flowtrace/dist/lib/Flowtrace" + +export type Start = { + /** + * access token to authorize the client + */ + secret?: string, + /** + * Graph identifier the message targets + */ + graph: string, + /** + * Size of tracing buffer to keep. In bytes + */ + buffersize: number, +} + +export type Stop = { + /** + * access token to authorize the client + */ + secret?: string, + /** + * Graph identifier the message targets + */ + graph: string, +} + +export type Dump = { + /** + * String describing type of trace. + */ + type: string, + /** + * Graph identifier the message targets + */ + graph: string, + /** + * A Flowtrace file of `type` + */ + flowtrace: FlowtraceJson +} + +/** + * Clear current tracing buffer. + */ +export type Clear = { + /** + * access token to authorize the client + */ + secret?: string, + /** + * Graph identifier the message targets + */ + graph: string, +} + +/** + * Error response to a command on trace protoco + */ +export type Error = { + /** + * Error message describing what went wrong + */ + message: string +} \ No newline at end of file diff --git a/packages/fbp/src/messages/util.ts b/packages/fbp/src/messages/util.ts new file mode 100644 index 000000000..5c26089ee --- /dev/null +++ b/packages/fbp/src/messages/util.ts @@ -0,0 +1,4 @@ +/** + * Strips the message secret from the message type. + */ +export type ServerMessage = Omit; \ No newline at end of file diff --git a/packages/fbp/src/protocol/component.ts b/packages/fbp/src/protocol/component.ts new file mode 100644 index 000000000..c2ee0132e --- /dev/null +++ b/packages/fbp/src/protocol/component.ts @@ -0,0 +1,232 @@ +import { Component, Error as ErrorMessage, Source } from "../messages/component"; +import { Context } from "../interfaces/context"; +import { + EventEmitter, +} from 'events'; +import { Graph, Input, NodeFactory, Port } from "@tokens-studio/graph-engine"; +import { Loader } from "../interfaces/loader"; +import { PortDefinition } from "../interfaces/port"; +import { ServerMessage } from "../messages/util"; +import { Transport } from "../interfaces/transport"; +import debounce from 'debounce' + + +type MessageLookup = { + error: ErrorMessage, + component: ServerMessage +} + +type MessageType = MessageLookup[T]; + +export type SourceRequest = { + library: string, + name: string +} + +export const extractSourceRequest = (component: string): SourceRequest => { + const parts = component.split('/'); + return { + library: parts[0], + name: parts[1], + }; +} + + + + +/** + * Provides communications about available components and changes to them + */ +export class ComponentProtocol extends EventEmitter { + + + transport: Transport; + loader: Loader + constructor(transport: Transport) { + super(); + this.transport = transport; + this.loader = transport.options.loader; + } + + send

(topic: P, payload: MessageType

, context?: Context) { + return this.transport.send('component', topic, payload, context); + } + + sendAll

(topic: P, payload: MessageType

, context?: Context) { + return this.transport.sendAll('component', topic, payload, context); + } + + receive(topic, payload, context) { + switch (topic) { + case 'list': return this.listComponents(payload, context); + case 'getsource': return this.getSource(payload, context); + case 'source': return this.setSource(payload, context); + default: return this.send('error', new Error(`component:${topic} not supported`), context); + } + } + + loadNode = async (sourceRequest: SourceRequest, context: Context): Promise => { + const loader = this.getLoader(); + return this.processComponent(loader, sourceRequest, context); + } + + getLoader() { + return this.loader; + } + + listComponents(payload, context) { + + const loader = this.getLoader(); + loader.listComponents() + .then((components) => { + const componentNames = Object.keys(components); + let processed = 0; + return Promise.all(componentNames.map((component) => this + .processComponent(loader, component, context) + .then(() => { + processed += 1; + }, (error) => { + processed += 1; + this.send('error', error, context); + }))) + .then(() => { + this.send('componentsready', processed, context); + }); + }, (err) => { + this.send('error', err, context); + }); + } + + getSource(payload, context) { + const loader = this.getLoader(); + loader.getSource(payload.name) + .then( + (src) => src, + (err) => { + // Try one of the registered graphs + const nameParts = parseName(payload.name); + const graph = this.transport.graph.graphs[payload.name] + || this.transport.graph.graphs[nameParts.name]; + if (!graph) { + return Promise.reject(err); + } + return { + name: nameParts.name, + library: nameParts.library || '', + code: JSON.stringify(graph.toJSON()), + language: 'json', + }; + }, + ) + .then((component) => { + this.send('source', component, context); + }, (err) => { + this.send('error', err, context); + }); + } + + async setSource(payload: Source, context: Context) { + + const loader = this.getLoader(); + try { + await loader.setSource(payload.library, payload.name, payload.code, payload.language); + + this.emit('updated', payload); + await this.processComponent( + loader, + payload, + context, + ); + } catch (err) { + this.send('error', err, context); + } + } + + async processComponent(loader: Loader, source: SourceRequest, context: Context): Promise { + const factory = await loader.load(source); + + this.sendComponent(source.name, factory, context); + return factory; + } + + processPort(portName: string, port: Port) { + // Required port properties + const portDef = { + id: portName, + type: port.type.type, + } as PortDefinition; + + portDef.schema = port.type.$id; + // TODO fix, we currently do not distinguish between required and optional ports + portDef.required = true; + portDef.addressable = (port as Input).variadic || false; + portDef.description = port.type.description; + portDef.default = port.type.default; + return portDef; + } + + sendComponent(component: string, Factory: NodeFactory, context) { + const inPorts = []; + const outPorts = []; + + //Use an empty graph to prevent side effects + const graph = new Graph(); + + const instance = new Factory({ graph }); + + + //@TODO make the way we determine inports, etc to be more declarative + + + Object.keys(instance.inputs).forEach((portName) => { + const port = instance.inputs[portName]; + inPorts.push(this.processPort(portName, port)); + }); + Object.keys(instance.outputs).forEach((portName) => { + const port = instance.outputs[portName]; + outPorts.push(this.processPort(portName, port)); + }); + + const icon = instance.getIcon ? instance.getIcon() : 'gear'; + + this.send('component', { + name: component, + description: instance.description, + subgraph: instance.isSubgraph(), + icon, + inPorts, + outPorts, + }, + context); + } + + async registerGraph(id: string, graph: Graph, context: Context) { + const loader = this.getLoader(); + const sender = () => this.processComponent(loader, id, context); + const send = debounce(sender, 10); + + + + // Send graph info again every time it changes so we get the updated ports + graph.on('nodeAdded', send); + graph.on('nodeRemoved', send); + graph.on('edgeAdded', send); + graph.on('edgeRemoved', send); + graph.on('inputPortAdded', send); + graph.on('inputPortRemoved', send); + graph.on('outputPortAdded', send); + graph.on('outputPortRemoved', send); + + + await loader.registerComponent(library, name, graph); + + await loader.listComponents() + .then(() => { + + // Send initial graph info back to client + send(); + }, (err) => { + this.send('error', err, context); + }); + } +} `` diff --git a/packages/fbp/src/protocol/graph.ts b/packages/fbp/src/protocol/graph.ts new file mode 100644 index 000000000..267d2a47b --- /dev/null +++ b/packages/fbp/src/protocol/graph.ts @@ -0,0 +1,497 @@ +import { AddEdge, AddInitial, AddInport, AddNode, AddOutport, ChangeNode, Clear, Error as ErrorMessage, RemoveEdge, RemoveInport, RemoveNode, RemoveOutport, RenameNode } from '../messages/graph'; +import { Context } from '../interfaces/context'; +import { + EventEmitter, +} from 'events'; +import { Graph, annotatedVariadicIndex } from '@tokens-studio/graph-engine'; +import { ServerMessage } from '../messages/util'; +import { Transport } from '../interfaces/transport'; +import { extractSourceRequest } from './component'; +import { findInput, findOutput } from '../utils'; +import { graphID } from '../annotations'; + +type MessageLookup = { + error: ErrorMessage, + clear: ServerMessage, + addnode: ServerMessage, + addedge: ServerMessage, + removenode: ServerMessage, + addinport: ServerMessage, + addoutport: ServerMessage, + removeedge: ServerMessage, + removeinport: ServerMessage, + removeoutport: ServerMessage +} + +type MessageType = MessageLookup[T]; + + + + + + +export class GraphProtocol extends EventEmitter { + + transport: Transport; + graphs: Record + + constructor(transport: Transport) { + super(); + this.transport = transport; + this.graphs = {}; + } + + send

(topic: P, payload: MessageType

, context?: Context) { + return this.transport.send('graph', topic, payload, context); + } + + sendAll

(topic: P, payload: MessageType

, context?: Context) { + return this.transport.sendAll('graph', topic, payload, context); + } + + receive(topic, payload, context) { + // Find locally stored graph by ID + let graph: Graph; + if (topic !== 'clear') { + graph = this.resolveGraph(payload, context); + if (!graph) { return; } + } + + switch (topic) { + case 'clear': this.initGraph(payload, context); break; + case 'addnode': this.addNode(graph, payload, context); break; + case 'removenode': this.removeNode(graph, payload, context); break; + case 'renamenode': this.renameNode(graph, payload, context); break; + case 'changenode': this.changeNode(graph, payload, context); break; + case 'addedge': this.addEdge(graph, payload, context); break; + case 'removeedge': this.removeEdge(graph, payload, context); break; + case 'changeedge': this.changeEdge(graph, payload, context); break; + case 'addinitial': this.addInitial(graph, payload, context); break; + case 'removeinitial': this.removeInitial(graph, payload, context); break; + case 'addinport': this.addInport(graph, payload, context); break; + case 'removeinport': this.removeInport(graph, payload, context); break; + case 'renameinport': this.renameInport(graph, payload, context); break; + case 'addoutport': this.addOutport(graph, payload, context); break; + case 'removeoutport': this.removeOutport(graph, payload, context); break; + case 'renameoutport': this.renameOutport(graph, payload, context); break; + case 'addgroup': this.addGroup(graph, payload, context); break; + case 'removegroup': this.removeGroup(graph, payload, context); break; + case 'renamegroup': this.renameGroup(graph, payload, context); break; + case 'changegroup': this.changeGroup(graph, payload, context); break; + default: this.send('error', new Error(`graph:${topic} not supported`), context); + } + } + + resolveGraph(payload, context): Graph | null { + if (!payload.graph) { + this.send('error', new Error('No graph specified'), context); + return null; + } + + if (!this.graphs[payload.graph]) { + this.send('error', new Error('Requested graph not found'), context); + return null; + } + return this.graphs[payload.graph]; + } + + getLoader() { + return this.transport.component.getLoader(); + } + + + initGraph(payload: Clear, context) { + if (!payload.id) { + this.send('error', new Error('No graph ID provided'), context); + return; + } + const graph = new Graph(); + + + graph.annotations[graphID] = payload.id; + graph.annotations['engine.main'] = payload.main; + graph.annotations['engine.title'] = payload.name || 'Unnamed Graph'; + + + const { library } = payload; + if (library) { + //TODO this is currently unused + } + if (payload.icon) { + graph.annotations['ui.icon'] = payload.icon; + } + if (payload.description) { + graph.annotations['engine.main'] = payload.description; + } + + this.registerGraph(payload.id, graph, context, true) + .catch((err: Error) => { + this.send('error', err, context); + }); + } + + async registerGraph(id: string, graph: Graph, context = null, propagate = true) { + // Prepare the network + try { + const network = await this.transport.network.initNetwork(graph, id, context) + + this.subscribeGraph(id, graph, context); + this.graphs[id] = graph; + + + const graphName = graph.annotations['engine.title'] as string; + const main = !!graph.annotations['engine.main']; + const icon = graph.annotations['ui.icon'] as string | undefined; + const description = graph.annotations['engine.description'] as string | undefined; + + this.sendAll('clear', { + id, + name: graphName, + //TODO currently unused + library: '', + main, + icon, + description, + }); + + if (!propagate) { + return; + } + + // Register for runtime exported ports + this.transport.runtime.registerNetwork(id, network); + + + } catch (err) { + this.send('error', err, context); + return Promise.reject(err); + } + } + + subscribeGraph(id: string, graph: Graph, context: Context) { + graph.on('nodeAdded', (node) => { + this.sendAll('addnode', { + id: node.id, + component: node.factory.type, + metadata: node.annotations, + graph: id, + } as AddNode, context); + }); + graph.on('nodeRemoved', (id) => { + const nodeData = { + id, + graph: id, + }; + this.sendAll('removenode', nodeData, context); + }); + + graph.on('edgeAdded', (edge) => { + const edgeData: AddEdge = { + src: { + node: edge.source, + port: edge.sourceHandle, + }, + tgt: { + node: edge.target, + port: edge.targetHandle, + }, + //Currently none + metadata: undefined, + graph: id, + }; + + if (edge.annotations[annotatedVariadicIndex]) { + edgeData.tgt.index = edge.annotations[annotatedVariadicIndex] as number; + } + + this.sendAll('addedge', edgeData, context); + }); + graph.on('edgeRemoved', (edge) => { + const edgeData = { + src: { + node: edge.source, + port: edge.sourceHandle, + }, + tgt: { + node: edge.target, + port: edge.targetHandle, + }, + graph: id, + }; + this.sendAll('removeedge', edgeData, context); + }); + graph.on('inputPortAdded', (port) => { + const data: AddInport = { + public: port.name, + node: port.node.id, + port: port.name, + metadata: { + ...port.fullType(), + ...port.annotations + }, + graph: id, + }; + this.sendAll('addinport', data, context); + }); + graph.on('outputPortAdded', (port) => { + const data = { + public: port.name, + node: port.node.id, + port: port.name, + metadata: { + ...port.fullType(), + ...port.annotations + }, + graph: id, + }; + this.sendAll('addoutport', data, context); + }); + graph.on('inputPortRemoved', (input) => { + + if (input.node.factory.type === 'studio.tokens.generic.input') { + const data = { + public: input.name, + graph: id, + }; + this.sendAll('removeinport', data, context); + } + + }); + graph.on('outputPortRemoved', (output) => { + if (output.node.factory.type === 'studio.tokens.generic.output') { + const data = { + public: output.name, + graph: id, + }; + this.sendAll('removeoutport', data, context); + } + }); + + } + + addInport(graph: Graph, payload: AddInport, context) { + if (!payload.public && !payload.node && !payload.port) { + this.send('error', new Error('Missing exported inport information'), context); + return; + } + /** + * Ensure the node exists + */ + const node = graph.getNode(payload.node); + if (!node) { + this.send('error', new Error(`Node ${payload.node} not found`), context); + return; + } + + node.addInput(payload.port, payload.metadata); + } + + async addNode(graph: Graph, payload: AddNode, context: Context) { + if (!payload.id && !payload.component) { + this.send('error', new Error('No ID or component supplied'), context); + return; + } + + + + const request = extractSourceRequest(payload.component) + const Factory = await this.transport.component.loadNode(request, context); + + const node = new Factory({ + id: payload.id, + graph + }); + //Note that we are spreading annotations here. This is so that node specific ones do not get overriden and we add them + node.annotations = { + ...node.annotations, + 'ui.position.x': payload.metadata.x, + 'ui.position.y': payload.metadata.y, + }; + } + + + + removeNode(graph: Graph, payload: RemoveNode, context: Context) { + if (!payload.id) { + this.send('error', new Error('No ID supplied'), context); + return; + } + graph.removeNode(payload.id); + } + + renameNode(graph: Graph, payload: RenameNode, context: Context) { + this.send('error', new Error('Unimplemented'), context); + } + + changeNode(graph: Graph, payload: ChangeNode, context) { + if (!payload.id && !payload.metadata) { + this.send('error', new Error('No id or metadata supplied'), context); + return; + } + //Get the node + // const node = graph.getNode(payload.id); + + this.send('error', new Error('Unimplemented'), context); + } + + addEdge(graph: Graph, edge: AddEdge, context: Context) { + if (!edge.src && !edge.tgt) { + this.send('error', new Error('No src or tgt supplied'), context); + return; + } + + const source = graph.getNode(edge.src.node); + const srcHandle = source.outputs[edge.src.port]; + const target = graph.getNode(edge.tgt.node); + const tgtHandle = target.inputs[edge.tgt.port]; + const variadicIndex = edge.tgt.index || -1; + + + graph.connect(source, srcHandle, target, tgtHandle, variadicIndex); + } + + removeEdge(graph: Graph, edge, context) { + if (!edge.src && !edge.tgt) { + this.send('error', new Error('No src or tgt supplied'), context); + return; + } + + //Note this is different to how the protocol specifies it as we do not care about the individual ids + this.send('error', new Error('Unimplemented'), context); + } + + changeEdge(graph: Graph, edge, context) { + if (!edge.src && !edge.tgt) { + this.send('error', new Error('No src or tgt supplied'), context); + return; + } + this.send('error', new Error('Unimplemented'), context); + } + + addInitial(graph: Graph, payload: AddInitial, context: Context) { + if (!payload.src && !payload.tgt) { + this.send('error', new Error('No src or tgt supplied'), context); + return; + } + + const node = graph.getNode(payload.tgt.node); + if (!node) { + this.send('error', new Error(`Node ${payload.tgt.node} not found`), context); + return; + } + + //Look for the input + const input = node.inputs[payload.tgt.port]; + if (!input) { + this.send('error', new Error(`Input ${payload.tgt.port} not found`), context); + return; + } + + //TODO handle variadic updates variadic + + input.setValue(payload.src.data); + } + + removeInitial(graph: Graph, payload, context) { + if (!payload.tgt) { + this.send('error', new Error('No tgt supplied'), context); + return; + } + + const node = graph.getNode(payload.tgt.node); + if (!node) { + this.send('error', new Error(`Node ${payload.tgt.node} not found`), context); + return; + } + + //Look for the input + const input = node.inputs[payload.tgt.port]; + if (!input) { + this.send('error', new Error(`Input ${payload.tgt.port} not found`), context); + return; + } + //TODO handle variadic updates variadic + input.reset(); + } + + removeInport(graph: Graph, payload: RemoveInport, context) { + if (!payload.public) { + this.send('error', new Error('Missing exported inport name'), context); + return; + } + + //Find the input port + const inputNode = findInput(graph); + inputNode?.removeInput(payload.public); + } + + renameInport(graph: Graph, payload, context) { + if (!payload.from && !payload.to) { + this.send('error', new Error('No from or to supplied'), context); + return; + } + //TODO we currently do not support renaming inports due to atomic concerns + this.send('error', new Error('Unimplemented'), context); + } + + addOutport(graph: Graph, payload: AddOutport, context: Context) { + if (!payload.public && !payload.node && !payload.port) { + this.send('error', new Error('Missing exported outport information'), context); + return; + } + + const output = findOutput(graph); + + output.addInput(payload.public, payload.metadata); + } + + removeOutport(graph: Graph, payload: RemoveOutport, context) { + if (!payload.public) { + this.send('error', new Error('Missing exported outport name'), context); + return; + } + const output = findOutput(graph); + output.removeInput(payload.public); + } + + renameOutport(graph: Graph, payload, context) { + if (!payload.from && !payload.to) { + this.send('error', new Error('No from or to supplied'), context); + return; + } + //TODO we currently do not support renaming inports due to atomic concerns + this.send('error', new Error('Unimplemented'), context); + } + + addGroup(graph: Graph, payload, context) { + if (!payload.name && !payload.nodes && !payload.metadata) { + this.send('error', new Error('No name or nodes or metadata supplied'), context); + return; + } + this.send('error', new Error('Unimplemented'), context); + } + + removeGroup(graph: Graph, payload, context) { + if (!payload.name) { + this.send('error', new Error('No name supplied'), context); + return; + } + this.send('error', new Error('Unimplemented'), context); + } + + renameGroup(graph: Graph, payload, context) { + if (!payload.from && !payload.to) { + this.send('error', new Error('No from or to supplied'), context); + return; + } + this.send('error', new Error('Unimplemented'), context); + } + + changeGroup(graph: Graph, payload, context) { + if (!payload.name && !payload.metadata) { + this.send('error', new Error('No name or metadata supplied'), context); + return; + } + this.send('error', new Error('Unimplemented'), context); + } +} \ No newline at end of file diff --git a/packages/fbp/src/protocol/network.ts b/packages/fbp/src/protocol/network.ts new file mode 100644 index 000000000..e69104a4a --- /dev/null +++ b/packages/fbp/src/protocol/network.ts @@ -0,0 +1,304 @@ +import { Graph, annotatedPlayState, annotatedVariadicIndex } from "@tokens-studio/graph-engine"; +import { Transport } from "../interfaces/transport"; + +import { Context } from "../interfaces/context"; +import { Data, Debug, Error as ErrorMessage, Persist, ProcessError, Start, Started, Status, Stopped } from "../messages/network"; +import { + EventEmitter, +} from 'events'; +import { Flowtrace } from "flowtrace"; + + + +type MessageLookup = { + data: Data, + status: Status, + error: ErrorMessage, + stopped: Stopped + started: Started, + processerror: ProcessError +} +type MessageType = MessageLookup[T]; + +type Events = { + addnetwork: (network: Network, graphID: string, networks: Record) => void, + removenetwork: (network: Network, graphID: string, networks: Record) => void, +} + + +export class Network { + + graph: Graph + debug = false + startTime = -1 + started = false + flowTrace?: Flowtrace + + constructor(graph: Graph) { + this.graph = graph; + } + + setDebug(enable: boolean) { + this.debug = enable; + } + isDebug() { + return this.debug + } + uptime() { + return Date.now() - this.startTime + } + + getNode(id: string) { + return this.graph.getNode(id); + } + + async start() { + this.startTime = Date.now(); + this.started = true; + this.graph.start() + } + isStarted() { + return this.started + } + /** + * If isStarted is true and isRunning is not, it is indicative of a crash + */ + isRunning() { + return this.graph.annotations[annotatedPlayState] == 'playing' + } + /** + * This is currently async because it is possible to have async operations in the future + */ + async stop() { + this.graph.stop(); + } + + + + setFlowtrace(flowTrace: Flowtrace) { + this.flowTrace = flowTrace; + //TODO we should have the tracer listen to emitted events for tracing + }; + +} + + +export interface NetworkProtocolEvents { + //Declare a strongly typed event emitter handler for 'on' + on(event: T, listener: Events[T]): this; + + //Declare a strongly typed event emitter handler for 'emit' + emit(event: T, ...args: Parameters): boolean; + +} + +/** + * Handles communications related to running a FBP graph + */ +export class NetworkProtocol extends EventEmitter implements NetworkProtocolEvents { + + transport: Transport; + + networks: Record + + constructor(transport: Transport) { + super(); + this.transport = transport; + this.networks = {}; + } + + send

(topic: P, payload: MessageType

, context: Context) { + return this.transport.send('network', topic, payload, context); + } + + sendAll

(topic: P, payload: MessageType

, context: Context) { + return this.transport.sendAll('network', topic, payload, context); + } + + receive(topic, payload, context) { + const graph = this.resolveGraph(payload, context); + if (!graph) { return; } + switch (topic) { + case 'persist': + this.persistNetwork(graph, payload, context); break; + case 'start': + this.startNetwork(graph, payload, context); break; + case 'stop': + this.stopNetwork(graph, payload, context); break; + case 'edges': + this.updateEdges(graph, payload, context); break; + case 'debug': + this.debugNetwork(graph, payload, context); break; + case 'getstatus': + this.getStatus(graph, payload, context); break; + default: this.send('error', new Error(`network:${topic} not supported`), context); + } + } + /** + * @TODO + */ + persistNetwork(graph: Graph, payload: Persist, context: Context) { + this.send('error', new Error('Not implemented'), context); + } + + resolveGraph(payload, context) { + if (!payload.graph) { + this.send('error', new Error('No graph specified'), context); + return null; + } + if (!this.transport.graph.graphs[payload.graph]) { + this.send('error', new Error('Requested graph not found'), context); + return null; + } + return this.transport.graph.graphs[payload.graph]; + } + + getNetwork(graphName) { + if (!graphName) { + return null; + } + if (!this.networks[graphName]) { + return null; + } + return this.networks[graphName]; + } + + updateEdges(graph, payload, context) { + + //We should be interacting with the tracer here and creating a filter for the user + + this.send('error', new Error('Not implemented'), context); + } + + + /** + * Creates a network instance for a graph + * @param graph + * @param graphID + * @param context + * @returns + */ + async initNetwork(graph: Graph, graphID: string, context: Context) { + // Ensure we stop previous network + const existingNetwork = this.getNetwork(graphID); + if (existingNetwork) { + await existingNetwork.stop(); + delete this.networks[graphID]; + this.emit('removenetwork', existingNetwork, graphID, this.networks); + return this.initNetwork(graph, graphID, context); + } + + + //At this moment, a network is just a graph, however in the future this will likely change to include more information + + const network = new Network(graph); + this.networks[graphID] = network; + this.emit('addnetwork', network, graphID, this.networks); + + //Add subscriptions on the network for the graph + + //Whenever data is sent, we want to send it to the transport + graph.on('valueSent', (edges) => { + edges.map((edge) => { + this.sendAll('data', { + id: edge.id, + graph: graphID, + //TODO + subgraph: [], + src: { + node: edge.source, + port: edge.sourceHandle, + + }, + tgt: { + node: edge.target, + port: edge.targetHandle, + index: edge.annotations[annotatedVariadicIndex] as number + }, + + }, context) + }); + }); + + graph.on('processError', (processError) => { + //Only emit in debug mode + if (network.isDebug()) { + this.sendAll('processerror', { + id: processError.node.id, + error: processError.error.message, + graph: graphID + }, context) + } + }) + } + + + async startNetwork(graph: Graph, payload: Start, context: Context) { + + let network; + const existingNetwork = this.getNetwork(payload.graph); + if (existingNetwork) { + // already initialized + existingNetwork.start(); + network = existingNetwork; + } else { + network = await this.initNetwork(graph, payload.graph, context); + network.start(); + } + + this.send('started', { + time: new Date().toISOString(), + graph: payload.graph, + running: network.isRunning(), + started: network.isStarted(), + debug: network.isDebug(), + }, + context); + } + + async stopNetwork(graph, payload, context) { + const net = this.getNetwork(payload.graph); + if (!net) { + this.send('error', new Error(`Network ${payload.graph} not found`), context); + return; + } + if (net.isStarted()) { + await net.stop() + } + // Was already stopped, just send the confirmation + this.send('stopped', { + time: new Date().toISOString(), + graph: payload.graph, + running: net.isRunning(), + started: net.isStarted(), + uptime: net.uptime(), + debug: net.isDebug() + }, + context); + } + + debugNetwork(graph, payload: Debug, context) { + const net = this.getNetwork(payload.graph); + if (!net) { + this.send('error', new Error(`Network ${payload.graph} not found`), context); + return; + } + net.setDebug(payload.enable); + } + + getStatus(graph, payload, context) { + const net = this.getNetwork(payload.graph); + if (!net) { + this.send('error', new Error(`Network ${payload.graph} not found`), context); + return; + } + this.send('status', { + graph: payload.graph, + running: net.isRunning(), + started: net.isStarted(), + uptime: net.uptime(), + debug: net.isDebug(), + }, + context); + } +} diff --git a/packages/fbp/src/protocol/runtime.ts b/packages/fbp/src/protocol/runtime.ts new file mode 100644 index 000000000..76a3446fc --- /dev/null +++ b/packages/fbp/src/protocol/runtime.ts @@ -0,0 +1,367 @@ +import { Context } from '../interfaces/context'; +import { + EventEmitter, +} from 'events'; +import { GetRuntime, Packet, PacketSent, Ports, Runtime } from '../messages/runtime'; +import { Graph, Input, Output, Port } from '@tokens-studio/graph-engine'; +import { Network } from './network'; +import { PortDefinition } from '../interfaces/port'; +import { ServerMessage } from '../messages/util'; +import { Transport } from '../interfaces/transport'; +import {autorun} from 'mobx' +import { findOutput, getGraphID } from '../utils'; + +function sendToInport(port: Input, event, payload: unknown) { + + switch (event) { + case 'begingroup': throw new Error('unimplemented'); + case 'endgroup': throw new Error('unimplemented'); + case 'data': port.setValue(payload);; break; + default: { + // Ignored + } + } +} + +function findPort(network: Network, name: string, inPort: boolean): Port { + let internalPort; + if (!network || !network.graph) { return null; } + + //Find the input and output nodes + const input = Object.values(network.graph.nodes).find((node) => node.factory.type === 'studio.tokens.generic.input'); + const output = Object.values(network.graph.nodes).find((node) => node.factory.type === 'studio.tokens.generic.output'); + + if (inPort) { + internalPort = input.inputs[name]; + } else { + internalPort = output.outputs[name]; + } + + return internalPort; +} + + + +function portToPayload(portName: string, port: Port): PortDefinition { + const def = { + id: portName, + type: 'all', + addressable: false, + required: false, + } as PortDefinition; + if (!port) { return def; } + //Todo this needs to be more complex + def.type = port.type.type || 'any'; + def.schema = port.type.$id; + + //Assume the description exists on the json schema + def.description = port.annotations['engine.description'] || port.type.description; + def.addressable = (port as Input).variadic || false; + //TODO we don't support this yet + def.required = true; + return def; +} + +/** + * Extracts the ports from a graph + * @param graphId + * @param network + * @returns + */ +function portsPayload(graphId: string, network: Network) { + const payload = { + graph: graphId, + inPorts: [], + outPorts: [], + }; + if (!(network != null ? network.graph : undefined)) { return payload; } + + //Find the input and output nodes + + //We assume it can be found + const input = Object.values(network.graph.nodes).find((node) => node.factory.type === 'studio.tokens.generic.input'); + const output = Object.values(network.graph.nodes).find((node) => node.factory.type === 'studio.tokens.generic.output'); + + Object.values(input.inputs).forEach((port) => { + payload.inPorts.push(portToPayload(port.name, port)) + }); + Object.values(output.inputs).forEach((port) => { + payload.inPorts.push(portToPayload(port.name, port)) + }); + return payload; +} + + +export type RuntimeDefinition = { + type: string; + version: string; + id: string; + label?: string; + namespace?: string; + repository?: string; + repositoryVersion?: string; + allCapabilities?: string[]; + capabilities?: string[]; + graph?: string + +} + +type MessageLookup = { + ports: Ports, + error: Error, + runtime: ServerMessage, + packet: Packet, + packetsent: PacketSent +} + +type MessageType = MessageLookup[T]; + + + +export class RuntimeProtocol extends EventEmitter { + + transport: Transport; + outputSockets: Record> + /** + * The main graph of the runtime + */ + mainGraph: Graph | null; + + constructor(transport: Transport) { + super(); + this.transport = transport; + this.outputSockets = {}; // graphId -> portName -> Output + this.mainGraph = null; + + this.transport.network.on('removenetwork', () => { + //TODO handle unsubscribe + }); + } + + registerNetwork(name, network) { + this.subscribeExportedPorts(name, network, true); + this.subscribeOutPorts(name, network); + this.sendPorts(name, network); + + if (network.isStarted()) { + // processes don't exist until started + this.subscribeToOutputData(name, network, true); + } + network.once('start', () => { + // processes don't exist until started + this.subscribeToOutputData(name, network, true); + }); + } + + send

(topic: P, payload: MessageType

, context?: Context) { + return this.transport.send('runtime', topic, payload, context); + } + + sendAll

(topic: P, payload: MessageType

, context?: Context) { + return this.transport.sendAll('runtime', topic, payload, context); + } + + sendError(err, context) { + return this.send('error', err, context); + } + + async receive(topic, payload, context) { + switch (topic) { + case 'getruntime': return this.getRuntime(payload, context); + case 'packet': + try { + await this.sendPacket(payload); + this.send('packetsent', payload as Packet, context); + } catch (err) { + this.sendError(err, context); + } + break; + default: return this.send('error', new Error(`runtime:${topic} not supported`), context); + } + } + + getRuntimeDefinition(): RuntimeDefinition { + const { + type, + } = this.transport.options; + + const payload: RuntimeDefinition = { + id: this.transport.options.id || 'unknown', + type, + version: this.transport.version, + }; + + + // Add project metadata if available + if (this.transport.options.label) { payload.label = this.transport.options.label; } + if (this.transport.options.namespace) { payload.namespace = this.transport.options.namespace; } + if (this.transport.options.repository) { + payload.repository = this.transport.options.repository; + } + if (this.transport.options.repositoryVersion) { + payload.repositoryVersion = this.transport.options.repositoryVersion; + } + + return payload; + } + + getRuntime(request: GetRuntime, context: Context) { + const payload = this.getRuntimeDefinition(); + + const { + capabilities, + } = this.transport.options; + const secret = request ? request.secret : null; + payload.allCapabilities = capabilities; + payload.capabilities = capabilities.filter( + (capability) => this.transport.canDo(capability, secret), + ); + + if (this.mainGraph) { + payload.graph = getGraphID(this.mainGraph); + } + + this.send('runtime', payload as Runtime, context); + // send port info about currently set up networks + return (() => { + const result = []; + Object.keys(this.transport.network.networks).forEach((name) => { + const network = this.transport.network.getNetwork(name); + result.push(this.sendPorts(name, network, context)); + }); + return result; + })(); + } + + sendPorts(graphId: string, network: Network, context?: Context) { + const payload = portsPayload(graphId, network); + this.emit('ports', payload); + + if (!context) { + return this.sendAll('ports', payload); + } + return this.send('ports', payload, context); + } + + setMainGraph(graph: Graph) { + this.mainGraph = graph; + } + // XXX: should send updated runtime info? + + subscribeExportedPorts(name, network, add) { + const sendExportedPorts = () => this.sendPorts(name, network); + const dependencies = [ + 'addInport', + 'addOutport', + 'removeInport', + 'removeOutport', + ]; + dependencies.forEach((d) => { + network.graph.removeListener(d, sendExportedPorts); + }); + + if (add) { + const result = []; + dependencies.forEach((d) => { + result.push(network.graph.on(d, sendExportedPorts)); + }); + } + } + + subscribeOutPorts(name: string, network: Network) { + const portRemoved = () => this.subscribeToOutputData(name, network, false); + const portAdded = () => this.subscribeToOutputData(name, network, true); + + const { + graph, + } = network; + + + graph.on('outputPortAdded', portAdded); + graph.on('outputPortRemoved', portRemoved); + + } + + subscribeToOutputData(graphId: string, network: Network, add: boolean) { + // Unsubscribe all + if (!this.outputSockets[graphId]) { + this.outputSockets[graphId] = {}; + } + let graphSockets = this.outputSockets[graphId]; + + + + graphSockets = {}; + + if (!add) { return; } + + //Find the input and output nodes + + + + + + const output = findOutput(network.graph); + + +autorun + + Object.keys(output.outputs).forEach((pub) => { + const internal = network.graph.outports[pub]; + const socket = noflo.internalSocket.createSocket(); + graphSockets[pub] = socket; + const { + component, + } = network.processes[internal.process]; + if (!(component != null ? component.outPorts[internal.port] : undefined)) { + throw new Error(`Exported outport ${internal.port} in node ${internal.process} not found`); + } + component.outPorts[internal.port].attach(socket); + let event; + socket.on('ip', (ip) => { + switch (ip.type) { + case 'openBracket': + event = 'begingroup'; + break; + case 'closeBracket': + event = 'endgroup'; + break; + default: + event = ip.type; + } + this.emit('packet', { + port: pub, + event, + graph: graphId, + payload: ip.data, + }); + this.sendAll('packet', { + port: pub, + event, + graph: graphId, + payload: ip.data, + } as Packet); + }); + }); + } + + /** + * Sends a packet into a port + */ + sendPacket(payload: Packet) { + return new Promise((resolve, reject) => { + const network = this.transport.network.getNetwork(payload.graph); + if (!network) { + reject(new Error(`Cannot find network for graph ${payload.graph}`)); + return; + } + const port = findPort(network, payload.port, true); + if (!port) { + reject(new Error(`Cannot find internal port for ${payload.port}`)); + return; + } + sendToInport(port as Input, payload.event, payload.payload); + resolve(); + }); + } +} diff --git a/packages/fbp/src/protocol/trace.ts b/packages/fbp/src/protocol/trace.ts new file mode 100644 index 000000000..22e848624 --- /dev/null +++ b/packages/fbp/src/protocol/trace.ts @@ -0,0 +1,135 @@ +import { Clear, Dump, Error as ErrorMessage, Start, Stop } from '../messages/trace'; +import { Context } from '../interfaces/context'; +import { Flowtrace } from 'flowtrace'; +import { Network } from './network'; +import { Transport } from '../interfaces/transport'; + + +type MessageLookup = { + error: ErrorMessage, + start: Start, + stop: Stop, + dump: Dump, + clear: Clear +} + +type MessageType = MessageLookup[T]; + +/** + * Provides communications related to tracing a FBP network + */ +export class TraceProtocol { + transport: Transport + /** + * Lookup for traces by graph ID + */ + traces: Record + constructor(transport: Transport) { + this.transport = transport; + this.traces = {}; + } + + send

(topic: P, payload: MessageType

, context:Context) { + return this.transport.send('trace', topic, payload, context); + } + + sendAll

(topic: P, payload: MessageType

) { + return this.transport.sendAll('trace', topic, payload); + } + + receive(topic, payload, context) { + switch (topic) { + case 'start': { + this.start(payload, context); + break; + } + case 'stop': { + this.stop(payload, context); + break; + } + case 'dump': { + this.dump(payload, context); + break; + } + case 'clear': { + this.clear(payload, context); + break; + } + default: { + this.send('error', new Error(`trace:${topic} not supported`), context); + } + } + } + + resolveTracer(graph: string, context:Context) { + if (!graph) { + this.send('error', new Error('No graph specified'), context); + return null; + } + if (!this.traces[graph]) { + this.send('error', new Error(`Trace for requested graph '${graph}' not found`), context); + return null; + } + return this.traces[graph]; + } + + startTrace(graphId: string, network: Network, buffersize = 400) { + const metadata = this.transport.runtime.getRuntimeDefinition(); + const tracer = this.traces[graphId] = this.traces[graphId] || new Flowtrace(metadata, buffersize); + network.setFlowtrace(tracer); + return tracer; + } + + start(payload, context) { + const network = this.transport.network.getNetwork(payload.graph); + if (!network) { + this.send('error', new Error(`Network for requested graph '${payload.graph}' not found`), context); + return; + } + const buffersize = payload.buffersize || 400; + this.startTrace(payload.graph, network, buffersize); + this.sendAll('start', { + graph: payload.graph, + buffersize, + }); + } + + stop(payload:Stop, context:Context) { + const tracer = this.resolveTracer(payload.graph, context); + if (!tracer) { + return; + } + const network = this.transport.network.getNetwork(payload.graph); + if (!network) { + this.send('error', new Error(`Network for requested graph '${payload.graph}' not found`), context); + return; + } + network.setFlowtrace(null); + this.sendAll('stop', { + graph: payload.graph, + }); + } + + dump(payload:Dump, context:Context) { + const tracer = this.resolveTracer(payload.graph, context); + if (!tracer) { + return; + } + this.send('dump', { + graph: payload.graph, + type: 'flowtrace.json', + flowtrace: tracer.toJSON(), + }, context); + } + + clear(payload: Clear, context:Context) { + const tracer = this.resolveTracer(payload.graph, context); + if (!tracer) { + return; + } + tracer.clear(); + this.sendAll('clear', { + graph: payload.graph, + }); + } +} diff --git a/packages/fbp/src/runtime.ts b/packages/fbp/src/runtime.ts new file mode 100644 index 000000000..4fb6b6fd1 --- /dev/null +++ b/packages/fbp/src/runtime.ts @@ -0,0 +1,63 @@ +import { BaseTransport } from './base'; + + +export type RawMessage = { + protocol: string, + command: string, + payload: unknown, +} + +export interface Client { + on(event: 'send', listener: (msg: unknown) => void): this; + _receive(msg: RawMessage): void; +} + +export class Runtime extends BaseTransport { + + clients: TClient[]; + + constructor(options) { + super(options); + this.clients = []; + } + + _connect(client: TClient) { + this.clients.push(client); + client.on('send', (msg) => { + // Capture context + this._receive(msg, { client }); + }); + } + + _disconnect(client) { + if (this.clients.indexOf(client) === -1) { return; } + this.clients.splice(this.clients.indexOf(client), 1); + client.removeAllListeners('send'); // XXX: a bit heavy + } + + _receive(msg, context) { + // Forward to Base + this.receive(msg.protocol, msg.command, msg.payload, context); + } + + send(protocol, topic, payload, context) { + if (!context || !context.client) { return; } + const m = { + protocol, + command: topic, + payload, + }; + context.client._receive(m); + } + + sendAll(protocol, topic, payload) { + const m = { + protocol, + command: topic, + payload, + }; + this.clients.forEach((client) => { + client._receive(m); + }); + } +} diff --git a/packages/fbp/src/utils.ts b/packages/fbp/src/utils.ts new file mode 100644 index 000000000..093fa4761 --- /dev/null +++ b/packages/fbp/src/utils.ts @@ -0,0 +1,41 @@ +import { Graph } from "@tokens-studio/graph-engine"; +import { graphID } from "./annotations"; + +export const parseName = (name: string) => { + if (name.indexOf('/') === -1) { + return { + library: null, + name, + }; + } + const nameParts = name.split('/'); + return { + library: nameParts[0], + name: nameParts[1], + }; +}; + +export const withNamespace = (name: string, namespace?: string) => { + if (!namespace || name.indexOf('/') !== -1) { + return name; + } + return `${namespace}/${name}`; +}; + +export const withoutNamespace = (name) => { + if (name.indexOf('/') === -1) { + return name; + } + return name.split('/')[1]; +}; + +export const getGraphID = (graph: Graph):string => { + return graph.annotations[graphID] as string; +} + +export const findInput = (graph: Graph) => { + return Object.values(graph.nodes).find((x) => x.factory.type === 'studio.tokens.generic.input'); +} +export const findOutput = (graph: Graph) => { + return Object.values(graph.nodes).find((x) => x.factory.type === 'studio.tokens.generic.output'); +} diff --git a/packages/fbp/tests/data/basic.json b/packages/fbp/tests/data/basic.json new file mode 100644 index 000000000..488f93978 --- /dev/null +++ b/packages/fbp/tests/data/basic.json @@ -0,0 +1,72 @@ +{ + "nodes": [ + { + "id": "a68cc882-ffb4-4d4c-9346-317c35925d8e", + "type": "studio.tokens.generic.input", + "inputs": [ + { + "name": "value", + "type": { + "$id": "https://schemas.tokens.studio/number.json", + "title": "Number", + "type": "number" + }, + "annotations": { + "ui.deletable": true + } + } + ], + "annotations": { + "engine.singleton": true, + "engine.dynamicInputs": true, + "ui.position.x": -492.13661146256345, + "ui.position.y": 125.86024357287201 + } + }, + { + "id": "410ec63b-1c1b-4c43-9b84-dc8418836015", + "type": "studio.tokens.generic.output", + "inputs": [ + { + "name": "value", + "type": { + "$id": "https://schemas.tokens.studio/number.json", + "title": "Number", + "type": "number" + }, + "visible": true, + "annotations": { + "ui.deletable": true + } + } + ], + "annotations": { + "engine.singleton": true, + "engine.dynamicInputs": true, + "ui.position.x": 100.5804403316514, + "ui.position.y": 126.98008421039714 + } + } + ], + "edges": [ + { + "id": "ccb549aa-ccf3-43e2-89b8-5106e9e2f85b", + "source": "a68cc882-ffb4-4d4c-9346-317c35925d8e", + "sourceHandle": "value", + "target": "410ec63b-1c1b-4c43-9b84-dc8418836015", + "targetHandle": "value" + } + ], + "annotations": { + "engine.id": "55b715d2-73d3-4323-9e5d-1879c826cb63", + "engine.capabilities.web-audio": "0.0.0", + "engine.capabilities.fs": "0.0.0", + "engine.version": "0.12.0", + "ui.viewport": { + "x": 439.59623478858055, + "y": 149.851518636609, + "zoom": 0.6944060200479415 + }, + "ui.version": "2.9.4" + } +} \ No newline at end of file diff --git a/packages/fbp/tests/suites/base.test.ts b/packages/fbp/tests/suites/base.test.ts new file mode 100644 index 000000000..69408f95b --- /dev/null +++ b/packages/fbp/tests/suites/base.test.ts @@ -0,0 +1,49 @@ +import { Graph, SerializedGraph, nodeLookup } from "@tokens-studio/graph-engine"; +import { Runtime } from "../../src/runtime"; +import { loader } from "../utils/loader"; +import Basic from '../data/basic.json' + +describe('Base interface', () => { + describe('without a graph', () => { + it('should become ready without network', (done) => { + const rt = new Runtime({ + loader + }); + rt.on('ready', (net) => { + expect(net).toEqual(null); + done(); + }); + }); + }); + describe('with a working default graph', () => { + it('should register and run a network', (done) => { + + const graph = new Graph().deserialize(Basic as unknown as SerializedGraph, nodeLookup); + let readyReceived = false; + let startReceived = false; + + const rt = new Runtime({ + defaultGraph: graph, + loader + }); + + rt.on('ready', (net) => { + expect(typeof net).toEqual('object'); + expect(typeof net.start).toEqual('function'); + expect(typeof net.graph).toEqual(graph); + readyReceived = true; + }); + rt.network.on('addnetwork', (network) => { + network.on('start', () => { + startReceived = true; + }); + network.on('end', () => { + expect(readyReceived).toEqual(true); + expect(startReceived).toEqual(true); + done(); + }); + }); + + }); + }); +}); \ No newline at end of file diff --git a/packages/fbp/tests/suites/component.test.ts b/packages/fbp/tests/suites/component.test.ts new file mode 100644 index 000000000..ab0ebfce7 --- /dev/null +++ b/packages/fbp/tests/suites/component.test.ts @@ -0,0 +1,142 @@ +import { Client } from "../utils/client"; +import { Runtime } from "../../src/runtime"; + +describe('Component protocol', () => { + let runtime = null; + let client = null; + let client2 = null; + let runtimeEvents = []; + + beforeEach(() => { + runtime = new Runtime({ + permissions: { + foo: [ + 'protocol:component', + 'component:setsource', + 'component:getsource', + ], + }, + }); + runtime.component.on('updated', (msg) => runtimeEvents.push(msg)); + client = new Client(runtime); + client.connect(); + client2 = new Client(runtime); + return client2.connect(); + }); + afterEach(() => { + client.disconnect(); + client = null; + client2.disconnect(); + client2 = null; + runtime = null; + }); + + describe('sending component:list', () => { + it('should fail without proper authentication', () => { + const payload = {}; + return client.send('component', 'list', payload) + .then( + () => Promise.reject(new Error('Unexpected success')), + () => true, + ); + }); + return it('should respond with list of components and a componentsready', (done) => { + const payload = { secret: 'foo' }; + let componentsReceived = 0; + const listener = (msg) => { + expect(msg.protocol).toEqual('component'); + expect([ + 'component', + 'componentsready', + ]).toContain(msg.command); + if (msg.command === 'componentsready') { + expect(msg.payload).toEqual(componentsReceived); + done(); + return; + } + componentsReceived += 1; + client.once('message', listener); + }; + client.once('message', listener); + client.send('component', 'list', payload); + }); + }); + + describe('sending component:getsource', () => { + it('should fail without proper authentication', () => { + const payload = { name: 'core/Repeat' }; + return client.send('component', 'getsource', payload) + .then( + () => Promise.reject(new Error('Unexpected success')), + () => true, + ); + }); + return it('should respond with the source code of the component', () => { + const msg = { + name: 'core/Repeat', + secret: 'foo', + }; + return client.send('component', 'getsource', msg) + .then((payload) => { + expect(payload.library).toEqual('core'); + expect(payload.name).toEqual('Repeat'); + expect([ + 'javascript', + 'coffeescript', + ]).toContain(payload.language); + expect(typeof payload.code).toEqual('string'); + }); + }); + }); + + return describe('sending component:source', () => { + const source = `\ +var noflo = require('noflo'); +exports.getComponent = function () { + return noflo.asComponent(Math.random); +};\ +`; + beforeAll(() => runtimeEvents = []); + afterAll(() => runtimeEvents = []); + it('should fail without proper authentication', () => { + const payload = { + name: 'GetRandom', + library: 'math', + language: 'javascript', + code: source, + tests: '', + }; + return client.send('component', 'source', payload) + .then( + () => Promise.reject(new Error('Unexpected success')), + () => true, + ); + }); + it('should not have emitted updated events', () => expect(runtimeEvents).toEqual([])); + it('should respond with a new component', (done) => { + const payload = { + name: 'GetRandom', + library: 'math', + language: 'javascript', + code: source, + tests: '', + secret: 'foo', + }; + client.once('message', (msg) => { + expect(msg.protocol).toEqual('component'); + expect(msg.command).toEqual('component'); + expect(msg.payload.name).toEqual('math/GetRandom'); + return done(); + }); + client.send('component', 'source', payload); + }); + return it('should have emitted a updated event', () => { + expect(runtimeEvents.length).toEqual(1); + const event = runtimeEvents.shift(); + expect(event.name).toEqual('GetRandom'); + expect(event.library).toEqual('math'); + expect(event.language).toEqual('javascript'); + expect(event.code).toEqual(source); + }); + }); +}); \ No newline at end of file diff --git a/packages/fbp/tests/suites/graph.test.ts b/packages/fbp/tests/suites/graph.test.ts new file mode 100644 index 000000000..c62b5fdeb --- /dev/null +++ b/packages/fbp/tests/suites/graph.test.ts @@ -0,0 +1,132 @@ +import { Client } from "../utils/client"; +import { Runtime } from "../../src/runtime"; + +describe('Graph protocol', () => { + let runtime = null; + let client = null; + let client2 = null; + let runtimeEvents = []; + + beforeEach(() => { + runtime = new Runtime({ + permissions: { + foo: ['protocol:graph'], + } + }); + runtime.graph.on('updated', (msg) => runtimeEvents.push(msg)); + client = new Client(runtime); + client.connect(); + client2 = new Client(runtime); + client2.connect(); + }); + afterEach(() => { + client.disconnect(); + client = null; + client2.disconnect(); + client2 = null; + runtime = null; + }); + + describe('sending graph:clear', () => { + it('should fail without proper authentication', () => { + const payload = { + id: 'mygraph', + main: true, + }; + return client.send('graph', 'clear', payload) + .then( + () => Promise.reject(new Error('Unexpected success')), + () => true, + ); + }); + it('should respond with graph:clear', () => { + const payload = { + id: 'mygraph', + main: true, + secret: 'foo', + }; + return client.send('graph', 'clear', payload) + .then((msg) => { + expect(msg).toHaveProperty('id'); + expect(msg.id).toEqual(payload.id); + }); + }); + it('should send to all clients', (done) => { + const payload = { + id: 'mygraph', + main: true, + secret: 'foo', + }; + client2.once('message', (msg) => { + expect(msg.protocol).toEqual('graph'); + expect(msg.command).toEqual('clear'); + expect(msg.payload).toHaveProperty('id'); + expect(msg.payload.id).toEqual(payload.id); + done(); + }); + client.send('graph', 'clear', payload) + .catch(done); + }); + }); + + describe('sending graph:addnode', () => { + const graph = 'graphwithnodes'; + const payload = { + id: 'node1', + component: 'core/Repeat', + graph, + metadata: {}, + }; + const authenticatedPayload = JSON.parse(JSON.stringify(payload)); + authenticatedPayload.secret = 'foo'; + const checkAddNode = function (msg, done) { + if (msg.command === 'error') { + done(msg.payload); + return; + } + if (msg.command !== 'addnode') { + return; + } + expect(msg.protocol).toEqual('graph'); + expect(msg.payload).toEqual(payload); + done(); + }; + afterAll(() => { + runtimeEvents = []; + }); + it('should respond with graph:addnode', (done) => { + client.on('message', (msg) => checkAddNode(msg, done)); + client.send('graph', 'clear', { id: graph, main: true, secret: 'foo' }) + .then(() => client.send('graph', 'addnode', authenticatedPayload)) + .catch(done); + }); + it('should have emitted an updated event', () => { + expect(runtimeEvents.length).toEqual(1); + const event = runtimeEvents.shift(); + expect(event.name).toEqual(graph); + }); + it('should send to all clients', (done) => { + client2.on('message', (msg) => checkAddNode(msg, done)); + client.send('graph', 'clear', { id: graph, main: true, secret: 'foo' }) + .then(() => client.send('graph', 'addnode', authenticatedPayload)) + .catch(done); + }); + }); + + describe('sending graph:addnode without an existing graph', () => { + it('should respond with an error', () => client + .send('graph', 'addnode', { + id: 'foo', + component: 'Bar', + graph: 'not-found', + metadata: {}, + secret: 'foo', + }) + .then( + () => Promise.reject(new Error('Unexpected success')), + (err) => { + expect(err.message).toEqual('Requested graph not found'); + }, + )); + }); +}); \ No newline at end of file diff --git a/packages/fbp/tests/suites/network.test.ts b/packages/fbp/tests/suites/network.test.ts new file mode 100644 index 000000000..c3b2ddd51 --- /dev/null +++ b/packages/fbp/tests/suites/network.test.ts @@ -0,0 +1,126 @@ +import { Client } from "../utils/client"; +import { Runtime } from "../../src/runtime"; + +describe('Network protocol', () => { + let runtime = null; + let client: Client | null = null; + beforeAll(() => { + runtime = new Runtime({ + permissions: { + foo: ['protocol:graph', 'protocol:network'], + } + }); + }); + beforeEach(() => { + client = new Client(runtime); + client.connect(); + }); + afterEach(() => { + if (!client) { return; } + client.removeAllListeners('message'); + client.disconnect(); + client = null; + }); + + describe('defining a graph', () => { + it('should succeed', () => Promise.resolve() + .then(() => client.send('graph', 'clear', { + id: 'bar', + main: true, + secret: 'foo', + })) + .then(() => client.send('graph', 'addnode', { + id: 'Hello', + component: 'core/Repeat', + graph: 'bar', + secret: 'foo', + })) + .then(() => client.send('graph', 'addnode', { + id: 'World', + component: 'core/Drop', + graph: 'bar', + secret: 'foo', + })) + .then(() => client.send('graph', 'addedge', { + src: { + node: 'Hello', + port: 'out', + }, + tgt: { + node: 'World', + port: 'in', + }, + graph: 'bar', + secret: 'foo', + })) + .then(() => client.send('graph', 'addinitial', { + src: { + data: 'Hello, world!', + }, + tgt: { + node: 'Hello', + port: 'in', + }, + graph: 'bar', + secret: 'foo', + }))); + }); + describe('starting the network', () => { + it('should process the nodes and stop when it completes', (done) => { + const expects = [ + 'started', + 'data', + 'data', + 'stopped', + ]; + client.on('message', (msg) => { + if (msg.protocol !== 'network') { return; } + expect(msg.protocol).toEqual('network'); + expect(msg.command).toEqual(expects.shift()); + if (!expects.length) { + done(); + } + }); + client.send('network', 'start', { + graph: 'bar', + secret: 'foo', + }) + .catch(done); + }); + it('should provide a "finished" status', (done) => { + client.on('message', (msg) => { + expect(msg.protocol).toEqual('network'); + expect(msg.command).toEqual('status'); + expect(msg.payload.graph).toEqual('bar'); + expect(msg.payload.running).toEqual(false); + expect(msg.payload.started).toEqual(false); + done(); + }); + client.send('network', 'getstatus', { + graph: 'bar', + secret: 'foo', + }) + .catch(done); + }); + it('should be able to rename a node', () => client + .send('graph', 'renamenode', { + from: 'World', + to: 'NoFlo', + graph: 'bar', + secret: 'foo', + })); + it('should not be able to add a node with a non-existing component', () => client + .send('graph', 'addnode', { + id: 'Nonworking', + component: '404NotFound', + graph: 'bar', + secret: 'foo', + }) + .then( + () => Promise.reject(new Error('Unexpected success')), + (err) => { + expect(err.message).toContain('Component 404NotFound not available'); + }, + )); + }); +}); \ No newline at end of file diff --git a/packages/fbp/tests/suites/runtime.test.ts b/packages/fbp/tests/suites/runtime.test.ts new file mode 100644 index 000000000..0fb074726 --- /dev/null +++ b/packages/fbp/tests/suites/runtime.test.ts @@ -0,0 +1,160 @@ +import { Client } from "../utils/client"; +import { Runtime } from "../../src/runtime"; + +describe('Runtime protocol', () => { + let runtime = null; + let client = null; + + describe('sending runtime:getruntime', () => { + beforeEach(() => { + runtime = new Runtime({}); + client = new Client(runtime); + client.connect(); + }); + afterEach(() => { + client.disconnect(); + client = null; + runtime = null; + }); + it('should respond with runtime:runtime for unauthorized user', () => client + .send('runtime', 'getruntime', null) + .then((payload) => { + expect(payload.type).toEqual('noflo'); + expect(payload.capabilities).toEqual([]); + expect(payload.allCapabilities).toContain('protocol:graph'); + })); + it('should respond with runtime:runtime for authorized user', () => { + client.disconnect(); + runtime = new Runtime({ + permissions: { + 'super-secret': ['protocol:graph', 'protocol:component', 'unknown:capability'], + 'second-secret': ['protocol:graph', 'protocol:runtime'], + }, + }); + client = new Client(runtime); + client.connect(); + return client.send('runtime', 'getruntime', { + secret: 'super-secret', + }) + .then((payload) => { + expect(payload.type).toEqual('noflo'); + expect(payload.capabilities).toEqual(['protocol:graph', 'protocol:component']); + expect(payload.allCapabilities).toContain('protocol:graph'); + }); + }); + }); + describe('with a graph containing exported ports', () => { + let ports = null; + + beforeEach(() => { + runtime = new Runtime({ + permissions: { + 'second-secret': ['protocol:graph', 'protocol:runtime', 'protocol:network'], + } + }); + runtime.runtime.on('ports', (emittedPorts) => { + ports = emittedPorts; + }); + client = new Client(runtime); + client.connect(); + }); + afterEach(() => { + client.disconnect(); + client = null; + runtime = null; + runtime = new Runtime({}); + ports = null; + }); + it('should be possible to upload graph', () => Promise.resolve() + .then(() => client.send('graph', 'clear', { + id: 'bar', + main: true, + secret: 'second-secret', + })) + .then(() => client.send('graph', 'addnode', { + id: 'Hello', + component: 'core/Repeat', + graph: 'bar', + secret: 'second-secret', + })) + .then(() => client.send('graph', 'addnode', { + id: 'World', + component: 'core/Repeat', + graph: 'bar', + secret: 'second-secret', + })) + .then(() => client.send('graph', 'addedge', { + src: { + node: 'Hello', + port: 'out', + }, + tgt: { + node: 'World', + port: 'in', + }, + graph: 'bar', + secret: 'second-secret', + })) + .then(() => client.send('graph', 'addinport', { + public: 'in', + node: 'Hello', + port: 'in', + graph: 'bar', + secret: 'second-secret', + })) + .then(() => client.send('graph', 'addoutport', { + public: 'out', + node: 'World', + port: 'out', + graph: 'bar', + secret: 'second-secret', + }))); + it('should be possible to start the network', () => client + .send('network', 'start', { + graph: 'bar', + secret: 'second-secret', + })); + it('packets sent to IN should be received at OUT', (done) => { + const payload = { hello: 'World' }; + client.on('error', (err) => done(err)); + const messageListener = function (msg) { + if (msg.protocol !== 'runtime') { return; } + if (msg.command !== 'packet') { return; } + if (msg.payload.port !== 'out') { return; } + if (msg.payload.event !== 'data') { return; } + expect(msg.payload.payload).toEqual(payload); + client.removeListener('message', messageListener); + done(); + }; + client.on('message', messageListener); + client.send('runtime', 'packet', { + graph: 'bar', + port: 'in', + event: 'data', + payload, + secret: 'second-secret', + }) + .catch(done); + }); + it('should have emitted ports via JS API', () => { + expect(ports.inPorts.length).toEqual(1); + expect(ports.outPorts.length).toEqual(1); + }); + it('packets sent via JS API to IN should be received at OUT', (done) => { + const payload = { hello: 'JavaScript' }; + runtime.runtime.on('packet', (msg) => { + if (msg.event !== 'data') { return; } + expect(msg.payload).toEqual(payload); + done(); + }); + runtime.runtime.sendPacket({ + graph: 'bar', + port: 'in', + event: 'data', + payload, + secret: 'second-secret', + }) + .catch(done); + }); + }); +}); \ No newline at end of file diff --git a/packages/fbp/tests/suites/trace.test.ts b/packages/fbp/tests/suites/trace.test.ts new file mode 100644 index 000000000..9fbf23978 --- /dev/null +++ b/packages/fbp/tests/suites/trace.test.ts @@ -0,0 +1,5 @@ +describe('Tracer', () => { + + + return describe('tracing unserializable events', () => it('should drop only those events')); +}); diff --git a/packages/fbp/tests/utils/client.ts b/packages/fbp/tests/utils/client.ts new file mode 100644 index 000000000..6f7e5273b --- /dev/null +++ b/packages/fbp/tests/utils/client.ts @@ -0,0 +1,57 @@ +import { + EventEmitter, +} from 'events'; +import { Runtime } from '../../src/runtime'; + +/** + * An internal testing client + */ +export class Client extends EventEmitter { + + name: string; + runtime: Runtime; + + constructor(runtime: Runtime, name?: string) { + super(); + this.name = name; + this.runtime = runtime; + if (!this.name) { this.name = 'Unnamed client'; } + } + + connect() { + return this.runtime._connect(this); + } + + disconnect() { + return this.runtime._disconnect(this); + } + + send(protocol, topic, payload) { + return new Promise((resolve, reject) => { + const m = { + protocol, + command: topic, + payload, + }; + const onMsg = (msg) => { + if (msg.protocol !== protocol) { + // Unrelated, wait for next + this.once('message', onMsg); + } + if (msg.command === 'error') { + reject(new Error(msg.payload.message)); + return; + } + resolve(msg.payload); + }; + this.once('message', onMsg); + this.emit('send', m); + }); + } + + _receive(message) { + return setTimeout(() => { + this.emit('message', message); + }, 1); + } +} diff --git a/packages/fbp/tests/utils/loader.ts b/packages/fbp/tests/utils/loader.ts new file mode 100644 index 000000000..c49699799 --- /dev/null +++ b/packages/fbp/tests/utils/loader.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Loader } from "../../src/interfaces/loader"; + +export const loader:Loader={ + + listComponents: async () => { + return {}; + }, + registerComponent: (library: string, componentName: string) => { + return; + }, + setSource: async (library: string, componentName: string, code: string, language: string) => { + return; + }, + load: async (library: string, componentName: string) => { + return; + } +} \ No newline at end of file diff --git a/packages/fbp/tsconfig.docs.json b/packages/fbp/tsconfig.docs.json new file mode 100644 index 000000000..1c64bc52f --- /dev/null +++ b/packages/fbp/tsconfig.docs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.prod.json", + "compilerOptions": { + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts", "cypress/**"] +} diff --git a/packages/fbp/tsconfig.json b/packages/fbp/tsconfig.json new file mode 100644 index 000000000..8d7eac393 --- /dev/null +++ b/packages/fbp/tsconfig.json @@ -0,0 +1,38 @@ +{ + "ts-node": { + "esm": true + }, + "compilerOptions": { + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "noEmit": true, + "jsx": "react", + "target": "ESNext", + "outDir": "./types", + "preserveConstEnums": true, + "module": "esnext", + "moduleResolution": "node", + "rootDir": ".", + "noImplicitAny": false, + "esModuleInterop": true, + "experimentalDecorators": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "types": [ + "jest", + "node" + ], + "baseUrl": "." + }, + "include": [ + "stories/**/*", + "src/**/*", + "tests/**/*" + ], + "exclude": [ + "node_modules", + "cypress/**" + ] +} \ No newline at end of file diff --git a/packages/fbp/tsconfig.prod.json b/packages/fbp/tsconfig.prod.json new file mode 100644 index 000000000..b566c0fda --- /dev/null +++ b/packages/fbp/tsconfig.prod.json @@ -0,0 +1,40 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "plugins": [ + ], + "baseUrl": ".", + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "noEmit": true, + "strict": true, + "declarationDir": "./types", + "outDir": "./dist", + "preserveConstEnums": true, + "module": "esnext", + "moduleResolution": "node", + "rootDir": "./src", + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "typeRoots": [ + "../../node_modules/@types", + "node_modules/@types" + ], + "types": [ + "jest", + "node" + ] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "**/*.test.ts" + ] +} \ No newline at end of file diff --git a/packages/fbp/tsconfig.test.json b/packages/fbp/tsconfig.test.json new file mode 100644 index 000000000..55e5bad14 --- /dev/null +++ b/packages/fbp/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "node", + "lib": [ + "es5", + "es6", + "es2017", + "dom" + ], + "types": [ + "jest", + "node" + ] + } +} \ No newline at end of file diff --git a/packages/fbp/tsup.config.ts b/packages/fbp/tsup.config.ts new file mode 100644 index 000000000..97cbbaa8b --- /dev/null +++ b/packages/fbp/tsup.config.ts @@ -0,0 +1,19 @@ +import type { Options } from 'tsup'; + + +const env: string = process.env.NODE_ENV || 'development'; + +export const tsup: Options = { + splitting: true, + sourcemap: env === 'production', // source map is only available in prod + clean: false, + dts: true, // generate dts file for main module + format: ['cjs', 'esm'] , + bundle: false, + skipNodeModulesBundle: true, + entryPoints: ['src/index.ts'], + target: 'es2020', + outDir: 'dist', + entry: ['src/**/*.ts','src/**/*.tsx'], + esbuildPlugins: [] +}; \ No newline at end of file diff --git a/packages/fbp/typedoc.json b/packages/fbp/typedoc.json new file mode 100644 index 000000000..0fbe69517 --- /dev/null +++ b/packages/fbp/typedoc.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "tsconfig": "./tsconfig.docs.json", + "entryPoints": ["./src/index.ts", "./src/nodes/**/*.ts"], + "externalPattern": [ + "**/node_modules/**", + "src/nodes/*+(index|.spec|.e2e).ts" + ], + "excludeExternals": true, + "cleanOutputDir": true +}