From 69d885412f758931805649081d116d3b98645f08 Mon Sep 17 00:00:00 2001 From: Jacob Mischka Date: Tue, 20 Dec 2022 13:18:13 -0600 Subject: [PATCH 1/9] Bubble response and state errors, and catch for pages cc https://github.com/interval/interval-node/issues/19 --- src/classes/IOClient.ts | 143 +++++++++++++++++++--------------- src/classes/IOComponent.ts | 23 +++++- src/classes/IOError.ts | 7 +- src/classes/IntervalClient.ts | 34 +++++--- src/classes/IntervalError.ts | 1 + src/classes/Layout.ts | 18 ++--- src/examples/basic/index.ts | 56 +------------ src/examples/basic/table.ts | 84 +++++++++++++++++--- 8 files changed, 214 insertions(+), 152 deletions(-) diff --git a/src/classes/IOClient.ts b/src/classes/IOClient.ts index 834d3de..752a3e3 100644 --- a/src/classes/IOClient.ts +++ b/src/classes/IOClient.ts @@ -151,87 +151,106 @@ export class IOClient { } this.onResponseHandler = async result => { - if (result.inputGroupKey && result.inputGroupKey !== inputGroupKey) { - this.logger.debug('Received response for other input group') - return - } - - if (this.isCanceled || isReturned) { - this.logger.debug('Received response after IO call complete') - return - } + try { + if ( + result.inputGroupKey && + result.inputGroupKey !== inputGroupKey + ) { + this.logger.debug('Received response for other input group') + return + } - // Transaction canceled from Interval cloud UI - if (result.kind === 'CANCELED') { - this.isCanceled = true - reject(new IOError('CANCELED')) - return - } + if (this.isCanceled || isReturned) { + this.logger.debug('Received response after IO call complete') + return + } - if (result.values.length !== components.length) { - throw new Error('Mismatch in return array length') - } + // Transaction canceled from Interval cloud UI + if (result.kind === 'CANCELED') { + this.isCanceled = true + reject(new IOError('CANCELED')) + return + } - if (result.valuesMeta) { - result.values = superjson.deserialize({ - json: result.values, - meta: result.valuesMeta, - }) - } + if (result.values.length !== components.length) { + throw new Error('Mismatch in return array length') + } - if (result.kind === 'RETURN') { - const validities = await Promise.all( - result.values.map(async (v, index) => { - const component = components[index] - if (component.validator) { - const resp = await component.handleValidation(v) - if (resp !== undefined) { - return false - } - } - return true + if (result.valuesMeta) { + result.values = superjson.deserialize({ + json: result.values, + meta: result.valuesMeta, }) - ) - - validationErrorMessage = undefined - - if (validities.some(v => !v)) { - render() - return } - if (groupValidator) { - validationErrorMessage = await groupValidator( - result.values as IOClientRenderReturnValues + if (result.kind === 'RETURN') { + const validities = await Promise.all( + result.values.map(async (v, index) => { + const component = components[index] + if (component.validator) { + const resp = await component.handleValidation(v) + if (resp !== undefined) { + return false + } + } + return true + }) ) - if (validationErrorMessage) { + validationErrorMessage = undefined + + if (validities.some(v => !v)) { render() return } - } - isReturned = true - - result.values.forEach((v, index) => { - // @ts-ignore - components[index].setReturnValue(v) - }) + if (groupValidator) { + validationErrorMessage = await groupValidator( + result.values as IOClientRenderReturnValues + ) - return - } + if (validationErrorMessage) { + render() + return + } + } - if (result.kind === 'SET_STATE') { - for (const [index, newState] of result.values.entries()) { - const prevState = components[index].getInstance().state + isReturned = true - if (JSON.stringify(newState) !== JSON.stringify(prevState)) { - this.logger.debug(`New state at ${index}`, newState) + result.values.forEach((v, index) => { // @ts-ignore - await components[index].setState(newState) + components[index].setReturnValue(v) + }) + + return + } + + if (result.kind === 'SET_STATE') { + for (const [index, newState] of result.values.entries()) { + const prevState = components[index].getInstance().state + + if (JSON.stringify(newState) !== JSON.stringify(prevState)) { + this.logger.debug(`New state at ${index}`, newState) + // @ts-ignore + await components[index].setState(newState) + } + } + render() + } + } catch (err) { + if (err instanceof Error) { + this.logger.error(err.message) + if (err.cause) { + if (err.cause instanceof Error) { + this.logger.error(err.cause.message) + } else { + this.logger.error(err.cause) + } } + } else { + this.logger.error(err) } - render() + reject(err) } } diff --git a/src/classes/IOComponent.ts b/src/classes/IOComponent.ts index 4b60a3a..a2931c9 100644 --- a/src/classes/IOComponent.ts +++ b/src/classes/IOComponent.ts @@ -1,4 +1,4 @@ -import { z } from 'zod' +import { z, ZodError } from 'zod' import { ioSchema, resolvesImmediately, @@ -7,6 +7,7 @@ import { T_IO_RETURNS, } from '../ioSchema' import { deserializeDates } from '../utils/deserialize' +import IOError from './IOError' import { IOPromiseValidator } from './IOPromise' type IoSchema = typeof ioSchema @@ -155,7 +156,12 @@ export default class IOComponent { this.resolver(parsed) } } catch (err) { - console.error('Received invalid return value:', err) + const ioError = new IOError( + 'BAD_RESPONSE', + 'Received invalid return value' + ) + ioError.cause = err + throw ioError } } @@ -178,7 +184,18 @@ export default class IOComponent { } this.onStateChangeHandler && this.onStateChangeHandler() } catch (err) { - console.error('Received invalid state:', err) + if (err instanceof ZodError) { + const ioError = new IOError('BAD_RESPONSE', 'Received invalid state') + ioError.cause = err + throw ioError + } else { + const ioError = new IOError( + 'RESPONSE_HANDLER_ERROR', + 'Error in state change handler' + ) + ioError.cause = err + throw ioError + } } return this.instance diff --git a/src/classes/IOError.ts b/src/classes/IOError.ts index bcad56d..0ddafc5 100644 --- a/src/classes/IOError.ts +++ b/src/classes/IOError.ts @@ -1,4 +1,8 @@ -export type IOErrorKind = 'CANCELED' | 'TRANSACTION_CLOSED' | 'BAD_RESPONSE' +export type IOErrorKind = + | 'CANCELED' + | 'TRANSACTION_CLOSED' + | 'BAD_RESPONSE' + | 'RESPONSE_HANDLER_ERROR' export default class IOError extends Error { kind: IOErrorKind @@ -6,5 +10,6 @@ export default class IOError extends Error { constructor(kind: IOErrorKind, message?: string) { super(message) this.kind = kind + this.name = 'IOError' } } diff --git a/src/classes/IntervalClient.ts b/src/classes/IntervalClient.ts index 0c04332..73a8ce5 100644 --- a/src/classes/IntervalClient.ts +++ b/src/classes/IntervalClient.ts @@ -59,8 +59,6 @@ import { Layout, BasicLayout, LayoutSchemaInput, - MetaItemSchema, - MetaItemsSchema, BasicLayoutConfig, } from './Layout' @@ -1249,6 +1247,7 @@ export default class IntervalClient { layoutKey, error: error.name, message: error.message, + // stack: error.stack, } } else { return { @@ -1334,19 +1333,34 @@ export default class IntervalClient { } if (page.children) { - group(page.children).then(() => { - this.#logger.debug( - 'Initial children render complete for pageKey', - pageKey - ) - }) + group(page.children).then( + () => { + this.#logger.debug( + 'Initial children render complete for pageKey', + pageKey + ) + }, + // We use the reject callback form because it's an IOGroupPromise, + // not a real Promise and we don't currently implement `.catch()` + // (I don't know how or if it's possbile right now, thenable objects aren't documented well) + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables + err => { + this.#logger.error(err) + if (err instanceof IOError && err.cause) { + errors.push(pageError(err.cause)) + } else { + errors.push(pageError(err)) + } + + scheduleSendPage() + } + ) } else { scheduleSendPage() } }) .catch(async err => { - this.#logger.error(err) - errors.push(pageError(err)) + this.#logger.error('Error in page:', err) const pageLayout: LayoutSchemaInput = { kind: 'BASIC', errors, diff --git a/src/classes/IntervalError.ts b/src/classes/IntervalError.ts index a3ce7cc..f46af4d 100644 --- a/src/classes/IntervalError.ts +++ b/src/classes/IntervalError.ts @@ -1,5 +1,6 @@ export default class IntervalError extends Error { constructor(message: string) { super(message) + this.name = 'IntervalError' } } diff --git a/src/classes/Layout.ts b/src/classes/Layout.ts index b45abe5..46e807a 100644 --- a/src/classes/Layout.ts +++ b/src/classes/Layout.ts @@ -62,6 +62,13 @@ export const META_ITEMS_SCHEMA = z.object({ export type MetaItemsSchema = z.infer +export const LAYOUT_ERROR_SCHEMA = z.object({ + layoutKey: z.string().optional(), + error: z.string(), + message: z.string(), + stack: z.string().optional(), +}) + export const BASIC_LAYOUT_SCHEMA = z.object({ kind: z.literal('BASIC'), title: z.string().nullish(), @@ -69,15 +76,7 @@ export const BASIC_LAYOUT_SCHEMA = z.object({ children: IO_RENDER.optional(), metadata: META_ITEMS_SCHEMA.optional(), menuItems: z.array(buttonItem).optional(), - errors: z - .array( - z.object({ - layoutKey: z.string().optional(), - error: z.string(), - message: z.string(), - }) - ) - .optional(), + errors: z.array(LAYOUT_ERROR_SCHEMA).optional(), }) // To be extended with z.discriminatedUnion when adding different pages @@ -87,5 +86,6 @@ export type LayoutSchema = z.infer export type LayoutSchemaInput = z.input export type BasicLayoutSchema = z.infer export type BasicLayoutSchemaInput = z.input +export type LayoutError = z.infer export { metaItemSchema as META_ITEM_SCHEMA } diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index 2cad226..6ac0e09 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -1,5 +1,5 @@ import { T_IO_PROPS } from './../../ioSchema' -import Interval, { IOError, io, ctx, Page, Layout } from '../../index' +import Interval, { IOError, io, ctx, Page } from '../../index' import IntervalClient from '../../classes/IntervalClient' import { IntervalActionDefinition, @@ -13,7 +13,6 @@ import unauthorized from './unauthorized' import './ghostHost' import { generateS3Urls } from '../utils/upload' import fs from 'fs' -import fakeUsers from '../utils/fakeUsers' const actionLinks: IntervalActionHandler = async () => { await io.group([ @@ -189,59 +188,6 @@ const interval = new Interval({ logLevel: 'debug', endpoint: 'ws://localhost:3000/websocket', routes: { - big_table: new Page({ - name: 'Big table', - handler: async () => { - const bigData = [ - ...fakeUsers, - ...fakeUsers, - ...fakeUsers, - ...fakeUsers, - ...fakeUsers, - ...fakeUsers, - ...fakeUsers, - ...fakeUsers, - ...fakeUsers, - ...fakeUsers, - ] - - return new Layout({ - children: [ - io.display.table('Large table', { - data: bigData, - // These don't work, they're just here to make the payload bigger - rowMenuItems: row => [ - { - label: 'Browse app structure', - action: 'organizations/app_structure', - params: { org: row.email }, - }, - { - label: 'Change slug', - action: 'organizations/change_slug', - params: { org: row.email }, - }, - { - label: 'Enable SSO', - action: 'organizations/create_org_sso', - params: { org: row.email }, - }, - { - label: 'Toggle feature flag', - action: 'organizations/org_feature_flag', - params: { org: row.email }, - }, - { - label: 'Transfer owner', - action: 'organizations/transfer_ownership', - params: { org: row.email }, - }, - ], - }), - ], - }) - }, - }), two_searches: async io => { const [r1, r2] = await io.group([ io.search('One', { diff --git a/src/examples/basic/table.ts b/src/examples/basic/table.ts index ab987ef..4afcd2f 100644 --- a/src/examples/basic/table.ts +++ b/src/examples/basic/table.ts @@ -1,5 +1,5 @@ import { IntervalActionDefinition } from '@interval/sdk/src/types' -import { IntervalActionHandler } from '../..' +import { IntervalActionHandler, Page, Layout, io } from '../..' import { faker } from '@faker-js/faker' function generateRows(count: number, offset = 0) { @@ -149,9 +149,52 @@ export const multiple_tables: IntervalActionHandler = async io => { ]) } -export const async_table: IntervalActionHandler = async io => { - const allData = generateRows(500) - await io.display.table[0]>('Display users', { +export const big_payload_table = new Page({ + name: 'Big table', + handler: async () => { + const bigData = generateRows(100000) + + return new Layout({ + children: [ + io.display.table('Large table', { + data: bigData, + // These don't work, they're just here to make the payload bigger + rowMenuItems: row => [ + { + label: 'Browse app structure', + action: 'organizations/app_structure', + params: { org: row.email }, + }, + { + label: 'Change slug', + action: 'organizations/change_slug', + params: { org: row.email }, + }, + { + label: 'Enable SSO', + action: 'organizations/create_org_sso', + params: { org: row.email }, + }, + { + label: 'Toggle feature flag', + action: 'organizations/org_feature_flag', + params: { org: row.email }, + }, + { + label: 'Transfer owner', + action: 'organizations/transfer_ownership', + params: { org: row.email }, + }, + ], + }), + ], + }) + }, +}) + +function asyncTable(numRows: number) { + const allData = generateRows(numRows) + return io.display.table[0]>('Display users', { async getData({ queryTerm, sortColumn, sortDirection, offset, pageSize }) { let filteredData = allData.slice() @@ -194,14 +237,18 @@ export const async_table: IntervalActionHandler = async io => { 'id', { label: 'User', - renderCell: row => ({ - label: row.name, - image: { - alt: 'Alt tag', - url: row.image, - size: 'small', - }, - }), + renderCell: row => { + throw new Error('Oops!') + + return { + label: row.name, + image: { + alt: 'Alt tag', + url: row.image, + size: 'small', + }, + } + }, }, { label: 'Email', @@ -230,6 +277,19 @@ export const async_table: IntervalActionHandler = async io => { }) } +export const async_table_page = new Page({ + name: 'Async table - in a page', + handler: async () => { + return new Layout({ + children: [asyncTable(500)], + }) + }, +}) + +export const async_table: IntervalActionHandler = async () => { + await asyncTable(500) +} + export const select_table: IntervalActionHandler = async io => { faker.seed(0) From 355420646093261df7af3063aa949f9f211d9a63 Mon Sep 17 00:00:00 2001 From: Jacob Mischka Date: Wed, 21 Dec 2022 11:21:28 -0600 Subject: [PATCH 2/9] Add eslint-plugin-promise, fix many lint errors and warnings --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 54aee9c..216444f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "types": "dist/index.d.ts", "scripts": { "tar": "yarn pack", + "check": "tsc --noEmit", "build:envoy": "yarn envoy $INIT_CWD/envoy.config.ts $INIT_CWD/.env $INIT_CWD/src/env.ts", "build": "yarn build:envoy && tsc", "demo:basic": "node ./dist/examples/basic/index.js", From c3a09c2034c3c093962849005110d92c05e4e0a2 Mon Sep 17 00:00:00 2001 From: Jacob Mischka Date: Wed, 21 Dec 2022 13:35:55 -0600 Subject: [PATCH 3/9] Type check accessorKey/column shorthands according to table data --- src/components/displayTable.ts | 2 +- src/components/selectTable.ts | 2 +- src/types.ts | 18 +++++++++++------- src/utils/table.ts | 7 ++++--- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/components/displayTable.ts b/src/components/displayTable.ts index bf218a4..ca1d473 100644 --- a/src/components/displayTable.ts +++ b/src/components/displayTable.ts @@ -17,7 +17,7 @@ type PublicProps> = Omit< T_IO_PROPS<'DISPLAY_TABLE'>, 'data' | 'columns' | 'totalRecords' | 'isAsync' > & { - columns?: (TableColumn | string)[] + columns?: (TableColumn | (string & keyof Row))[] rowMenuItems?: (row: Row) => MenuItem[] } & ( | { diff --git a/src/components/selectTable.ts b/src/components/selectTable.ts index 410e2eb..ce5c8c2 100644 --- a/src/components/selectTable.ts +++ b/src/components/selectTable.ts @@ -13,7 +13,7 @@ import { type PublicProps = Omit, 'data' | 'columns'> & { data: Row[] - columns?: (TableColumn | string)[] + columns?: (TableColumn | (string & keyof Row))[] rowMenuItems?: (row: Row) => MenuItem[] } diff --git a/src/types.ts b/src/types.ts index 1a02f27..015a839 100644 --- a/src/types.ts +++ b/src/types.ts @@ -195,11 +195,13 @@ export type ComponentsRenderer< components: Components, validator?: IOClientRenderValidator, continueButton?: ButtonConfig -) => Promise<{ - [Idx in keyof Components]: Components[Idx] extends AnyIOComponent - ? z.infer | undefined - : Components[Idx] -}> +) => Promise< + { + [Idx in keyof Components]: Components[Idx] extends AnyIOComponent + ? z.infer | undefined + : Components[Idx] + } +> export type IORenderSender = (ioToRender: T_IO_RENDER_INPUT) => Promise @@ -363,15 +365,17 @@ export type TableColumnResult = } | TableCellValue +export type ColumnKey = string & keyof Row + export type TableColumn = { label: string } & ( | { - accessorKey: string + accessorKey: string & keyof Row renderCell?: (row: Row) => TableColumnResult } | { - accessorKey?: string + accessorKey?: string & keyof Row renderCell: (row: Row) => TableColumnResult } ) diff --git a/src/utils/table.ts b/src/utils/table.ts index 848239d..d6e75a6 100644 --- a/src/utils/table.ts +++ b/src/utils/table.ts @@ -1,5 +1,5 @@ import { internalTableColumn, tableRow, internalTableRow } from '../ioSchema' -import { MenuItem } from '../types' +import { ColumnKey, MenuItem } from '../types' import { z } from 'zod' import { TableColumn, TableColumnResult } from '../types' import { bufferToDataUrl } from './image' @@ -15,7 +15,7 @@ export const TABLE_DATA_BUFFER_SIZE = 500 */ export function columnsBuilder>( props: { - columns?: (TableColumn | string)[] + columns?: (TableColumn | ColumnKey)[] data?: Row[] }, logMissingColumn: (column: string) => void @@ -29,12 +29,13 @@ export function columnsBuilder>( return labels.map(label => ({ label, - accessorKey: label, + accessorKey: label as ColumnKey, })) } return props.columns.map(column => { const accessorKey = typeof column === 'string' ? column : column.accessorKey + if (accessorKey && !dataColumns.has(accessorKey)) { logMissingColumn(accessorKey) } From 697743469073a69d551eabf49649c465baa3e4aa Mon Sep 17 00:00:00 2001 From: Jacob Mischka Date: Wed, 21 Dec 2022 14:08:26 -0600 Subject: [PATCH 4/9] Show a bit more context/cause for errors, mention to check logs --- src/classes/IOClient.ts | 16 +++++++++------- src/classes/IntervalClient.ts | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/classes/IOClient.ts b/src/classes/IOClient.ts index 752a3e3..05773e9 100644 --- a/src/classes/IOClient.ts +++ b/src/classes/IOClient.ts @@ -239,13 +239,15 @@ export class IOClient { } } catch (err) { if (err instanceof Error) { - this.logger.error(err.message) - if (err.cause) { - if (err.cause instanceof Error) { - this.logger.error(err.cause.message) - } else { - this.logger.error(err.cause) - } + const errorCause = err.cause + ? err.cause instanceof Error + ? err.cause.message + : err.cause + : undefined + if (errorCause) { + this.logger.error(`${err.message}:`, errorCause) + } else { + this.logger.error(err.message) } } else { this.logger.error(err) diff --git a/src/classes/IntervalClient.ts b/src/classes/IntervalClient.ts index 73a8ce5..0ddb147 100644 --- a/src/classes/IntervalClient.ts +++ b/src/classes/IntervalClient.ts @@ -958,12 +958,22 @@ export default class IntervalClient { intervalClient.#logger.error(err) + let data: IOFunctionReturnType = null + if (err instanceof Error) { + data = { + error: err.name, + message: err.message, + cause: + err.cause && err.cause instanceof Error + ? `${err.cause.name}: ${err.cause.message}` + : undefined, + } + } + const result: ActionResultSchema = { schemaVersion: TRANSACTION_RESULT_SCHEMA_VERSION, status: 'FAILURE', - data: err.message - ? { error: err.name, message: err.message } - : null, + data, } return result From 11eb98246dcb8a6d80f45a2d9d879f71c86c39fa Mon Sep 17 00:00:00 2001 From: Jacob Mischka Date: Wed, 21 Dec 2022 14:18:15 -0600 Subject: [PATCH 5/9] Don't show top-level errors and cause, just cause --- src/classes/IntervalClient.ts | 12 ++++++++---- src/classes/Layout.ts | 1 + src/types.ts | 13 ++++++++----- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/classes/IntervalClient.ts b/src/classes/IntervalClient.ts index 0ddb147..a35d5ed 100644 --- a/src/classes/IntervalClient.ts +++ b/src/classes/IntervalClient.ts @@ -959,14 +959,14 @@ export default class IntervalClient { intervalClient.#logger.error(err) let data: IOFunctionReturnType = null + if (err instanceof IOError && err.cause) { + err = err.cause + } + if (err instanceof Error) { data = { error: err.name, message: err.message, - cause: - err.cause && err.cause instanceof Error - ? `${err.cause.name}: ${err.cause.message}` - : undefined, } } @@ -1257,6 +1257,10 @@ export default class IntervalClient { layoutKey, error: error.name, message: error.message, + cause: + error.cause && error.cause instanceof Error + ? `${error.cause.name}: ${error.cause.message}` + : undefined, // stack: error.stack, } } else { diff --git a/src/classes/Layout.ts b/src/classes/Layout.ts index 46e807a..3c81218 100644 --- a/src/classes/Layout.ts +++ b/src/classes/Layout.ts @@ -66,6 +66,7 @@ export const LAYOUT_ERROR_SCHEMA = z.object({ layoutKey: z.string().optional(), error: z.string(), message: z.string(), + cause: z.string().optional(), stack: z.string().optional(), }) diff --git a/src/types.ts b/src/types.ts index 1a02f27..17c36c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -195,11 +195,13 @@ export type ComponentsRenderer< components: Components, validator?: IOClientRenderValidator, continueButton?: ButtonConfig -) => Promise<{ - [Idx in keyof Components]: Components[Idx] extends AnyIOComponent - ? z.infer | undefined - : Components[Idx] -}> +) => Promise< + { + [Idx in keyof Components]: Components[Idx] extends AnyIOComponent + ? z.infer | undefined + : Components[Idx] + } +> export type IORenderSender = (ioToRender: T_IO_RENDER_INPUT) => Promise @@ -379,5 +381,6 @@ export type TableColumn = { export type PageError = { error: string message: string + cause?: string layoutKey?: keyof BasicLayoutConfig } From 71443d91dc4b74ed3099a82d0536f5a2554aa62c Mon Sep 17 00:00:00 2001 From: Jacob Mischka Date: Thu, 22 Dec 2022 12:18:08 -0600 Subject: [PATCH 6/9] Use/document more specific log methods (info, warn, etc), add "quiet" Adds the new "quiet" logLevel option, which silences info and warn messages (leaving only error and prod). The default logLevel is now "info", which is an alias to the deprecated "prod". Changes instances of Logger calls to be more mindful about their importance. Generally means that many were downgraded (prod -> info, error -> warn) if they're not fatal or a result of errors in user code. cc: https://github.com/interval/interval-node/issues/21 --- src/classes/IntervalClient.ts | 14 +++--- src/classes/Logger.ts | 66 ++++++++++++++++++++------ src/classes/TransactionLoadingState.ts | 2 +- src/components/displayTable.ts | 4 +- src/components/selectTable.ts | 2 +- src/components/upload.ts | 1 + src/examples/basic/index.ts | 2 +- src/index.ts | 4 +- src/utils/fileActionLoader.ts | 2 +- 9 files changed, 67 insertions(+), 30 deletions(-) diff --git a/src/classes/IntervalClient.ts b/src/classes/IntervalClient.ts index 0c04332..15de53c 100644 --- a/src/classes/IntervalClient.ts +++ b/src/classes/IntervalClient.ts @@ -473,7 +473,7 @@ export default class IntervalClient { }) .catch(async err => { if (err instanceof IOError) { - this.#logger.error( + this.#logger.warn( 'Failed resending pending IO call: ', err.kind ) @@ -534,7 +534,7 @@ export default class IntervalClient { }) .catch(async err => { if (err instanceof IOError) { - this.#logger.error( + this.#logger.warn( 'Failed resending transaction loading state: ', err.kind ) @@ -655,14 +655,14 @@ export default class IntervalClient { } catch (err) { this.#logger.warn('Pong not received in time') if (!(err instanceof TimeoutError)) { - this.#logger.error(err) + this.#logger.warn(err) } if ( lastSuccessfulPing.getTime() < new Date().getTime() - this.#closeUnresponsiveConnectionTimeoutMs ) { - this.#logger.error( + this.#logger.warn( 'No pong received in last three minutes, closing connection to Interval and retrying...' ) if (this.#pingIntervalHandle) { @@ -769,7 +769,7 @@ export default class IntervalClient { inputs.type as DescriptionType ) } else { - this.#logger.warn( + this.#logger.debug( 'INITIALIZE_PEER_CONNECTION:', 'DCC not found for inputs', inputs @@ -796,7 +796,7 @@ export default class IntervalClient { } } } catch (err) { - this.#logger.error('Failed initializing peer connection', err) + this.#logger.warn('Failed initializing peer connection', err) } }, START_TRANSACTION: async inputs => { @@ -1199,7 +1199,7 @@ export default class IntervalClient { pageSendTimeout = null sendPagePromise = sendPage() .catch(err => { - this.#logger.error(`Failed sending page with key ${pageKey}`, err) + this.#logger.debug(`Failed sending page with key ${pageKey}`, err) }) .finally(() => { sendPagePromise = null diff --git a/src/classes/Logger.ts b/src/classes/Logger.ts index b2daf88..16bf245 100644 --- a/src/classes/Logger.ts +++ b/src/classes/Logger.ts @@ -5,12 +5,16 @@ import { } from '../utils/packageManager' import * as pkg from '../../package.json' -export type LogLevel = 'prod' | 'debug' +export type LogLevel = + | 'quiet' + | 'info' + | 'prod' /* @deprecated, alias for 'info' */ + | 'debug' export const CHANGELOG_URL = 'https://interval.com/changelog' export default class Logger { - logLevel: LogLevel = 'prod' + logLevel: LogLevel = 'info' constructor(logLevel?: LogLevel) { if (logLevel) { @@ -18,18 +22,41 @@ export default class Logger { } } + /* Important messages, always emitted */ prod(...args: any[]) { console.log('[Interval] ', ...args) } - warn(...args: any[]) { - console.warn('[Interval] ', ...args) + /* Same as prod, but without the [Interval] prefix */ + prodNoPrefix(...args: any[]) { + console.log(...args) } + /* Fatal errors or errors in user code, always emitted */ error(...args: any[]) { console.error('[Interval] ', ...args) } + /* Informational messages, not emitted in "quiet" logLevel */ + info(...args: any[]) { + if (this.logLevel !== 'quiet') { + console.info('[Interval] ', ...args) + } + } + + /* Same as info, but without the [Interval] prefix */ + infoNoPrefix(...args: any[]) { + console.log(...args) + } + + /* Non-fatal warnings, not emitted in "quiet" logLevel */ + warn(...args: any[]) { + if (this.logLevel !== 'quiet') { + console.warn('[Interval] ', ...args) + } + } + + /* Debugging/tracing information, only emitted in "debug" logLevel */ debug(...args: any[]) { if (this.logLevel === 'debug') { console.debug('[Interval] ', ...args) @@ -37,7 +64,7 @@ export default class Logger { } handleSdkAlert(sdkAlert: SdkAlert) { - console.log('') + console.info('') const WARN_EMOJI = '\u26A0\uFE0F' const ERROR_EMOJI = '‼️' @@ -46,30 +73,39 @@ export default class Logger { switch (severity) { case 'INFO': - this.prod('🆕\tA new Interval SDK version is available.') + this.info('🆕\tA new Interval SDK version is available.') + if (message) { + this.info(message) + } break case 'WARNING': - this.prod( + this.warn( `${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.` ) + if (message) { + this.warn(message) + } break case 'ERROR': - this.prod( + this.error( `${ERROR_EMOJI}\tThis version of the Interval SDK is no longer supported. Your app will not work until you update.` ) + if (message) { + this.error(message) + } break + default: + if (message) { + this.prod(message) + } } - if (message) { - this.prod(message) - } - - this.prod("\t- See what's new at:", CHANGELOG_URL) - this.prod( + this.info("\t- See what's new at:", CHANGELOG_URL) + this.info( '\t- Update now by running:', getInstallCommand(`${pkg.name}@latest`, detectPackageManager()) ) - console.log('') + console.info('') } } diff --git a/src/classes/TransactionLoadingState.ts b/src/classes/TransactionLoadingState.ts index d6068e5..6d068e9 100644 --- a/src/classes/TransactionLoadingState.ts +++ b/src/classes/TransactionLoadingState.ts @@ -75,7 +75,7 @@ export default class TransactionLoadingState { async completeOne() { if (!this.#state || !this.#state.itemsInQueue) { this.#logger.warn( - 'Please call `loading.start` with `itemsInQueue` before `loading.completeOne`, failing to do so does nothing.' + 'Please call `loading.start` with `itemsInQueue` before `loading.completeOne`, nothing to complete.' ) return } diff --git a/src/components/displayTable.ts b/src/components/displayTable.ts index bf218a4..a5e3364 100644 --- a/src/components/displayTable.ts +++ b/src/components/displayTable.ts @@ -33,7 +33,7 @@ export default function displayTable(logger: Logger) { props: PublicProps ) { const initialColumns = columnsBuilder(props, column => - logger.error(missingColumnMessage('io.display.table')(column)) + logger.warn(missingColumnMessage('io.display.table')(column)) ) // Rendering all rows on initialization is necessary for filtering and sorting @@ -74,7 +74,7 @@ export default function displayTable(logger: Logger) { data, }, column => - logger.error(missingColumnMessage('io.display.table')(column)) + logger.warn(missingColumnMessage('io.display.table')(column)) ) serializedData = data.map((row, index) => tableRowSerializer({ diff --git a/src/components/selectTable.ts b/src/components/selectTable.ts index 410e2eb..5eb78a3 100644 --- a/src/components/selectTable.ts +++ b/src/components/selectTable.ts @@ -24,7 +24,7 @@ export default function selectTable(logger: Logger) { type DataList = typeof props['data'] const columns = columnsBuilder(props, column => - logger.error(missingColumnMessage('io.select.table')(column)) + logger.warn(missingColumnMessage('io.select.table')(column)) ) // Rendering all rows on initialization is necessary for filtering and sorting diff --git a/src/components/upload.ts b/src/components/upload.ts index b7b5017..0e3e337 100644 --- a/src/components/upload.ts +++ b/src/components/upload.ts @@ -69,6 +69,7 @@ export function file(logger: Logger) { const urls = await generatePresignedUrls(newState) return urls } catch (error) { + // TODO: We should not swallow this error after merging #1012 logger.error( 'An error was unexpectedly thrown from the `generatePresignedUrls` function:' ) diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index 2cad226..9a276c6 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -62,7 +62,7 @@ const actionLinks: IntervalActionHandler = async () => { const prod = new Interval({ apiKey: 'live_N47qd1BrOMApNPmVd0BiDZQRLkocfdJKzvt8W6JT5ICemrAN', endpoint: 'ws://localhost:3000/websocket', - logLevel: 'debug', + logLevel: 'quiet', routes: { backgroundable: { backgroundable: true, diff --git a/src/index.ts b/src/index.ts index 7164b0a..ed3b22c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import fetch from 'cross-fetch' import Routes from './classes/Routes' import IOError from './classes/IOError' -import Logger from './classes/Logger' +import Logger, { LogLevel } from './classes/Logger' import Page from './classes/Page' import { NOTIFY, @@ -54,7 +54,7 @@ export interface InternalConfig { // TODO: Mark as deprecated soon, remove soon afterward groups?: Record endpoint?: string - logLevel?: 'prod' | 'debug' + logLevel?: LogLevel retryIntervalMs?: number retryChunkIntervalMs?: number pingIntervalMs?: number diff --git a/src/utils/fileActionLoader.ts b/src/utils/fileActionLoader.ts index 3c05fee..4151ce5 100644 --- a/src/utils/fileActionLoader.ts +++ b/src/utils/fileActionLoader.ts @@ -59,7 +59,7 @@ async function loadFolder(currentDirectory: string, logger: Logger) { } } } catch (err) { - logger.error( + logger.warn( `Failed loading file at ${fullPath} as module, skipping.`, err ) From 69404bd5cb30826d0a9b2bb91a496f18be158952 Mon Sep 17 00:00:00 2001 From: Jacob Mischka Date: Thu, 22 Dec 2022 12:29:48 -0600 Subject: [PATCH 7/9] Add options argument to IOError constructor signature, mirror Error --- src/classes/IOComponent.ts | 4 ++-- src/classes/IOError.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/classes/IOComponent.ts b/src/classes/IOComponent.ts index a2931c9..753057c 100644 --- a/src/classes/IOComponent.ts +++ b/src/classes/IOComponent.ts @@ -158,9 +158,9 @@ export default class IOComponent { } catch (err) { const ioError = new IOError( 'BAD_RESPONSE', - 'Received invalid return value' + 'Received invalid return value', + { cause: err } ) - ioError.cause = err throw ioError } } diff --git a/src/classes/IOError.ts b/src/classes/IOError.ts index 0ddafc5..efd671d 100644 --- a/src/classes/IOError.ts +++ b/src/classes/IOError.ts @@ -7,8 +7,8 @@ export type IOErrorKind = export default class IOError extends Error { kind: IOErrorKind - constructor(kind: IOErrorKind, message?: string) { - super(message) + constructor(kind: IOErrorKind, message?: string, options?: { cause?: any }) { + super(message, options) this.kind = kind this.name = 'IOError' } From 8ec375b99b4a615cd7014287a231737c304e5113 Mon Sep 17 00:00:00 2001 From: Jacob Mischka Date: Thu, 22 Dec 2022 12:37:37 -0600 Subject: [PATCH 8/9] Remove error stack code for now, add cause to action error messages --- src/classes/IntervalClient.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/classes/IntervalClient.ts b/src/classes/IntervalClient.ts index a35d5ed..708cdb6 100644 --- a/src/classes/IntervalClient.ts +++ b/src/classes/IntervalClient.ts @@ -967,6 +967,12 @@ export default class IntervalClient { data = { error: err.name, message: err.message, + cause: + err.cause && err.cause instanceof Error + ? `${err.cause.name}: ${err.cause.message}` + : undefined, + // TODO: Maybe show stack traces in the future? + // stack: err.stack, } } @@ -1261,6 +1267,7 @@ export default class IntervalClient { error.cause && error.cause instanceof Error ? `${error.cause.name}: ${error.cause.message}` : undefined, + // TODO: Maybe show stack traces in the future? // stack: error.stack, } } else { From 0056d028fa6473eadf9dea80ba9fdfa28c2d40ce Mon Sep 17 00:00:00 2001 From: Jacob Mischka Date: Thu, 22 Dec 2022 12:39:30 -0600 Subject: [PATCH 9/9] Change logLevel in internal demo code back to "debug" --- src/examples/basic/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index 9a276c6..2cad226 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -62,7 +62,7 @@ const actionLinks: IntervalActionHandler = async () => { const prod = new Interval({ apiKey: 'live_N47qd1BrOMApNPmVd0BiDZQRLkocfdJKzvt8W6JT5ICemrAN', endpoint: 'ws://localhost:3000/websocket', - logLevel: 'quiet', + logLevel: 'debug', routes: { backgroundable: { backgroundable: true,