├── .gitignore ├── .npmignore ├── .prettierrc ├── ARCHITECTURE.md ├── README.md ├── interval-avatar.png ├── nodemon.json ├── package.json ├── screenshot.png ├── src ├── classes │ ├── Action.ts │ ├── DuplexRPCClient.ts │ ├── IOClient.ts │ ├── IOComponent.ts │ ├── IOError.ts │ ├── IOPromise.ts │ ├── ISocket.ts │ ├── IntervalClient.ts │ ├── IntervalError.ts │ ├── Layout.ts │ ├── Logger.ts │ ├── Page.ts │ ├── Routes.ts │ └── TransactionLoadingState.ts ├── components │ ├── displayGrid.ts │ ├── displayImage.ts │ ├── displayLink.ts │ ├── displayMetadata.ts │ ├── displayTable.ts │ ├── displayVideo.ts │ ├── inputDate.ts │ ├── search.ts │ ├── selectMultiple.ts │ ├── selectSingle.ts │ ├── selectTable.ts │ ├── spreadsheet.ts │ ├── upload.ts │ └── url.ts ├── env.ts ├── examples │ ├── .gitignore │ ├── basic │ │ ├── editEmail.ts │ │ ├── grid.ts │ │ ├── index.ts │ │ ├── selectFromTable.ts │ │ ├── table.ts │ │ └── unauthorized.ts │ ├── filesystem │ │ ├── index.ts │ │ └── routes │ │ │ ├── action.ts │ │ │ ├── page.ts │ │ │ └── page2 │ │ │ ├── index.ts │ │ │ └── infile.ts │ ├── http │ │ └── index.ts │ ├── io │ │ └── index.ts │ ├── permissions │ │ └── index.ts │ ├── queued-actions │ │ └── index.ts │ ├── shutdown │ │ └── index.ts │ ├── static │ │ ├── canyon.mp4 │ │ └── fail.gif │ ├── structure │ │ ├── db.ts │ │ └── index.ts │ └── utils │ │ ├── fakeUsers.ts │ │ ├── helpers.ts │ │ ├── ioMethodWrappers.ts │ │ └── upload.ts ├── experimental.ts ├── index.ts ├── internalRpcSchema.ts ├── ioSchema.ts ├── types.ts └── utils │ ├── deserialize.ts │ ├── fileActionLoader.ts │ ├── grid.ts │ ├── http.ts │ ├── image.ts │ ├── packageManager.ts │ ├── spreadsheet.ts │ ├── superjson.ts │ └── table.ts ├── tsconfig.json ├── tsconfig.without-examples.json └── typings.d.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | *.tgz 5 | pm2.config.js 6 | .env 7 | */.interval.config.json 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | .prettierrc 3 | envoy.config.ts 4 | *.tar.gz 5 | *.tgz 6 | examples/ 7 | dist/demos/ 8 | dist/examples/ 9 | dist/env.* 10 | .env 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | trailingComma: "es5" 2 | singleQuote: true 3 | printWidth: 80 4 | semi: false 5 | arrowParens: "avoid" 6 | -------------------------------------------------------------------------------- /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | # Interval JS SDK 2 | 3 | The Interval SDK consists of several high-level actors responsible 4 | for handling communication between the defined actions and Interval. 5 | 6 | ## Architecture 7 | 8 | What follows is a high-level overview of how the underlying actors interact 9 | with each other and with Interval. 10 | 11 | ### `Interval` 12 | 13 | The default export `Interval` class is the entrypoint to connecting 14 | to Interval. Upon calling `listen()`, the `Interval` class does the 15 | following: 16 | 17 | 1. Establishes an `ISocket` connection to Interval 18 | 2. Creates a `DuplexRPCClient`, defining methods for sending 19 | and responding to high-level RPC messages from Interval. 20 | 3. Sends the `INITIALIZE_HOST` RPC message to Interval, 21 | letting it know what actions this host is defining 22 | and the handlers to call when those actions are run. 23 | 24 | ### `ISocket` 25 | 26 | A relatively thin wrapper around an underlying WebSocket connection. 27 | ISocket connections can be thought of as a TCP layer over WebSockets, 28 | each `MESSAGE` message must followed by an `ACK` message from the recipient. 29 | If the `ACK` is not received for a given `MESSAGE`, the Promise for that 30 | message is rejected and a `TimeoutError` is thrown. 31 | 32 | ### `DuplexRPCClient` 33 | 34 | Responsible for exchanging high-level RPC messages with another `DuplexRPCClient`. 35 | Schemas that define the messages that the client can send and respond to are 36 | defined ahead of time, providing static guarantees that the messages are 37 | acceptable for the given connection. Uses an `ISocket` object to exchange data. 38 | 39 | ### `IOClient` 40 | 41 | When a transaction is created for a given transaction, the SDK host 42 | `DuplexRPCClient` receives a `START_TRANSACTION` call, detailing the action 43 | and some additional metadata about the transaction. Upon receiving the 44 | `START_TRANSACTION` call, the call handler creates an `IOClient` object 45 | for the new transaction, passing a `send` argument to the `IOClient` 46 | constructor which translates the `IOClient`'s IO render instruction into 47 | an RPC message to be sent by the `DuplexRPCClient`. 48 | 49 | The `IOClient`'s primary purpose is to pass the `IO` namespace of IO methods to 50 | the action handler. These methods return `IOPromise` objects which detail 51 | translating the user-provided properties into the properties that make up an IO 52 | render instruction. 53 | The `IOPromise` objects can be `await`ed directly, 54 | rendering only the single component to the action runner, or in an `io.group`, 55 | which can render multiple `IOPromise` components in a single call. 56 | 57 | The `IOClient` defines the internal `renderComponents` method, which 58 | handles the render loop for a given IO call. 59 | Given a list of `IOComponent`s (potentially only one if not rendering a group) 60 | this method is responsible for sending the initial render call and handling 61 | responses (returns, state updates, or cancellations) from Interval. 62 | Resolves when each `IOComponent`'s `returnValue` Promise is resolved via 63 | response from Interval, or throws an IOError of kind `CANCELED` if canceled. 64 | 65 | ### `IOPromise` 66 | 67 | A custom wrapper class that handles creating the underlying component 68 | model when the IO call is to be rendered, and optionally transforming 69 | the value received from Interval to a custom component return type. 70 | A relatively thin wrapper around the internal `IOComponent` which is primarily 71 | responsible for being `await`able and transforming the network-level 72 | props and return values to the values expected by the IO method caller. 73 | If `await`ed, resolves when the internal `IOComponent`'s `returnValue` Promise 74 | is resolved. 75 | 76 | ### `IOComponent` 77 | 78 | The internal model underlying each `IOPromise`, responsible for constructing 79 | the data transmitted to Interval for an IO component, and handling responses 80 | received from Interval for the current component: resolving its `returnValue` 81 | when receiving a final response from the action runner, or constructing a new 82 | set of props when receiving new state. 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Interval 3 | 4 | 5 | # Interval Node.js SDK 6 | 7 | [![npm version](https://img.shields.io/npm/v/@interval/sdk?style=flat)](https://www.npmjs.com/package/@interval/sdk) [![Documentation](https://img.shields.io/badge/documentation-informational)](https://interval.com/docs) [![Twitter](https://img.shields.io/twitter/follow/useinterval.svg?color=%2338A1F3&label=twitter&style=flat)](https://twitter.com/useinterval) [![Discord](https://img.shields.io/badge/discord-join-blueviolet)](https://interval.com/discord) 8 | 9 | [Interval](https://interval.com) lets you quickly build internal web apps (think: customer support tools, admin panels, etc.) just by writing backend Node.js code. 10 | 11 | This is our Node.js SDK which connects to the interval.com web app. If you don't have an Interval account, you can [create one here](https://interval.com/signup). All core features are free to use. 12 | 13 | ## Why choose Interval? 14 | 15 | _"Node code > no-code"_ 16 | 17 | Interval is an alternative to no-code/low-code UI builders. Modern frontend development is inherently complicated, and teams rightfully want to spend minimal engineering resources on internal dashboards. No-code tools attempt to solve this problem by allowing you to build UIs in a web browser without writing any frontend code. 18 | 19 | We don't think this is the right solution. **Building UIs for mission-critical tools in your web browser** — often by non-technical teammates, outside of your codebase, without versioning or code review — **is an anti-pattern.** Apps built in this manner are brittle and break in unexpected ways. 20 | 21 | With Interval, **all of the code for generating your web UIs lives within your app's codebase.** Tools built with Interval (we call these [actions](https://interval.com/docs/concepts/actions)) are just asynchronous functions that run in your backend. Because these are plain old functions, you can access the complete power of your Node app. You can loop, conditionally branch, access shared functions, and so on. When you need to request input or display output, `await` any of our [I/O methods](https://interval.com/docs/io-methods/) to present a form to the user and your script will pause execution until input is received. 22 | 23 | Here's a simple app with a single "Hello, world" action: 24 | 25 | ```ts 26 | import Interval from '@interval/sdk' 27 | 28 | const interval = new Interval({ 29 | apiKey: '', 30 | actions: { 31 | hello_world: async () => { 32 | const name = await io.input.text('Your name') 33 | return `Hello, ${name}` 34 | }, 35 | }, 36 | }) 37 | 38 | interval.listen() 39 | ``` 40 | 41 | Interval: 42 | 43 | - Makes creating full-stack apps as easy as writing CLI scripts. 44 | - Can scale from a handful of scripts to robust multi-user dashboards. 45 | - Lets you build faster than no-code, without leaving your codebase & IDE. 46 | 47 | With Interval, you do not need to: 48 | 49 | - Write REST or GraphQL API endpoints to connect internal functionality to no-code tools. 50 | - Give Interval write access to your database (or give us _any_ of your credentials, for that matter). 51 | - Build web UIs with a drag-and-drop interface. 52 | 53 | An image containing a code sample alongside a screenshot of the Interval app it generates. 54 | 55 | ## More about Interval 56 | 57 | - 📖 [Documentation](https://interval.com/docs) 58 | - 🌐 [Interval website](https://interval.com) 59 | - 💬 [Discord community](https://interval.com/discord) 60 | - 📰 [Product updates](https://interval.com/blog) 61 | -------------------------------------------------------------------------------- /interval-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interval/interval-node/0a094e695aad547825a2a47a918fccce0d5e40f3/interval-avatar.png -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "execMap": { 3 | "ts": "ts-node --project tsconfig.json --files" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@interval/sdk", 3 | "version": "2.0.0", 4 | "description": "The frontendless framework for high growth companies. Interval automatically generates apps by inlining the UI in your backend code. It's a faster and more maintainable way to build internal tools, rapid prototypes, and more.", 5 | "homepage": "https://interval.com", 6 | "repository": { 7 | "type": "git", 8 | "url": "github:interval/interval-node" 9 | }, 10 | "bugs": "https://github.com/interval/interval-node/issues", 11 | "keywords": ["internal tool", "app", "ui", "ui builder"], 12 | "license": "MIT", 13 | "engines": { 14 | "node": ">=12.17.0" 15 | }, 16 | "main": "dist/index.js", 17 | "types": "dist/index.d.ts", 18 | "scripts": { 19 | "tar": "yarn pack", 20 | "check": "tsc --noEmit", 21 | "build": "tsc", 22 | "demo:basic": "node ./dist/examples/basic/index.js", 23 | "dev": "nodemon --watch src -e ts src/examples/${1:-basic}/index.ts" 24 | }, 25 | "dependencies": { 26 | "@brillout/import": "^0.2.2", 27 | "cross-fetch": "^3.1.5", 28 | "evt": "^2.4.10", 29 | "superjson": "^1.9.1", 30 | "uuid": "^9.0.0", 31 | "ws": "^8.4.1", 32 | "zod": "^3.13.3" 33 | }, 34 | "devDependencies": { 35 | "@aws-sdk/client-s3": "^3.135.0", 36 | "@aws-sdk/s3-request-presigner": "^3.135.0", 37 | "@faker-js/faker": "^7.3.0", 38 | "@types/dedent": "^0.7.0", 39 | "@types/node": "^17.0.8", 40 | "@types/uuid": "^8.3.4", 41 | "@types/ws": "^8.2.0", 42 | "dotenv": "^16.3.1", 43 | "nodemon": "^2.0.20", 44 | "ts-node": "^10.9.1", 45 | "typescript": "^4.8.4" 46 | }, 47 | "resolutions": { 48 | "ts-node": "^10.9.1" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interval/interval-node/0a094e695aad547825a2a47a918fccce0d5e40f3/screenshot.png -------------------------------------------------------------------------------- /src/classes/Action.ts: -------------------------------------------------------------------------------- 1 | import { AccessControlDefinition } from '../internalRpcSchema' 2 | import { 3 | ExplicitIntervalActionDefinition, 4 | IntervalActionDefinition, 5 | IntervalActionHandler, 6 | } from '../types' 7 | 8 | export default class Action implements ExplicitIntervalActionDefinition { 9 | handler: IntervalActionHandler 10 | backgroundable?: boolean 11 | unlisted?: boolean 12 | warnOnClose?: boolean 13 | name?: string 14 | description?: string 15 | access?: AccessControlDefinition 16 | 17 | constructor( 18 | def: ExplicitIntervalActionDefinition | IntervalActionDefinition 19 | ) { 20 | if (typeof def === 'function') { 21 | this.handler = def 22 | } else { 23 | Object.assign(this, def) 24 | // to appease typescript 25 | this.handler = def.handler 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/classes/DuplexRPCClient.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodError } from 'zod' 2 | import { Evt } from 'evt' 3 | import type { DuplexMessage } from '../internalRpcSchema' 4 | import { DUPLEX_MESSAGE_SCHEMA } from '../internalRpcSchema' 5 | import { sleep } from './IntervalClient' 6 | import ISocket, { TimeoutError } from './ISocket' 7 | 8 | let count = 0 9 | function generateId() { 10 | count = count + 1 11 | return count.toString() 12 | } 13 | 14 | export interface MethodDef { 15 | [key: string]: { 16 | inputs: z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion 17 | returns: z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion 18 | } 19 | } 20 | 21 | type OnReplyFn = (anyObject: any) => void 22 | 23 | export type DuplexRPCHandlers = { 24 | [Property in keyof ResponderSchema]: ( 25 | inputs: z.infer 26 | ) => Promise> 27 | } 28 | 29 | interface CreateDuplexRPCClientProps< 30 | CallerSchema extends MethodDef, 31 | ResponderSchema extends MethodDef 32 | > { 33 | communicator: ISocket 34 | canCall: CallerSchema 35 | canRespondTo: ResponderSchema 36 | handlers: DuplexRPCHandlers 37 | retryChunkIntervalMs?: number 38 | } 39 | 40 | /** 41 | * Responsible for making RPC calls to another DuplexRPCClient. 42 | * Can send messages from CallerSchema and respond to messages 43 | * from ResponderSchema. 44 | * 45 | * @property communicator - The ISocket instance responsible for 46 | * sending the RPC messages. 47 | * @property handlers - Defines the actions taken when receiving 48 | * a given message, an object keyed by the message schema key. 49 | */ 50 | export class DuplexRPCClient< 51 | CallerSchema extends MethodDef, 52 | ResponderSchema extends MethodDef 53 | > { 54 | communicator: ISocket 55 | canCall: CallerSchema 56 | canRespondTo: ResponderSchema 57 | handlers: { 58 | [Property in keyof ResponderSchema]: ( 59 | inputs: z.infer 60 | ) => Promise> 61 | } 62 | pendingCalls = new Map() 63 | messageChunks = new Map() 64 | #retryChunkIntervalMs: number = 100 65 | 66 | onMessageReceived: Evt 67 | 68 | constructor({ 69 | communicator, 70 | canCall, 71 | canRespondTo, 72 | handlers, 73 | retryChunkIntervalMs, 74 | }: CreateDuplexRPCClientProps) { 75 | this.communicator = communicator 76 | this.communicator.onMessage.attach(this.onmessage.bind(this)) 77 | this.canCall = canCall 78 | this.canRespondTo = canRespondTo 79 | this.handlers = handlers 80 | this.onMessageReceived = new Evt() 81 | 82 | if (retryChunkIntervalMs && retryChunkIntervalMs > 0) { 83 | this.#retryChunkIntervalMs = retryChunkIntervalMs 84 | } 85 | } 86 | 87 | private packageResponse({ 88 | id, 89 | methodName, 90 | data, 91 | }: Omit) { 92 | const preparedResponseText: DuplexMessage = { 93 | id: id, 94 | kind: 'RESPONSE', 95 | methodName: methodName, 96 | data, 97 | } 98 | return JSON.stringify(preparedResponseText) 99 | } 100 | 101 | private packageCall({ 102 | id, 103 | methodName, 104 | data, 105 | }: Omit): string | string[] { 106 | const callerData: DuplexMessage = { 107 | id, 108 | kind: 'CALL', 109 | data, 110 | methodName: methodName as string, // ?? 111 | } 112 | 113 | return JSON.stringify(callerData) 114 | } 115 | 116 | public setCommunicator(newCommunicator: ISocket): void { 117 | this.communicator.onMessage.detach() 118 | this.communicator = newCommunicator 119 | this.communicator.onMessage.attach(this.onmessage.bind(this)) 120 | } 121 | 122 | private handleReceivedResponse(parsed: DuplexMessage & { kind: 'RESPONSE' }) { 123 | const onReplyFn = this.pendingCalls.get(parsed.id) 124 | if (!onReplyFn) return 125 | 126 | onReplyFn(parsed.data) 127 | this.pendingCalls.delete(parsed.id) 128 | } 129 | 130 | private async handleReceivedCall(parsed: DuplexMessage & { kind: 'CALL' }) { 131 | type MethodKeys = keyof typeof this.canRespondTo 132 | 133 | const methodName = parsed.methodName as MethodKeys 134 | const method: ResponderSchema[MethodKeys] | undefined = 135 | this.canRespondTo[methodName] 136 | 137 | if (!method) { 138 | throw new Error(`There is no method for ${parsed.methodName}`) 139 | } 140 | 141 | // struggling to get real inference here 142 | const inputs = method.inputs.parse(parsed.data) 143 | const handler = this.handlers[methodName] 144 | 145 | const returnValue = await handler(inputs) 146 | 147 | const preparedResponseText = this.packageResponse({ 148 | id: parsed.id, 149 | methodName: methodName as string, //?? 150 | data: returnValue, 151 | }) 152 | 153 | try { 154 | await this.communicator.send(preparedResponseText) 155 | } catch (err) { 156 | console.error('Failed sending response', preparedResponseText, err) 157 | } 158 | 159 | return 160 | } 161 | 162 | private async onmessage(data: unknown) { 163 | const txt = data as string 164 | try { 165 | let inputParsed = DUPLEX_MESSAGE_SCHEMA.parse(JSON.parse(txt)) 166 | 167 | this.onMessageReceived.post(inputParsed) 168 | 169 | if (inputParsed.kind === 'CALL') { 170 | try { 171 | await this.handleReceivedCall(inputParsed) 172 | } catch (err) { 173 | if (err instanceof ZodError) { 174 | console.error( 175 | '[DuplexRPCClient] Received invalid call:', 176 | inputParsed 177 | ) 178 | } else { 179 | console.error( 180 | '[DuplexRPCClient] Failed handling call: ', 181 | inputParsed 182 | ) 183 | } 184 | console.error(err) 185 | } 186 | } else if (inputParsed.kind === 'RESPONSE') { 187 | try { 188 | this.handleReceivedResponse(inputParsed) 189 | } catch (err) { 190 | if (err instanceof ZodError) { 191 | console.error( 192 | '[DuplexRPCClient] Received invalid response:', 193 | inputParsed 194 | ) 195 | } else { 196 | console.error( 197 | '[DuplexRPCClient] Failed handling response: ', 198 | inputParsed 199 | ) 200 | } 201 | 202 | console.error(err) 203 | } 204 | } 205 | } catch (err) { 206 | console.error('[DuplexRPCClient] Received invalid message:', data) 207 | console.error(err) 208 | } 209 | } 210 | 211 | public async send( 212 | methodName: MethodName, 213 | inputs: z.input, 214 | options: { 215 | timeoutFactor?: number 216 | } = {} 217 | ) { 218 | const id = generateId() 219 | 220 | const msg = this.packageCall({ 221 | id, 222 | data: inputs, 223 | methodName: methodName as string, // ?? 224 | }) 225 | 226 | type ReturnType = z.infer 227 | 228 | return new Promise((resolve, reject) => { 229 | this.pendingCalls.set(id, (rawResponseText: string) => { 230 | try { 231 | const parsed = 232 | this.canCall[methodName]['returns'].parse(rawResponseText) 233 | return resolve(parsed) 234 | } catch (err) { 235 | reject(err) 236 | } 237 | }) 238 | 239 | if (Array.isArray(msg)) { 240 | Promise.allSettled( 241 | msg.map(async chunk => { 242 | const NUM_TRIES_PER_CHUNK = 3 243 | 244 | // If a chunk times out, retry it a few times 245 | for (let i = 0; i <= NUM_TRIES_PER_CHUNK; i++) { 246 | try { 247 | return await this.communicator.send(chunk) 248 | } catch (err) { 249 | if (err instanceof TimeoutError) { 250 | // console.debug( 251 | // `Chunk timed out, retrying in ${ 252 | // this.#retryChunkIntervalMs 253 | // }ms...` 254 | // ) 255 | await sleep(this.#retryChunkIntervalMs) 256 | } else { 257 | throw err 258 | } 259 | } 260 | } 261 | 262 | throw new TimeoutError() 263 | }) 264 | ).then(responses => { 265 | // reject the first failed promise, if any 266 | for (const response of responses) { 267 | if (response.status === 'rejected') { 268 | reject(response.reason) 269 | } 270 | } 271 | }) 272 | } else { 273 | this.communicator.send(msg, options).catch(err => { 274 | reject(err) 275 | }) 276 | } 277 | }) 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/classes/IOComponent.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodError } from 'zod' 2 | import { Evt } from 'evt' 3 | import { 4 | ioSchema, 5 | resolvesImmediately, 6 | T_IO_DISPLAY_METHOD_NAMES, 7 | T_IO_METHOD_NAMES, 8 | T_IO_PROPS, 9 | T_IO_RETURNS, 10 | T_IO_STATE, 11 | } from '../ioSchema' 12 | import { deserializeDates } from '../utils/deserialize' 13 | import IOError from './IOError' 14 | import { IOPromiseValidator } from './IOPromise' 15 | 16 | type IoSchema = typeof ioSchema 17 | export interface ComponentInstance { 18 | methodName: MN 19 | label: string 20 | props?: T_IO_PROPS 21 | state: T_IO_STATE 22 | isStateful?: boolean 23 | isOptional?: boolean 24 | isMultiple?: boolean 25 | validationErrorMessage?: string | undefined 26 | multipleProps?: { 27 | defaultValue?: T_IO_RETURNS[] | null 28 | } 29 | } 30 | 31 | export type ComponentRenderInfo = Omit< 32 | ComponentInstance, 33 | 'state' 34 | > 35 | 36 | export type ComponentReturnValue = T_IO_RETURNS 37 | 38 | export type MaybeMultipleComponentReturnValue = 39 | | T_IO_RETURNS 40 | | T_IO_RETURNS[] 41 | 42 | export type IOComponentMap = { 43 | [MethodName in T_IO_METHOD_NAMES]: IOComponent 44 | } 45 | 46 | export type AnyIOComponent = IOComponentMap[keyof IoSchema] 47 | 48 | export type DisplayComponentMap = { 49 | [MethodName in T_IO_DISPLAY_METHOD_NAMES]: IOComponent 50 | } 51 | 52 | export type AnyDisplayComponent = DisplayComponentMap[T_IO_DISPLAY_METHOD_NAMES] 53 | 54 | /** 55 | * The internal model underlying each IOPromise, responsible for constructing 56 | * the data transmitted to Interval for an IO component, and handling responses 57 | * received from Interval. 58 | */ 59 | export default class IOComponent { 60 | schema: IoSchema[MethodName] 61 | instance: ComponentInstance 62 | resolver: 63 | | ((v: MaybeMultipleComponentReturnValue | undefined) => void) 64 | | undefined 65 | returnValue: Promise< 66 | MaybeMultipleComponentReturnValue | undefined 67 | > 68 | onStateChangeHandler: (() => void) | undefined 69 | handleStateChange: 70 | | (( 71 | incomingState: z.infer 72 | ) => Promise>>) 73 | | undefined 74 | onPropsUpdate: (() => T_IO_PROPS) | undefined 75 | 76 | validator: 77 | | IOPromiseValidator< 78 | MaybeMultipleComponentReturnValue | undefined 79 | > 80 | | undefined 81 | 82 | resolvesImmediately = false 83 | 84 | /** 85 | * @param options.methodName - The component's method name from ioSchema, used 86 | * to determine the valid types for communication with Interval. 87 | * @param options.label - The UI label to be displayed to the action runner. 88 | * @param options.initialProps - The properties send to Interval for the initial 89 | * render call. 90 | * @param options.handleStateChange - A handler that converts new state received 91 | * from Interval into a new set of props. 92 | * @param options.isOptional - If true, the input can be omitted by the action 93 | * runner, in which case the component will accept and return `undefined`. 94 | */ 95 | constructor({ 96 | methodName, 97 | label, 98 | initialProps, 99 | onStateChange, 100 | isOptional = false, 101 | isMultiple = false, 102 | validator, 103 | multipleProps, 104 | displayResolvesImmediately, 105 | onPropsUpdate, 106 | }: { 107 | methodName: MethodName 108 | label: string 109 | initialProps?: T_IO_PROPS 110 | onStateChange?: ( 111 | incomingState: T_IO_STATE 112 | ) => Promise>> 113 | isOptional?: boolean 114 | isMultiple?: boolean 115 | validator?: IOPromiseValidator< 116 | MaybeMultipleComponentReturnValue | undefined 117 | > 118 | multipleProps?: { 119 | defaultValue?: T_IO_RETURNS[] | null 120 | } 121 | displayResolvesImmediately?: boolean 122 | onPropsUpdate?: Evt> 123 | }) { 124 | this.handleStateChange = onStateChange 125 | this.schema = ioSchema[methodName] 126 | this.validator = validator 127 | 128 | if (onPropsUpdate) { 129 | onPropsUpdate.attach(this.setProps.bind(this)) 130 | } 131 | 132 | try { 133 | initialProps = this.schema.props.parse(initialProps ?? {}) 134 | } catch (err) { 135 | console.error( 136 | `[Interval] Invalid props found for IO call with label "${label}":` 137 | ) 138 | console.error(err) 139 | throw err 140 | } 141 | 142 | this.instance = { 143 | methodName, 144 | label, 145 | props: initialProps, 146 | state: null, 147 | isStateful: !!onStateChange, 148 | isOptional: isOptional, 149 | isMultiple: isMultiple, 150 | multipleProps, 151 | } 152 | 153 | this.returnValue = new Promise< 154 | MaybeMultipleComponentReturnValue | undefined 155 | >(resolve => { 156 | this.resolver = resolve 157 | }) 158 | 159 | this.resolvesImmediately = resolvesImmediately(methodName, { 160 | displayResolvesImmediately, 161 | }) 162 | } 163 | 164 | async handleValidation( 165 | returnValue: MaybeMultipleComponentReturnValue | undefined 166 | ): Promise { 167 | if (this.validator) { 168 | const message = await this.validator(returnValue) 169 | this.instance.validationErrorMessage = message 170 | return message 171 | } 172 | } 173 | 174 | setReturnValue(value: z.input) { 175 | let requiredReturnSchema: 176 | | IoSchema[MethodName]['returns'] 177 | | z.ZodArray = this.schema.returns 178 | 179 | if (this.instance.isMultiple) { 180 | requiredReturnSchema = z.array(requiredReturnSchema) 181 | } 182 | 183 | const returnSchema = this.instance.isOptional 184 | ? requiredReturnSchema 185 | .nullable() 186 | .optional() 187 | // JSON.stringify turns undefined into null in arrays 188 | .transform(value => value ?? undefined) 189 | : requiredReturnSchema 190 | 191 | try { 192 | let parsed: ReturnType 193 | 194 | if (value && typeof value === 'object') { 195 | // TODO: Remove this when all active SDKs support superjson 196 | if (Array.isArray(value)) { 197 | parsed = returnSchema.parse( 198 | value.map(v => 199 | typeof v === 'object' && !Array.isArray(v) 200 | ? deserializeDates(v) 201 | : v 202 | ) 203 | ) 204 | } else { 205 | parsed = returnSchema.parse(deserializeDates(value)) 206 | } 207 | } else { 208 | parsed = returnSchema.parse(value) 209 | } 210 | 211 | if (this.resolver) { 212 | this.resolver(parsed) 213 | } 214 | } catch (err) { 215 | const ioError = new IOError( 216 | 'BAD_RESPONSE', 217 | 'Received invalid return value', 218 | { cause: err } 219 | ) 220 | throw ioError 221 | } 222 | } 223 | 224 | async setState( 225 | newState: z.infer 226 | ): Promise> { 227 | try { 228 | const parsedState = this.schema.state.parse(newState) 229 | if (this.handleStateChange) { 230 | this.instance.props = { 231 | ...this.instance.props, 232 | ...(await this.handleStateChange(parsedState)), 233 | } 234 | } 235 | this.instance.state = parsedState 236 | if (parsedState !== null && !this.handleStateChange) { 237 | console.warn( 238 | 'Received non-null state, but no method was defined to handle.' 239 | ) 240 | } 241 | this.onStateChangeHandler && this.onStateChangeHandler() 242 | } catch (err) { 243 | if (err instanceof ZodError) { 244 | const ioError = new IOError('BAD_RESPONSE', 'Received invalid state') 245 | ioError.cause = err 246 | throw ioError 247 | } else { 248 | const ioError = new IOError( 249 | 'RESPONSE_HANDLER_ERROR', 250 | 'Error in state change handler' 251 | ) 252 | ioError.cause = err 253 | throw ioError 254 | } 255 | } 256 | 257 | return this.instance 258 | } 259 | 260 | setProps(newProps: z.input) { 261 | this.instance.props = newProps 262 | this.onStateChangeHandler && this.onStateChangeHandler() 263 | } 264 | 265 | getInstance() { 266 | return this.instance 267 | } 268 | 269 | get label() { 270 | return this.instance.label 271 | } 272 | 273 | onStateChange(handler: () => void) { 274 | this.onStateChangeHandler = handler 275 | } 276 | 277 | getRenderInfo(): ComponentRenderInfo { 278 | return { 279 | methodName: this.instance.methodName, 280 | label: this.instance.label, 281 | props: this.instance.props, 282 | isStateful: this.instance.isStateful, 283 | isOptional: this.instance.isOptional, 284 | isMultiple: this.instance.isMultiple, 285 | validationErrorMessage: this.instance.validationErrorMessage, 286 | multipleProps: this.instance.multipleProps, 287 | } 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/classes/IOError.ts: -------------------------------------------------------------------------------- 1 | export type IOErrorKind = 2 | | 'CANCELED' 3 | | 'TRANSACTION_CLOSED' 4 | | 'BAD_RESPONSE' 5 | | 'RESPONSE_HANDLER_ERROR' 6 | | 'RENDER_ERROR' 7 | 8 | export default class IOError extends Error { 9 | kind: IOErrorKind 10 | 11 | constructor(kind: IOErrorKind, message?: string, options?: { cause?: any }) { 12 | super(message, options) 13 | this.kind = kind 14 | this.name = 'IOError' 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/classes/ISocket.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | WebSocket as NodeWebSocket, 3 | MessageEvent as NodeWSMessageEvent, 4 | } from 'ws' 5 | import { Evt } from 'evt' 6 | import { v4 } from 'uuid' 7 | import { z } from 'zod' 8 | 9 | const MESSAGE_META = z.object({ 10 | data: z.any(), 11 | id: z.string(), 12 | type: z.union([z.literal('ACK'), z.literal('MESSAGE')]), 13 | }) 14 | 15 | export class TimeoutError extends Error {} 16 | 17 | export class NotConnectedError extends Error {} 18 | 19 | interface PendingMessage { 20 | data: string 21 | onAckReceived: () => void 22 | } 23 | 24 | export interface ISocketConfig { 25 | connectTimeout?: number 26 | sendTimeout?: number 27 | pingTimeout?: number 28 | id?: string // manually specifying ids is helpful for debugging 29 | } 30 | 31 | /** 32 | * A relatively thin wrapper around an underlying WebSocket connection. Can be thought of as a TCP layer on top of WebSockets, 33 | * ISockets send and expect `ACK` messages following receipt of a `MESSAGE` message containing the transmitted data. 34 | * Can also ping its connected counterpart to determine if the 35 | * connection has been lost. 36 | * 37 | * @property connectTimeout - The number of ms that this ISocket will 38 | * wait to establish connection to its counterpart before rejecting 39 | * the `connect` Promise. 40 | * @property sendTimeout - The number of ms that this ISocket will 41 | * wait to receive an `ACK` response after sending a `MESSAGE` 42 | * before rejecting the `send` Promise. 43 | * @property pingTimeout - The number of ms that this ISocket will 44 | * wait to receive a `pong` after sending a `ping` before 45 | * rejecting the `ping` Promise. 46 | */ 47 | export default class ISocket { 48 | private ws: WebSocket | NodeWebSocket 49 | private connectTimeout: number 50 | private sendTimeout: number 51 | private pingTimeout: number 52 | private isAuthenticated: boolean 53 | private timeouts: Set 54 | onMessage: Evt 55 | onOpen: Evt 56 | onError: Evt 57 | onClose: Evt<[number, string]> 58 | onAuthenticated: Evt 59 | id: string 60 | 61 | private pendingMessages = new Map() 62 | 63 | /** Client **/ 64 | /** 65 | * Establishes an ISocket connection to the connected WebSocket 66 | * counterpart, throwing an error if connection is not established 67 | * within `connectTimeout`. 68 | */ 69 | async connect() { 70 | return new Promise((resolve, reject) => { 71 | if (this.isOpen && this.isAuthenticated) { 72 | return resolve() 73 | } 74 | 75 | const failTimeout = setTimeout( 76 | () => reject(new TimeoutError()), 77 | this.connectTimeout 78 | ) 79 | 80 | this.timeouts.add(failTimeout) 81 | 82 | this.onAuthenticated.attach(() => { 83 | clearTimeout(failTimeout) 84 | this.timeouts.delete(failTimeout) 85 | return resolve() 86 | }) 87 | }) 88 | } 89 | 90 | /** Server **/ 91 | async confirmAuthentication() { 92 | return this.send('authenticated') 93 | } 94 | 95 | get isOpen() { 96 | return this.ws.readyState === this.ws.OPEN 97 | } 98 | 99 | /** Both **/ 100 | /** 101 | * Send a `MESSAGE` containing data to the connected counterpart, 102 | * throwing an error if `ACK` is not received within `sendTimeout`. 103 | */ 104 | async send(data: string, options: { timeoutFactor?: number } = {}) { 105 | if (!this.isOpen) throw new NotConnectedError() 106 | 107 | return new Promise((resolve, reject) => { 108 | const id = v4() 109 | 110 | const failTimeout = setTimeout(() => { 111 | reject(new TimeoutError()) 112 | }, this.sendTimeout * (options.timeoutFactor ?? 1)) 113 | 114 | this.timeouts.add(failTimeout) 115 | 116 | this.pendingMessages.set(id, { 117 | data, 118 | onAckReceived: () => { 119 | clearTimeout(failTimeout) 120 | this.timeouts.delete(failTimeout) 121 | resolve() 122 | }, 123 | }) 124 | this.ws.send(JSON.stringify({ id, data, type: 'MESSAGE' })) 125 | }) 126 | } 127 | 128 | /** Both **/ 129 | /** 130 | * Close the underlying WebSocket connection, and this ISocket 131 | * connection. 132 | */ 133 | close(code?: number, reason?: string) { 134 | this.onMessage.detach() 135 | return this.ws.close(code, reason) 136 | } 137 | 138 | constructor(ws: WebSocket | NodeWebSocket, config?: ISocketConfig) { 139 | // this works but on("error") does not. No idea why ¯\_(ツ)_/¯ 140 | // will emit "closed" regardless 141 | // this.ws.addEventListener('error', e => { 142 | // this.dispatchEvent(e) 143 | // }) 144 | 145 | this.onMessage = new Evt() 146 | this.onOpen = new Evt() 147 | this.onError = new Evt() 148 | this.onClose = new Evt<[number, string]>() 149 | this.onAuthenticated = new Evt() 150 | this.timeouts = new Set() 151 | 152 | this.ws = ws 153 | 154 | this.id = config?.id || v4() 155 | this.connectTimeout = config?.connectTimeout ?? 15_000 156 | this.sendTimeout = config?.sendTimeout ?? 5000 157 | this.pingTimeout = config?.pingTimeout ?? 5000 158 | this.isAuthenticated = false 159 | 160 | this.onClose.attach(() => { 161 | for (const timeout of this.timeouts) { 162 | clearTimeout(timeout) 163 | } 164 | this.timeouts.clear() 165 | }) 166 | 167 | this.ws.onopen = () => { 168 | this.onOpen.post() 169 | } 170 | 171 | this.ws.onclose = (ev?: CloseEvent) => { 172 | this.onClose.post([ev?.code ?? 0, ev?.reason ?? 'Unknown']) 173 | } 174 | 175 | this.ws.onerror = (ev: ErrorEvent | Event) => { 176 | const message = 'message' in ev ? ev.message : 'Unknown error' 177 | this.onError.post(new Error(message)) 178 | } 179 | 180 | this.ws.onmessage = ( 181 | evt: MessageEvent | Pick 182 | ) => { 183 | // only in browser 184 | if ('stopPropagation' in evt) { 185 | evt.stopPropagation() 186 | } 187 | 188 | if (!this.isOpen) return 189 | 190 | const data = JSON.parse(evt.data.toString()) 191 | const meta = MESSAGE_META.parse(data) 192 | 193 | if (meta.type === 'ACK') { 194 | const pm = this.pendingMessages.get(meta.id) 195 | if (pm) { 196 | pm.onAckReceived() 197 | this.pendingMessages.delete(meta.id) 198 | } 199 | } 200 | if (meta.type === 'MESSAGE') { 201 | ws.send(JSON.stringify({ type: 'ACK', id: meta.id })) 202 | if (meta.data === 'authenticated') { 203 | this.isAuthenticated = true 204 | this.onAuthenticated.post() 205 | } else if (meta.data === 'ping') { 206 | // do nothing 207 | } else { 208 | this.onMessage.post(meta.data) 209 | } 210 | } 211 | } 212 | 213 | if ('pong' in ws) { 214 | ws.on('pong', buf => { 215 | const id = buf.toString() 216 | const pm = this.pendingMessages.get(id) 217 | if (pm?.data === 'ping') { 218 | pm.onAckReceived() 219 | } 220 | }) 221 | } 222 | } 223 | 224 | get isPingSupported() { 225 | return 'ping' in this.ws 226 | } 227 | 228 | get readyState() { 229 | return this.ws.readyState 230 | } 231 | 232 | /** Both **/ 233 | /** 234 | * Ping the connected counterpart, throwing a TimeoutError if a 235 | * `pong` is not received within `pingTimeout`. 236 | */ 237 | async ping() { 238 | if (!this.isOpen) throw new NotConnectedError() 239 | 240 | const ws = this.ws 241 | return new Promise((resolve, reject) => { 242 | const pongTimeout = setTimeout( 243 | () => reject(new TimeoutError('Pong not received in time')), 244 | this.pingTimeout 245 | ) 246 | this.timeouts.add(pongTimeout) 247 | 248 | const id = v4() 249 | this.pendingMessages.set(id, { 250 | data: 'ping', 251 | onAckReceived: () => { 252 | clearTimeout(pongTimeout) 253 | this.timeouts.delete(pongTimeout) 254 | resolve() 255 | }, 256 | }) 257 | 258 | if ('ping' in ws) { 259 | ws.ping(id, undefined, err => { 260 | if (err) { 261 | reject(err) 262 | } 263 | }) 264 | } else { 265 | ws.send(JSON.stringify({ type: 'MESSAGE', id, data: 'ping' })) 266 | } 267 | }) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/classes/IntervalError.ts: -------------------------------------------------------------------------------- 1 | export default class IntervalError extends Error { 2 | constructor(message: string) { 3 | super(message) 4 | this.name = 'IntervalError' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/classes/Layout.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { Literal, IO_RENDER, buttonItem, metaItemSchema } from '../ioSchema' 3 | import { 4 | AnyDisplayIOPromise, 5 | ButtonItem, 6 | PageError, 7 | EventualValue, 8 | } from '../types' 9 | 10 | type EventualString = EventualValue 11 | 12 | export interface BasicLayoutConfig { 13 | title?: EventualString 14 | description?: EventualString 15 | children?: AnyDisplayIOPromise[] 16 | menuItems?: ButtonItem[] 17 | } 18 | 19 | export interface Layout { 20 | title?: EventualString 21 | description?: EventualString 22 | children?: AnyDisplayIOPromise[] 23 | menuItems?: ButtonItem[] 24 | errors?: PageError[] 25 | } 26 | 27 | // Base class 28 | export class BasicLayout implements Layout { 29 | title?: EventualString 30 | description?: EventualString 31 | children?: AnyDisplayIOPromise[] 32 | menuItems?: ButtonItem[] 33 | errors?: PageError[] 34 | 35 | constructor(config: BasicLayoutConfig) { 36 | this.title = config.title 37 | this.description = config.description 38 | this.children = config.children 39 | this.menuItems = config.menuItems 40 | this.errors = [] 41 | } 42 | } 43 | 44 | export type MetaItemSchema = z.infer 45 | 46 | export type MetaItemValue = Literal | bigint 47 | 48 | export interface MetaItem extends Omit { 49 | label: string 50 | value: 51 | | MetaItemValue 52 | | Promise 53 | | (() => MetaItemValue) 54 | | (() => Promise) 55 | error?: string 56 | } 57 | 58 | // For superjson (de)serialization 59 | export const META_ITEMS_SCHEMA = z.object({ 60 | json: z.array(metaItemSchema), 61 | meta: z.any(), 62 | }) 63 | 64 | export type MetaItemsSchema = z.infer 65 | 66 | export const LAYOUT_ERROR_SCHEMA = z.object({ 67 | layoutKey: z.string().optional(), 68 | error: z.string(), 69 | message: z.string(), 70 | cause: z.string().optional(), 71 | stack: z.string().optional(), 72 | }) 73 | 74 | export const BASIC_LAYOUT_SCHEMA = z.object({ 75 | kind: z.literal('BASIC'), 76 | title: z.string().nullish(), 77 | description: z.string().nullish(), 78 | children: IO_RENDER.nullish(), 79 | metadata: META_ITEMS_SCHEMA.optional(), 80 | menuItems: z.array(buttonItem).nullish(), 81 | errors: z.array(LAYOUT_ERROR_SCHEMA).nullish(), 82 | }) 83 | 84 | // To be extended with z.discriminatedUnion when adding different pages 85 | export const LAYOUT_SCHEMA = BASIC_LAYOUT_SCHEMA 86 | 87 | export type LayoutSchema = z.infer 88 | export type LayoutSchemaInput = z.input 89 | export type BasicLayoutSchema = z.infer 90 | export type BasicLayoutSchemaInput = z.input 91 | export type LayoutError = z.infer 92 | 93 | export { metaItemSchema as META_ITEM_SCHEMA } 94 | -------------------------------------------------------------------------------- /src/classes/Logger.ts: -------------------------------------------------------------------------------- 1 | import type { SdkAlert } from '../internalRpcSchema' 2 | import { 3 | detectPackageManager, 4 | getInstallCommand, 5 | } from '../utils/packageManager' 6 | import * as pkg from '../../package.json' 7 | 8 | export type LogLevel = 9 | | 'quiet' 10 | | 'info' 11 | | 'prod' /* @deprecated, alias for 'info' */ 12 | | 'debug' 13 | 14 | export const CHANGELOG_URL = 'https://interval.com/changelog' 15 | 16 | export default class Logger { 17 | logLevel: LogLevel = 'info' 18 | 19 | constructor(logLevel?: LogLevel) { 20 | if (logLevel) { 21 | this.logLevel = logLevel 22 | } 23 | } 24 | 25 | /* Important messages, always emitted */ 26 | prod(...args: any[]) { 27 | console.log('[Interval] ', ...args) 28 | } 29 | 30 | /* Same as prod, but without the [Interval] prefix */ 31 | prodNoPrefix(...args: any[]) { 32 | console.log(...args) 33 | } 34 | 35 | /* Fatal errors or errors in user code, always emitted */ 36 | error(...args: any[]) { 37 | console.error('[Interval] ', ...args) 38 | } 39 | 40 | /* Informational messages, not emitted in "quiet" logLevel */ 41 | info(...args: any[]) { 42 | if (this.logLevel !== 'quiet') { 43 | console.info('[Interval] ', ...args) 44 | } 45 | } 46 | 47 | /* Same as info, but without the [Interval] prefix */ 48 | infoNoPrefix(...args: any[]) { 49 | if (this.logLevel !== 'quiet') { 50 | console.log(...args) 51 | } 52 | } 53 | 54 | /* Non-fatal warnings, not emitted in "quiet" logLevel */ 55 | warn(...args: any[]) { 56 | if (this.logLevel !== 'quiet') { 57 | console.warn('[Interval] ', ...args) 58 | } 59 | } 60 | 61 | /* Debugging/tracing information, only emitted in "debug" logLevel */ 62 | debug(...args: any[]) { 63 | if (this.logLevel === 'debug') { 64 | console.debug('[Interval] ', ...args) 65 | } 66 | } 67 | 68 | handleSdkAlert(sdkAlert: SdkAlert) { 69 | this.infoNoPrefix() 70 | 71 | const WARN_EMOJI = '\u26A0\uFE0F' 72 | const ERROR_EMOJI = '‼️' 73 | 74 | const { severity, message } = sdkAlert 75 | 76 | switch (severity) { 77 | case 'INFO': 78 | this.info('🆕\tA new Interval SDK version is available.') 79 | if (message) { 80 | this.info(message) 81 | } 82 | break 83 | case 'WARNING': 84 | this.warn( 85 | `${WARN_EMOJI}\tThis version of the Interval SDK has been deprecated. Please update as soon as possible, it will not work in a future update.` 86 | ) 87 | if (message) { 88 | this.warn(message) 89 | } 90 | break 91 | case 'ERROR': 92 | this.error( 93 | `${ERROR_EMOJI}\tThis version of the Interval SDK is no longer supported. Your app will not work until you update.` 94 | ) 95 | if (message) { 96 | this.error(message) 97 | } 98 | break 99 | default: 100 | if (message) { 101 | this.prod(message) 102 | } 103 | } 104 | 105 | this.info("\t- See what's new at:", CHANGELOG_URL) 106 | this.info( 107 | '\t- Update now by running:', 108 | getInstallCommand(`${pkg.name}@latest`, detectPackageManager()) 109 | ) 110 | 111 | this.infoNoPrefix() 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/classes/Page.ts: -------------------------------------------------------------------------------- 1 | import { Evt } from 'evt' 2 | import { AccessControlDefinition } from '../internalRpcSchema' 3 | import { 4 | IntervalActionDefinition, 5 | IntervalPageHandler, 6 | IntervalRouteDefinitions, 7 | } from '../types' 8 | 9 | export interface PageConfig { 10 | name: string 11 | description?: string 12 | unlisted?: boolean 13 | actions?: Record 14 | groups?: Record 15 | routes?: IntervalRouteDefinitions 16 | handler?: IntervalPageHandler 17 | access?: AccessControlDefinition 18 | } 19 | 20 | export default class Page { 21 | name: string 22 | description?: string 23 | unlisted?: boolean 24 | routes: IntervalRouteDefinitions 25 | handler?: IntervalPageHandler 26 | access?: AccessControlDefinition 27 | 28 | onChange: Evt 29 | #groupChangeCtx = Evt.newCtx() 30 | 31 | constructor(config: PageConfig) { 32 | this.name = config.name 33 | this.description = config.description 34 | this.unlisted = config.unlisted 35 | this.routes = { 36 | ...config.routes, 37 | ...config.actions, 38 | ...config.groups, 39 | } 40 | this.access = config.access 41 | this.handler = config.handler 42 | this.onChange = new Evt() 43 | 44 | for (const actionOrGroup of Object.values(this.routes)) { 45 | if (actionOrGroup instanceof Page) { 46 | actionOrGroup.onChange.attach(this.#groupChangeCtx, this.onChange.post) 47 | } 48 | } 49 | } 50 | 51 | add(slug: string, route: IntervalActionDefinition | Page) { 52 | this.routes[slug] = route 53 | 54 | if (route instanceof Page) { 55 | route.onChange.attach(this.#groupChangeCtx, this.onChange.post) 56 | } 57 | 58 | this.onChange.post() 59 | } 60 | 61 | remove(slug: string) { 62 | const route = this.routes[slug] 63 | if (route) { 64 | if (route instanceof Page) { 65 | route.onChange.detach(this.#groupChangeCtx) 66 | } 67 | 68 | delete this.routes[slug] 69 | this.onChange.post() 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/classes/Routes.ts: -------------------------------------------------------------------------------- 1 | import Logger from './Logger' 2 | import Interval, { IntervalActionDefinition, Page, QueuedAction } from '..' 3 | import { Ctx } from 'evt' 4 | 5 | /** 6 | * This is effectively a namespace inside of Interval with a little bit of its own state. 7 | */ 8 | export default class Routes { 9 | protected interval: Interval 10 | #logger: Logger 11 | #apiKey?: string 12 | #endpoint: string 13 | #groupChangeCtx: Ctx 14 | 15 | constructor( 16 | interval: Interval, 17 | endpoint: string, 18 | logger: Logger, 19 | ctx: Ctx, 20 | apiKey?: string 21 | ) { 22 | this.interval = interval 23 | this.#apiKey = apiKey 24 | this.#logger = logger 25 | this.#endpoint = endpoint + '/api/actions' 26 | this.#groupChangeCtx = ctx 27 | } 28 | 29 | /** 30 | * @deprecated Use `interval.enqueue()` instead. 31 | */ 32 | async enqueue( 33 | slug: string, 34 | args: Pick = {} 35 | ): Promise { 36 | return this.interval.enqueue(slug, args) 37 | } 38 | 39 | /** 40 | * @deprecated Use `interval.dequeue()` instead. 41 | */ 42 | async dequeue(id: string): Promise { 43 | return this.interval.dequeue(id) 44 | } 45 | 46 | add(slug: string, route: IntervalActionDefinition | Page) { 47 | if (!this.interval.config.routes) { 48 | this.interval.config.routes = {} 49 | } 50 | 51 | if (route instanceof Page) { 52 | route.onChange.attach(this.#groupChangeCtx, () => { 53 | this.interval.client?.handleActionsChange(this.interval.config) 54 | }) 55 | } 56 | 57 | this.interval.config.routes[slug] = route 58 | this.interval.client?.handleActionsChange(this.interval.config) 59 | } 60 | 61 | remove(slug: string) { 62 | for (const key of ['routes', 'actions', 'groups'] as const) { 63 | const routes = this.interval.config[key] 64 | 65 | if (!routes) continue 66 | const route = routes[slug] 67 | if (!route) continue 68 | 69 | if (route instanceof Page) { 70 | route.onChange.detach(this.#groupChangeCtx) 71 | } 72 | 73 | delete routes[slug] 74 | 75 | this.interval.client?.handleActionsChange(this.interval.config) 76 | return 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/classes/TransactionLoadingState.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BackwardCompatibleLoadingOptions, 3 | BackwardCompatibleLoadingState, 4 | } from '../internalRpcSchema' 5 | import Logger from './Logger' 6 | 7 | export interface TransactionLoadingStateConfig { 8 | logger: Logger 9 | send: (loadingState: BackwardCompatibleLoadingState) => Promise 10 | } 11 | 12 | export default class TransactionLoadingState { 13 | #logger: Logger 14 | #sender: TransactionLoadingStateConfig['send'] 15 | #state: BackwardCompatibleLoadingState | undefined 16 | #sendTimeout: NodeJS.Timeout | null = null 17 | #sendTimeoutMs = 100 18 | 19 | constructor(config: TransactionLoadingStateConfig) { 20 | this.#sender = config.send 21 | this.#logger = config.logger 22 | } 23 | 24 | async #sendState() { 25 | if (!this.#sendTimeout) { 26 | // Buffer send calls for 100ms to prevent accidental DoSing with 27 | // many loading calls 28 | this.#sendTimeout = setTimeout(() => { 29 | this.#sender(this.#state ?? {}).catch(err => { 30 | this.#logger.error('Failed sending loading state to Interval') 31 | this.#logger.debug(err) 32 | }) 33 | this.#sendTimeout = null 34 | }, this.#sendTimeoutMs) 35 | } 36 | } 37 | 38 | get state() { 39 | return { ...this.#state } 40 | } 41 | 42 | /** 43 | * Kicks off a loading spinner to provide context during any long-running action work. Can also be called with a single string argument as the label, or with no arguments to display only a spinner. 44 | * 45 | * **Usage:** 46 | * 47 | *```typescript 48 | * await ctx.loading.start({ 49 | * label: "Reticulating splines...", 50 | * }); 51 | * 52 | * await ctx.loading.start("Label only shorthand"); 53 | *``` 54 | */ 55 | async start(options?: string | BackwardCompatibleLoadingOptions) { 56 | if (typeof options === 'string') { 57 | options = { label: options } 58 | } else if (options === undefined) { 59 | options = {} 60 | } 61 | 62 | this.#state = { ...options } 63 | if (this.#state.itemsInQueue) { 64 | this.#state.itemsCompleted = 0 65 | } 66 | 67 | return this.#sendState() 68 | } 69 | 70 | /** 71 | * Updates any existing loading spinner initated with `ctx.loading.start` to dynamically provide new loading information to the action runner. 72 | * 73 | * **Usage:** 74 | * 75 | *```typescript 76 | * await ctx.loading.start({ 77 | * label: "Something is loading", 78 | * description: "Mapping all the things", 79 | * }); 80 | * 81 | * await ctx.loading.update({ 82 | * label: "Something is loading", 83 | * description: "Now reducing all the things", 84 | * }); 85 | *``` 86 | */ 87 | async update(options?: string | BackwardCompatibleLoadingOptions) { 88 | if (!this.#state) { 89 | this.#logger.warn('Please call `loading.start` before `loading.update`') 90 | return this.start(options) 91 | } 92 | 93 | if (typeof options === 'string') { 94 | options = { label: options } 95 | } else if (options === undefined) { 96 | options = {} 97 | } 98 | 99 | Object.assign(this.#state, options) 100 | 101 | if (this.#state?.itemsInQueue && this.#state.itemsCompleted === undefined) { 102 | this.#state.itemsCompleted = 0 103 | } 104 | 105 | return this.#sendState() 106 | } 107 | 108 | /** 109 | * Marks a chunk of work as completed to dynamically provide granular loading progress. Can only be used after `ctx.loading.start` was called with `itemsInQueue`. 110 | * 111 | * **Usage:** 112 | * 113 | *```typescript 114 | * await ctx.loading.start({ 115 | * label: "Migrating users", 116 | * description: "Enabling edit button for selected users", 117 | * itemsInQueue: 100, 118 | * }); 119 | * 120 | * for (const user of users) { 121 | * migrateUser(user); 122 | * await ctx.loading.completeOne(); 123 | * } 124 | *``` 125 | */ 126 | async completeOne() { 127 | if (!this.#state || !this.#state.itemsInQueue) { 128 | this.#logger.warn( 129 | 'Please call `loading.start` with `itemsInQueue` before `loading.completeOne`, nothing to complete.' 130 | ) 131 | return 132 | } 133 | 134 | if (this.#state.itemsCompleted === undefined) { 135 | this.#state.itemsCompleted = 0 136 | } 137 | 138 | this.#state.itemsCompleted++ 139 | return this.#sendState() 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/components/displayGrid.ts: -------------------------------------------------------------------------------- 1 | import { T_IO_PROPS, T_IO_STATE, GridItem, InternalGridItem } from '../ioSchema' 2 | import { filterRows, sortRows, TABLE_DATA_BUFFER_SIZE } from '../utils/table' 3 | import { GridDataFetcher, gridItemSerializer } from '../utils/grid' 4 | 5 | type PublicProps = Omit< 6 | T_IO_PROPS<'DISPLAY_GRID'>, 7 | 'data' | 'totalRecords' | 'isAsync' 8 | > & { 9 | renderItem: (row: Row) => GridItem & { 10 | /** @deprecated Please use `label` instead. */ 11 | title?: string | null 12 | } 13 | } & ( 14 | | { 15 | data: Row[] 16 | } 17 | | { 18 | getData: GridDataFetcher 19 | } 20 | ) 21 | 22 | export default function displayGrid(props: PublicProps) { 23 | // Rendering all rows on initialization is necessary for filtering and sorting 24 | const initialData = 25 | 'data' in props && props.data 26 | ? props.data.map((row, index) => 27 | gridItemSerializer({ 28 | key: index.toString(), 29 | renderItem: props.renderItem, 30 | row, 31 | }) 32 | ) 33 | : [] 34 | 35 | const isAsync = 'getData' in props && !!props.getData 36 | 37 | return { 38 | props: { 39 | ...props, 40 | data: initialData.slice(0, TABLE_DATA_BUFFER_SIZE), 41 | totalRecords: 42 | 'data' in props && props.data ? initialData.length : undefined, 43 | isAsync, 44 | } as T_IO_PROPS<'DISPLAY_GRID'>, 45 | async onStateChange(newState: T_IO_STATE<'DISPLAY_GRID'>) { 46 | let serializedData: InternalGridItem[] 47 | let totalRecords: number | undefined 48 | 49 | if (isAsync) { 50 | const { data, totalRecords: r } = await props.getData(newState) 51 | serializedData = data.map((row, index) => 52 | gridItemSerializer({ 53 | renderItem: props.renderItem, 54 | key: (index + newState.offset).toString(), 55 | row, 56 | }) 57 | ) 58 | totalRecords = r 59 | } else { 60 | const filtered = filterRows({ 61 | queryTerm: newState.queryTerm, 62 | data: initialData, 63 | }) 64 | 65 | serializedData = filtered.slice( 66 | newState.offset, 67 | newState.offset + 68 | Math.min(newState.pageSize * 3, TABLE_DATA_BUFFER_SIZE) 69 | ) 70 | 71 | totalRecords = initialData.length 72 | } 73 | 74 | return { 75 | ...props, 76 | data: serializedData, 77 | totalRecords, 78 | isAsync, 79 | } 80 | }, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/components/displayImage.ts: -------------------------------------------------------------------------------- 1 | import { T_IO_PROPS, ImageSize } from '../ioSchema' 2 | import { bufferToDataUrl } from '../utils/image' 3 | 4 | export default function displayImage( 5 | props: { 6 | alt?: T_IO_PROPS<'DISPLAY_IMAGE'>['alt'] 7 | width?: T_IO_PROPS<'DISPLAY_IMAGE'>['width'] 8 | height?: T_IO_PROPS<'DISPLAY_IMAGE'>['height'] 9 | size?: ImageSize 10 | } & ( 11 | | { 12 | url: string 13 | } 14 | | { 15 | buffer: Buffer 16 | } 17 | ) 18 | ) { 19 | const size = props.size 20 | delete props.size 21 | props.width = size ? size : props.width 22 | props.height = size ? size : props.height 23 | 24 | if ('buffer' in props) { 25 | return { 26 | props: { 27 | ...props, 28 | url: bufferToDataUrl(props.buffer), 29 | }, 30 | } 31 | } else { 32 | return { props } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/displayLink.ts: -------------------------------------------------------------------------------- 1 | import { SerializableRecord, T_IO_PROPS } from '../ioSchema' 2 | 3 | export default function displayLink( 4 | props: { 5 | theme?: T_IO_PROPS<'DISPLAY_LINK'>['theme'] 6 | } & ( 7 | | { 8 | url: string 9 | } 10 | | { 11 | route: string 12 | params?: SerializableRecord 13 | } 14 | // deprecated in favor of `route` 15 | // TODO: Add TS deprecated flag soon 16 | | { 17 | action: string 18 | params?: SerializableRecord 19 | } 20 | ) 21 | ) { 22 | return { 23 | props: props as T_IO_PROPS<'DISPLAY_LINK'>, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/displayMetadata.ts: -------------------------------------------------------------------------------- 1 | import type { Evt } from 'evt' 2 | import Logger from '../classes/Logger' 3 | import { 4 | T_IO_PROPS, 5 | Serializable, 6 | SerializableRecord, 7 | ImageSchema, 8 | } from '../ioSchema' 9 | import { EventualValue } from '../types' 10 | 11 | export interface EventualMetaItem { 12 | label: string 13 | value?: EventualValue 14 | url?: EventualValue 15 | image?: EventualValue 16 | route?: EventualValue 17 | /** @deprecated Please use `route` instead */ 18 | action?: EventualValue 19 | params?: EventualValue 20 | } 21 | 22 | export default function displaymetadata(logger: Logger) { 23 | return function displayMetadata( 24 | props: Pick, 'layout'> & { 25 | data: EventualMetaItem[] 26 | }, 27 | onPropsUpdate?: Evt> 28 | ): { props: T_IO_PROPS<'DISPLAY_METADATA'> } { 29 | const layout = props.layout 30 | const metaItems: EventualMetaItem[] = [] 31 | const data: T_IO_PROPS<'DISPLAY_METADATA'>['data'] = props.data.map( 32 | metaItem => { 33 | metaItem = { ...metaItem } 34 | 35 | const initialItem: T_IO_PROPS<'DISPLAY_METADATA'>['data'][0] = { 36 | label: metaItem.label, 37 | } 38 | 39 | // Currently doing all of this repetitive work separately to leverage 40 | // static type checking, but could be done more dynamically in a loop as well 41 | 42 | if ('value' in metaItem && metaItem.value !== undefined) { 43 | if (typeof metaItem.value === 'function') { 44 | metaItem.value = metaItem.value() 45 | } 46 | 47 | if (!(metaItem.value instanceof Promise)) { 48 | initialItem.value = metaItem.value 49 | } else { 50 | initialItem.value = undefined 51 | } 52 | } 53 | 54 | if ('url' in metaItem && metaItem.url !== undefined) { 55 | if (typeof metaItem.url === 'function') { 56 | metaItem.url = metaItem.url() 57 | } 58 | 59 | if (!(metaItem.url instanceof Promise)) { 60 | initialItem.url = metaItem.url 61 | } else { 62 | initialItem.url = undefined 63 | } 64 | } 65 | 66 | if ('image' in metaItem && metaItem.image !== undefined) { 67 | if (typeof metaItem.image === 'function') { 68 | metaItem.image = metaItem.image() 69 | } 70 | 71 | if (!(metaItem.image instanceof Promise)) { 72 | initialItem.image = metaItem.image 73 | } else { 74 | initialItem.image = undefined 75 | } 76 | } 77 | 78 | if ('route' in metaItem && metaItem.route !== undefined) { 79 | if (typeof metaItem.route === 'function') { 80 | metaItem.route = metaItem.route() 81 | } 82 | 83 | if (!(metaItem.route instanceof Promise)) { 84 | initialItem.route = metaItem.route 85 | } else { 86 | initialItem.route = undefined 87 | } 88 | } 89 | 90 | if ('action' in metaItem && metaItem.action !== undefined) { 91 | if (typeof metaItem.action === 'function') { 92 | metaItem.action = metaItem.action() 93 | } 94 | 95 | if (!(metaItem.action instanceof Promise)) { 96 | initialItem.action = metaItem.action 97 | } else { 98 | initialItem.action = undefined 99 | } 100 | } 101 | 102 | if ('params' in metaItem && metaItem.params !== undefined) { 103 | if (typeof metaItem.params === 'function') { 104 | metaItem.params = metaItem.params() 105 | } 106 | 107 | if (!(metaItem.params instanceof Promise)) { 108 | initialItem.params = metaItem.params 109 | } else { 110 | initialItem.params = undefined 111 | } 112 | } 113 | 114 | metaItems.push(metaItem) 115 | 116 | return initialItem 117 | } 118 | ) 119 | 120 | if (onPropsUpdate) { 121 | for (let i = 0; i < metaItems.length; i++) { 122 | const metaItem = metaItems[i] 123 | 124 | if ('value' in metaItem) { 125 | if (metaItem.value instanceof Promise) { 126 | metaItem.value 127 | .then(resolvedValue => { 128 | data[i].value = resolvedValue 129 | onPropsUpdate?.post({ 130 | layout, 131 | data, 132 | }) 133 | }) 134 | .catch(err => { 135 | logger.error( 136 | 'Error updating metadata field "value" with result from Promise:', 137 | err 138 | ) 139 | }) 140 | } 141 | } 142 | 143 | if ('url' in metaItem) { 144 | if (metaItem.url instanceof Promise) { 145 | metaItem.url 146 | .then(resolvedurl => { 147 | data[i].url = resolvedurl 148 | onPropsUpdate?.post({ 149 | layout, 150 | data, 151 | }) 152 | }) 153 | .catch(err => { 154 | logger.error( 155 | 'Error updating metadata field "url" with result from Promise:', 156 | err 157 | ) 158 | }) 159 | } 160 | } 161 | 162 | if ('image' in metaItem) { 163 | if (metaItem.image instanceof Promise) { 164 | metaItem.image 165 | .then(resolvedimage => { 166 | data[i].image = resolvedimage 167 | onPropsUpdate?.post({ 168 | layout, 169 | data, 170 | }) 171 | }) 172 | .catch(err => { 173 | logger.error( 174 | 'Error updating metadata field "image" with result from Promise:', 175 | err 176 | ) 177 | }) 178 | } 179 | } 180 | 181 | if ('route' in metaItem) { 182 | if (metaItem.route instanceof Promise) { 183 | metaItem.route 184 | .then(resolvedroute => { 185 | data[i].route = resolvedroute 186 | onPropsUpdate?.post({ 187 | layout, 188 | data, 189 | }) 190 | }) 191 | .catch(err => { 192 | logger.error( 193 | 'Error updating metadata field "route" with result from Promise:', 194 | err 195 | ) 196 | }) 197 | } 198 | } 199 | 200 | if ('action' in metaItem) { 201 | if (metaItem.action instanceof Promise) { 202 | metaItem.action 203 | .then(resolvedaction => { 204 | data[i].action = resolvedaction 205 | onPropsUpdate?.post({ 206 | layout, 207 | data, 208 | }) 209 | }) 210 | .catch(err => { 211 | logger.error( 212 | 'Error updating metadata field "action" with result from Promise:', 213 | err 214 | ) 215 | }) 216 | } 217 | } 218 | 219 | if ('params' in metaItem) { 220 | if (metaItem.params instanceof Promise) { 221 | metaItem.params 222 | .then(resolvedparams => { 223 | data[i].params = resolvedparams 224 | onPropsUpdate?.post({ 225 | layout, 226 | data, 227 | }) 228 | }) 229 | .catch(err => { 230 | logger.error( 231 | 'Error updating metadata field "params" with result from Promise:', 232 | err 233 | ) 234 | }) 235 | } 236 | } 237 | } 238 | } 239 | 240 | return { 241 | props: { 242 | data, 243 | layout, 244 | }, 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/components/displayTable.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import Logger from '../classes/Logger' 3 | import { tableRow, T_IO_PROPS, T_IO_STATE, internalTableRow } from '../ioSchema' 4 | import { MenuItem, TableColumn } from '../types' 5 | import { 6 | columnsBuilder, 7 | tableRowSerializer, 8 | filterRows, 9 | sortRows, 10 | TABLE_DATA_BUFFER_SIZE, 11 | missingColumnMessage, 12 | TableDataFetcher, 13 | columnsWithoutRender, 14 | } from '../utils/table' 15 | 16 | type PublicProps> = Omit< 17 | T_IO_PROPS<'DISPLAY_TABLE'>, 18 | 'data' | 'columns' | 'totalRecords' | 'isAsync' 19 | > & { 20 | columns?: (TableColumn | (string & keyof Row))[] 21 | rowMenuItems?: (row: Row) => MenuItem[] 22 | } & ( 23 | | { 24 | data: Row[] 25 | } 26 | | { 27 | getData: TableDataFetcher 28 | } 29 | ) 30 | 31 | export default function displayTable(logger: Logger) { 32 | return function displayTable = any>( 33 | props: PublicProps 34 | ) { 35 | const initialColumns = columnsBuilder(props, column => 36 | logger.warn(missingColumnMessage('io.display.table')(column)) 37 | ) 38 | 39 | // Rendering all rows on initialization is necessary for filtering and sorting 40 | const initialData = 41 | 'data' in props && props.data 42 | ? props.data.map((row, index) => 43 | tableRowSerializer({ 44 | key: index.toString(), 45 | row, 46 | columns: initialColumns, 47 | menuBuilder: props.rowMenuItems, 48 | logger, 49 | }) 50 | ) 51 | : [] 52 | 53 | const isAsync = 'getData' in props && !!props.getData 54 | 55 | return { 56 | props: { 57 | ...props, 58 | data: initialData.slice(0, TABLE_DATA_BUFFER_SIZE), 59 | totalRecords: 60 | 'data' in props && props.data ? initialData.length : undefined, 61 | columns: columnsWithoutRender(initialColumns), 62 | isAsync, 63 | } as T_IO_PROPS<'DISPLAY_TABLE'>, 64 | async onStateChange(newState: T_IO_STATE<'DISPLAY_TABLE'>) { 65 | let serializedData: z.infer[] 66 | let builtColumns: TableColumn[] 67 | let totalRecords: number | undefined 68 | 69 | if (isAsync) { 70 | const { data, totalRecords: r } = await props.getData(newState) 71 | builtColumns = columnsBuilder( 72 | { 73 | columns: props.columns, 74 | data, 75 | }, 76 | column => 77 | logger.warn(missingColumnMessage('io.display.table')(column)) 78 | ) 79 | serializedData = data.map((row, index) => 80 | tableRowSerializer({ 81 | key: (index + newState.offset).toString(), 82 | row, 83 | columns: builtColumns, 84 | menuBuilder: props.rowMenuItems, 85 | logger, 86 | }) 87 | ) 88 | totalRecords = r 89 | } else { 90 | const filtered = filterRows({ 91 | queryTerm: newState.queryTerm, 92 | data: initialData, 93 | }) 94 | 95 | const sorted = sortRows({ 96 | data: filtered, 97 | column: newState.sortColumn ?? null, 98 | direction: newState.sortDirection ?? null, 99 | }) 100 | 101 | serializedData = sorted.slice( 102 | newState.offset, 103 | newState.offset + 104 | Math.min(newState.pageSize * 3, TABLE_DATA_BUFFER_SIZE) 105 | ) 106 | 107 | builtColumns = initialColumns 108 | totalRecords = sorted.length 109 | } 110 | 111 | return { 112 | ...props, 113 | data: serializedData, 114 | totalRecords, 115 | isAsync, 116 | columns: columnsWithoutRender(builtColumns), 117 | } 118 | }, 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/components/displayVideo.ts: -------------------------------------------------------------------------------- 1 | import { T_IO_PROPS, ImageSize } from '../ioSchema' 2 | import { IntervalError } from '..' 3 | 4 | const MAX_BUFFER_SIZE_MB = 50 5 | 6 | export default function displayVideo( 7 | props: { 8 | width?: T_IO_PROPS<'DISPLAY_VIDEO'>['width'] 9 | height?: T_IO_PROPS<'DISPLAY_VIDEO'>['height'] 10 | size?: ImageSize 11 | muted?: T_IO_PROPS<'DISPLAY_VIDEO'>['muted'] 12 | loop?: T_IO_PROPS<'DISPLAY_VIDEO'>['loop'] 13 | } & ( 14 | | { 15 | url: string 16 | } 17 | | { 18 | buffer: Buffer 19 | } 20 | ) 21 | ) { 22 | const size = props.size 23 | delete props.size 24 | props.width = size ? size : props.width 25 | props.height = size ? size : props.height 26 | 27 | if ('buffer' in props) { 28 | if (Buffer.byteLength(props.buffer) > MAX_BUFFER_SIZE_MB * 1000 * 1000) { 29 | throw new IntervalError( 30 | `Buffer for io.display.video is too large, must be under ${MAX_BUFFER_SIZE_MB} MB` 31 | ) 32 | } 33 | 34 | const data = props.buffer.toString('base64') 35 | 36 | // using first character as a simple check for common video formats: 37 | let mime 38 | switch (data[0]) { 39 | case 'A': 40 | mime = 'video/mp4' 41 | break 42 | case 'G': 43 | mime = 'video/webm' 44 | break 45 | case 'T': 46 | mime = 'video/ogg' 47 | break 48 | case 'U': 49 | mime = 'video/avi' 50 | break 51 | default: 52 | // A fallback of `video/unknown` doesn't work like it does for images. 53 | // Various formats seem to play fine in chrome with mp4. 54 | // Still relying on the switch ^^ for correctness though. 55 | mime = 'video/mp4' 56 | break 57 | } 58 | 59 | return { 60 | props: { 61 | ...props, 62 | url: `data:${mime};base64,${data}`, 63 | }, 64 | async prepareProps(props: T_IO_PROPS<'DISPLAY_VIDEO'>) { 65 | return props 66 | }, 67 | } 68 | } else { 69 | return { props } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/inputDate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | T_IO_PROPS, 3 | T_IO_RETURNS, 4 | DateObject, 5 | DateTimeObject, 6 | } from '../ioSchema' 7 | 8 | function dateToDateObject(d: Date): DateObject { 9 | return { 10 | year: d.getFullYear(), 11 | month: d.getMonth() + 1, 12 | day: d.getDate(), 13 | } 14 | } 15 | 16 | function dateToDateTimeObject(d: Date): DateTimeObject { 17 | return { 18 | ...dateToDateObject(d), 19 | hour: d.getHours(), 20 | minute: d.getMinutes(), 21 | } 22 | } 23 | 24 | function normalizeDateObject( 25 | d: DateObject | Date | undefined 26 | ): DateObject | undefined { 27 | return d && d instanceof Date ? dateToDateObject(d) : d 28 | } 29 | 30 | function normalizeDateTimeObject( 31 | d: DateTimeObject | Date | undefined 32 | ): DateTimeObject | undefined { 33 | return d && d instanceof Date ? dateToDateTimeObject(d) : d 34 | } 35 | 36 | export function date( 37 | props: Omit, 'defaultValue' | 'min' | 'max'> & { 38 | defaultValue?: DateObject | Date 39 | min?: DateObject | Date 40 | max?: DateObject | Date 41 | } 42 | ) { 43 | return { 44 | props: { 45 | ...props, 46 | defaultValue: normalizeDateObject(props.defaultValue), 47 | min: normalizeDateObject(props.min), 48 | max: normalizeDateObject(props.max), 49 | }, 50 | getValue(response: T_IO_RETURNS<'INPUT_DATE'>) { 51 | const jsDate = new Date( 52 | response.year, 53 | response.month - 1, 54 | response.day, 55 | 0, 56 | 0, 57 | 0, 58 | 0 59 | ) 60 | 61 | return { 62 | ...response, 63 | jsDate, 64 | } 65 | }, 66 | } 67 | } 68 | 69 | export function datetime( 70 | props: Omit, 'defaultValue' | 'min' | 'max'> & { 71 | defaultValue?: DateTimeObject | Date 72 | min?: DateTimeObject | Date 73 | max?: DateTimeObject | Date 74 | } 75 | ) { 76 | return { 77 | props: { 78 | ...props, 79 | defaultValue: normalizeDateTimeObject(props.defaultValue), 80 | min: normalizeDateTimeObject(props.min), 81 | max: normalizeDateTimeObject(props.max), 82 | }, 83 | getValue(response: T_IO_RETURNS<'INPUT_DATETIME'>) { 84 | const jsDate = new Date( 85 | response.year, 86 | response.month - 1, 87 | response.day, 88 | response.hour, 89 | response.minute, 90 | 0, 91 | 0 92 | ) 93 | 94 | return { 95 | ...response, 96 | jsDate, 97 | } 98 | }, 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/components/search.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ImageSchema, 3 | T_IO_PROPS, 4 | T_IO_RETURNS, 5 | T_IO_STATE, 6 | } from '../ioSchema' 7 | import IOError from '../classes/IOError' 8 | 9 | type RenderResultDef = 10 | | string 11 | | number 12 | | boolean 13 | | Date 14 | | { 15 | label: string | number | boolean | Date 16 | description?: string 17 | image?: ImageSchema 18 | /** 19 | * @deprecated Deprecated in favor of `image.url`. 20 | */ 21 | imageUrl?: string 22 | } 23 | 24 | type InternalResults = T_IO_PROPS<'SEARCH'>['results'] 25 | type DefaultValue = T_IO_PROPS<'SEARCH'>['defaultValue'] 26 | 27 | export default function search({ 28 | onSearch, 29 | initialResults = [], 30 | defaultValue, 31 | renderResult, 32 | disabled = false, 33 | ...rest 34 | }: { 35 | placeholder?: string 36 | helpText?: string 37 | disabled?: boolean 38 | initialResults?: Result[] 39 | defaultValue?: Result 40 | renderResult: (result: Result) => RenderResultDef 41 | onSearch: (query: string) => Promise 42 | }) { 43 | let resultBatchIndex = 0 44 | let resultMap: Map = new Map([['0', initialResults]]) 45 | 46 | type Output = Result 47 | 48 | function renderResults(results: Result[]): InternalResults { 49 | return results.map((result, index) => { 50 | const r = renderResult(result) 51 | 52 | const value = `${resultBatchIndex}:${index}` 53 | 54 | if (r && typeof r === 'object' && !(r instanceof Date)) { 55 | return { 56 | ...r, 57 | value, 58 | } 59 | } 60 | 61 | return { 62 | value, 63 | label: r.toString(), 64 | } 65 | }) 66 | } 67 | 68 | const results = renderResults(initialResults) 69 | 70 | function getDefaultValue(defaultValue: Result): DefaultValue { 71 | let defaultResults = resultMap.get('default') 72 | if (!defaultResults) { 73 | defaultResults = [] 74 | resultMap.set('default', defaultResults) 75 | } 76 | const r = renderResult(defaultValue) 77 | const value = `default:${defaultResults.length}` 78 | defaultResults.push(defaultValue) 79 | 80 | if (r && typeof r == 'object' && !(r instanceof Date)) { 81 | results.push({ 82 | ...r, 83 | value, 84 | }) 85 | } else { 86 | results.push({ 87 | value, 88 | label: r.toString(), 89 | }) 90 | } 91 | 92 | return value 93 | } 94 | 95 | const props: T_IO_PROPS<'SEARCH'> = { 96 | ...rest, 97 | defaultValue: defaultValue ? getDefaultValue(defaultValue) : undefined, 98 | results, 99 | disabled, 100 | } 101 | 102 | return { 103 | props, 104 | getValue(response: T_IO_RETURNS<'SEARCH'>) { 105 | try { 106 | const [batchIndex, index] = response.split(':') 107 | const batch = resultMap.get(batchIndex) 108 | if (!batch) throw new IOError('BAD_RESPONSE') 109 | 110 | return batch[Number(index)] as Output 111 | } catch (err) { 112 | if (err instanceof IOError) throw err 113 | throw new IOError('BAD_RESPONSE') 114 | } 115 | }, 116 | getDefaultValue, 117 | async onStateChange(newState: T_IO_STATE<'SEARCH'>) { 118 | const results = await onSearch(newState.queryTerm) 119 | 120 | resultBatchIndex++ 121 | const newIndex = resultBatchIndex.toString() 122 | resultMap.set(newIndex, results) 123 | 124 | return { results: renderResults(results) } 125 | }, 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/components/selectMultiple.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { 3 | T_IO_PROPS, 4 | T_IO_RETURNS, 5 | labelValue, 6 | primitiveValue, 7 | } from '../ioSchema' 8 | import Logger from '../classes/Logger' 9 | 10 | type SelectMultipleProps< 11 | Option extends z.infer | z.infer 12 | > = Omit, 'options' | 'defaultValue'> & { 13 | options: Option[] 14 | defaultValue?: Option[] 15 | } 16 | 17 | export default function selectMultiple(logger: Logger) { 18 | return < 19 | Option extends z.infer | z.infer 20 | >( 21 | props: SelectMultipleProps