├── .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 |
3 |
4 |
5 | # Interval Node.js SDK
6 |
7 | [](https://www.npmjs.com/package/@interval/sdk) [](https://interval.com/docs) [](https://twitter.com/useinterval) [](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 |
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