@chatally/core
Advanced tools
| > @chatally/core@0.0.10 tsc | ||
| > tsc | ||
| /** | ||
| * Outgoing chat message (part of ChatResponse). | ||
| */ | ||
| export type ChatMessage = | ||
| | Text | ||
| | Buttons | ||
| | Menu | ||
| | Reaction | ||
| | Location | ||
| | Image | ||
| | Audio | ||
| | Video | ||
| | Document | ||
| | Custom | ||
| export type Action = { | ||
| readonly type: 'action' | ||
| } & _Action | ||
| interface _Action { | ||
| id: string | ||
| title: string | ||
| description?: string | ||
| } | ||
| export interface Buttons { | ||
| readonly type: 'buttons' | ||
| content: string | ||
| actions: _Action[] | ||
| } | ||
| export interface Custom { | ||
| readonly type: 'custom' | ||
| readonly schema: string | ||
| readonly custom: unknown | ||
| } | ||
| export interface Location { | ||
| readonly type: 'location' | ||
| /** Longitude of the location. */ | ||
| longitude: number | ||
| /** Latitude of the location. */ | ||
| latitude: number | ||
| /** Name of the location. */ | ||
| name?: string | ||
| /** Address of the location. */ | ||
| address?: string | ||
| } | ||
| export interface Menu { | ||
| readonly type: 'menu' | ||
| title: string | ||
| content: string | ||
| sections: Array<{ | ||
| title?: string | ||
| actions: _Action[] | ||
| }> | ||
| } | ||
| export interface Reaction { | ||
| readonly type: 'reaction' | ||
| /** The ID of the message the customer reacted to. */ | ||
| replyTo: string | ||
| /** The emoji the customer reacted with. */ | ||
| emoji: string | ||
| } | ||
| export interface Text { | ||
| readonly type: 'text' | ||
| /** The content of the message */ | ||
| readonly content: string | ||
| } | ||
| export interface Media { | ||
| /** Location of media data */ | ||
| url: string | ||
| /** MIME type */ | ||
| mimeType: string | ||
| /** [Optional] Caption */ | ||
| caption?: string | ||
| } | ||
| export interface Image extends Media { | ||
| readonly type: 'image' | ||
| /** [Optional] Image description */ | ||
| description?: string | ||
| } | ||
| export interface Audio extends Media { | ||
| readonly type: 'audio' | ||
| /** [Optional] transcript of the audio */ | ||
| transcript?: string | ||
| } | ||
| export interface Video extends Media { | ||
| readonly type: 'video' | ||
| /** [Optional] transcript of the audio */ | ||
| transcript?: string | ||
| } | ||
| export interface Document extends Media { | ||
| readonly type: 'document' | ||
| /** [Optional] description of the document */ | ||
| description?: string | ||
| /** [Optional] Name for the file on the device. */ | ||
| filename?: string | ||
| } |
| import type { | ||
| Action, | ||
| Audio, | ||
| Custom, | ||
| Document, | ||
| Image, | ||
| Location, | ||
| Reaction, | ||
| Text, | ||
| Video, | ||
| } from './chat-message.js' | ||
| /** | ||
| * Chat request with incoming message. | ||
| */ | ||
| export type ChatRequest = { | ||
| /** Arrival time of message */ | ||
| readonly timestamp: number | ||
| /** Id of message */ | ||
| readonly id: string | ||
| /** Id of sender */ | ||
| readonly from: string | ||
| /** Source of the request, i.e. name of the server */ | ||
| readonly source: string | ||
| /** [Optional] Id of message that this message is a reply to. */ | ||
| readonly replyTo?: string | ||
| } & ( | ||
| | Text | ||
| | Action | ||
| | Reaction | ||
| | Location | ||
| | Image | ||
| | Audio | ||
| | Video | ||
| | Document | ||
| | Custom | ||
| ) |
| import type { ChatMessage } from './chat-message.js' | ||
| /** | ||
| * Chat response. | ||
| */ | ||
| // eslint-disable-next-line ts/no-unsafe-declaration-merging | ||
| export interface ChatResponse extends NodeJS.EventEmitter<ChatResponseEvents> { | ||
| /** Messages to send as response. */ | ||
| readonly messages: ChatMessage[] | ||
| /** True if no middleware called end. */ | ||
| readonly isWritable: Readonly<boolean> | ||
| /** Write a message. */ | ||
| write: (msg: string | ChatMessage) => void | ||
| /** End the response, optionally with a message. */ | ||
| end: (msg?: string | ChatMessage) => void | ||
| } | ||
| export interface ChatResponseEvents { | ||
| finished: [ChatResponse] | ||
| write: [ChatMessage] | ||
| } | ||
| /** | ||
| * Chat response. | ||
| */ | ||
| // eslint-disable-next-line ts/no-unsafe-declaration-merging | ||
| export declare class ChatResponse implements NodeJS.EventEmitter<ChatResponseEvents>, ChatResponse { | ||
| /** | ||
| * Create a new chat response. | ||
| * | ||
| * @param onFinished | ||
| * [Optional] Handler to be called, when response `end()` is called. | ||
| */ | ||
| constructor(onFinished?: (r: ChatResponse) => void) | ||
| readonly messages: ChatMessage[] | ||
| readonly isWritable: Readonly<boolean> | ||
| write: (msg: string | ChatMessage) => void | ||
| end: (msg?: string | ChatMessage | undefined) => void | ||
| } |
| import { EventEmitter } from 'node:events' | ||
| /** | ||
| * @typedef {import('./chat-message.d.ts').ChatMessage} ChatMessage | ||
| * @typedef {import('./chat-response.d.ts').ChatResponse} IChatResponse | ||
| */ | ||
| /** | ||
| * @class | ||
| * @extends {EventEmitter<import('./chat-response.d.ts').ChatResponseEvents>} | ||
| * @implements {IChatResponse} | ||
| */ | ||
| export class ChatResponse extends EventEmitter { | ||
| /** @type {ChatMessage[]} */ | ||
| #messages = [] | ||
| #finished = false | ||
| get messages() { | ||
| return this.#messages | ||
| } | ||
| get isWritable() { | ||
| return !this.#finished | ||
| } | ||
| /** @param {string | ChatMessage} [msg] */ | ||
| end(msg) { | ||
| this.write(msg) | ||
| this.#finished = true | ||
| this.emit('finished', this) | ||
| } | ||
| /** @param {string | ChatMessage} [msg] */ | ||
| write(msg) { | ||
| if (!msg) | ||
| return | ||
| if (this.#finished) { | ||
| throw new Error('Cannot write anymore, response is finished.') | ||
| } | ||
| if (typeof msg === 'string') { | ||
| msg = { type: 'text', content: msg } | ||
| } | ||
| if (Array.isArray(msg)) { | ||
| this.#messages.push(...msg) | ||
| } else { | ||
| this.#messages.push(msg) | ||
| } | ||
| this.emit('write', msg) | ||
| } | ||
| } |
| import type { Logger } from '@chatally/logger' | ||
| export interface IMediaServer { | ||
| /** | ||
| * Load a media asset and provide it's raw data as Buffer | ||
| * | ||
| * @param media Media meta-data that contains a URL. | ||
| * @returns The raw data | ||
| */ | ||
| load: (media: { url: string }) => Promise<Buffer> | ||
| } | ||
| export declare class MediaServer implements IMediaServer { | ||
| constructor(options?: MediaOptions) | ||
| load: (media: { url: string }) => Promise<Buffer> | ||
| addServer(d: Download): void | ||
| } | ||
| export interface Download { | ||
| canDownload: (url: string) => boolean | ||
| download: (url: string) => Promise<Buffer> | ||
| } | ||
| export interface MediaOptions { | ||
| log?: Logger | ||
| cache?: false | ||
| dir?: string | ||
| ttl?: number | ||
| ttl2?: number | ||
| } |
+152
| import fss from 'node:fs' | ||
| import fs from 'node:fs/promises' | ||
| import { NoLogger } from '@chatally/logger' | ||
| import { nanoid } from 'nanoid' | ||
| import NodeCache from 'node-cache' | ||
| const __cwd = new URL(`file://${process.cwd()}/`) | ||
| export class MediaServer { | ||
| /** @type {import('@chatally/logger').Logger} */ | ||
| #log | ||
| /** @type {string} */ | ||
| #dir | ||
| /** @type {NodeCache | undefined} */ | ||
| #cache | ||
| /** @type {number} */ | ||
| #ttl = 0 | ||
| /** @type {number} */ | ||
| #ttl2 = 0 | ||
| /** @type {import('./media.d.ts').Download[]} */ | ||
| #servers = [] | ||
| /** | ||
| * @param {import('./media.d.ts').MediaOptions} [config] | ||
| */ | ||
| constructor(config = {}) { | ||
| this.#log = config.log || new NoLogger() | ||
| this.#dir = config.dir || 'media-cache' | ||
| fss.mkdirSync(this.#dir, { recursive: true }) | ||
| if (config.cache === false || (config.ttl === 0 && config.ttl2 === 0)) { | ||
| this.#cache = undefined | ||
| } else { | ||
| this.#ttl = config.ttl ?? 0 | ||
| this.#ttl2 = config.ttl2 ?? 0 | ||
| this.#cache = new NodeCache({ | ||
| stdTTL: this.#ttl, | ||
| checkperiod: this.#ttl * 1.2, | ||
| useClones: false, | ||
| deleteOnExpire: false, | ||
| }) | ||
| this.#cache?.on('expired', async (key, value) => { | ||
| if (value instanceof Buffer) { | ||
| if (this.#ttl2 > 0) { | ||
| const oldFile = this.#cache?.get(`---${key}`) | ||
| if (oldFile) { | ||
| this.#cache?.set(key, oldFile, this.#ttl2) | ||
| this.#cache?.del(`---${key}`) | ||
| } else { | ||
| // Move buffer to second-level cache (file-system) | ||
| const file = new URL(`${this.#dir}/${nanoid()}`, __cwd) | ||
| try { | ||
| await fs.writeFile(file, value) | ||
| this.#cache?.set(key, file.toString(), this.#ttl2) | ||
| } catch (e) { | ||
| this.#log.error(e) | ||
| } | ||
| } | ||
| } else { | ||
| // Second-level cache deactivated | ||
| this.#cache?.del(key) | ||
| } | ||
| } else { | ||
| // Second-level cache expired | ||
| this.#cache?.del(key) | ||
| try { | ||
| await fs.unlink(value) | ||
| } catch (e) { | ||
| this.#log.error(e) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| // Self-register for 'file:' protocol | ||
| this.#servers.push(this) | ||
| } | ||
| /** | ||
| * | ||
| * @param {import('./media.d.ts').Download} server | ||
| */ | ||
| addServer(server) { | ||
| this.#servers.push(server) | ||
| } | ||
| /** | ||
| * @param {string} url | ||
| * @returns {boolean} True if this is a file URL | ||
| */ | ||
| canDownload(url) { | ||
| return url.startsWith('file:') | ||
| } | ||
| /** | ||
| * @param {string} url | ||
| * @returns {Promise<Buffer>} The raw data from the file | ||
| */ | ||
| async download(url) { | ||
| if (!this.canDownload(url)) { | ||
| throw new Error(`Cannot read ${url.toString()}, only 'file:' protocol is supported by default.`) | ||
| } | ||
| return await fs.readFile(url) | ||
| } | ||
| /** | ||
| * @param {import("./chat-message.d.ts").Media} media | ||
| * @returns {Promise<Buffer>} The raw data | ||
| */ | ||
| async load(media) { | ||
| const key = media.url | ||
| const cached = this.#cache?.get(key) | ||
| if (cached instanceof Buffer) { | ||
| // Refresh ttl | ||
| this.#cache?.ttl(key, this.#ttl) | ||
| // Refresh potential ttl2 | ||
| this.#cache?.ttl(`---${key}`, this.#ttl2) | ||
| return cached | ||
| } else if (typeof cached === 'string') { | ||
| const value = await fs.readFile(cached) | ||
| if (this.#ttl > 0) { | ||
| // Move to first-level cache | ||
| this.#cache?.set(key, value, this.#ttl) | ||
| // Keep second cache entry for file | ||
| this.#cache?.set(`---${key}`, cached, this.#ttl2) | ||
| } else { | ||
| // Refresh ttl2 | ||
| this.#cache?.ttl(key, this.#ttl2) | ||
| } | ||
| return value | ||
| } | ||
| const server = this.#servers.find(s => s.canDownload(key)) | ||
| if (!server) { | ||
| throw new Error(`No server registered, which could download ${key}`) | ||
| } | ||
| const value = await server.download(key) | ||
| if (this.#ttl) { | ||
| this.#cache?.set(key, value, this.#ttl) | ||
| } else if (this.#ttl2) { | ||
| const file = new URL(`${this.#dir}/${nanoid()}`, __cwd) | ||
| await fs.writeFile(file, value) | ||
| this.#cache?.set(key, file.toString(), this.#ttl2) | ||
| } | ||
| return value | ||
| } | ||
| } |
| > @chatally/core@0.0.9 lint | ||
| > @chatally/core@0.0.10 lint | ||
| > eslint . | ||
| { | ||
| project: '/home/runner/work/chatally/chatally/packages/core/tsconfig.json' | ||
| } |
+26
-27
| > @chatally/core@0.0.9 test | ||
| > @chatally/core@0.0.10 test | ||
| > vitest run | ||
@@ -8,31 +8,30 @@ | ||
| [90mstdout[2m | lib/application.test.js[2m > [22m[2mApplication[2m > [22m[2mdispatches to middleware[22m[39m | ||
| [09:53:53.145] INFO (@chatally/core): Registered middleware 'echo' | ||
| [15:15:12.806] INFO (@chatally/core): Registered middleware 'echo' | ||
| [15:15:12.817] INFO (@chatally/core): Registered middleware 'a' | ||
| [15:15:12.818] INFO (@chatally/core): Registered middleware 'b' | ||
| [15:15:12.818] INFO (@chatally/core): Registered middleware 'c' | ||
| [15:15:12.818] INFO (@chatally/core): Registered middleware 'd' | ||
| [15:15:12.818] INFO (@chatally/core): Registered middleware 'e' | ||
| [15:15:12.820] INFO (@chatally/core): Registered middleware 'echo' | ||
| [15:15:12.820] WARN (@chatally/core): ⚠️ For better traceability, prefer using named functions | ||
| instead of arrow functions or provide an optional | ||
| 'name' parameter when registering it with 'use'. | ||
| [15:15:12.820] INFO (@chatally/core): Registered middleware '<unnamed>' | ||
| [15:15:12.821] INFO (@chatally/core): Registered middleware 'echo' | ||
| [15:15:12.821] INFO (@chatally/core): Registered middleware 'throws' | ||
| [15:15:12.822] INFO (@chatally/core): Registered middleware 'echo' | ||
| [15:15:12.822] WARN (@chatally/core): ⚠️ For better traceability, prefer using named functions | ||
| instead of arrow functions or provide an optional | ||
| 'name' parameter when registering it with 'use'. | ||
| [15:15:12.822] INFO (@chatally/core): Registered middleware '<unnamed>' | ||
| [15:15:12.822] WARN (@chatally/core): ⚠️ For better traceability, prefer using named functions | ||
| instead of arrow functions or provide an optional | ||
| 'name' parameter when registering it with 'use'. | ||
| [15:15:12.822] INFO (@chatally/core): Registered middleware '<unnamed>' | ||
| [32m✓[39m lib/application.test.js [2m ([22m[2m7 tests[22m[2m)[22m[90m 22[2mms[22m[39m | ||
| [90mstdout[2m | lib/application.test.js[2m > [22m[2mApplication[2m > [22m[2mdispatches in order of registration[22m[39m | ||
| [09:53:53.150] INFO (@chatally/core): Registered middleware 'a' | ||
| [09:53:53.150] INFO (@chatally/core): Registered middleware 'b' | ||
| [09:53:53.150] INFO (@chatally/core): Registered middleware 'c' | ||
| [09:53:53.151] INFO (@chatally/core): Registered middleware 'd' | ||
| [09:53:53.151] INFO (@chatally/core): Registered middleware 'e' | ||
| [90mstdout[2m | lib/application.test.js[2m > [22m[2mApplication[2m > [22m[2mcatches sync middleware errors[22m[39m | ||
| [09:53:53.153] INFO (@chatally/core): Registered middleware 'echo' | ||
| [09:53:53.153] INFO (@chatally/core): Registered middleware 'throws' | ||
| [90mstdout[2m | lib/application.test.js[2m > [22m[2mApplication[2m > [22m[2mcatches async middleware errors[22m[39m | ||
| [09:53:53.154] INFO (@chatally/core): Registered middleware 'echo' | ||
| [09:53:53.154] INFO (@chatally/core): Registered middleware 'throws' | ||
| [90mstdout[2m | lib/application.test.js[2m > [22m[2mApplication[2m > [22m[2mexposes errors if demanded[22m[39m | ||
| [09:53:53.155] INFO (@chatally/core): Registered middleware 'echo' | ||
| [09:53:53.155] INFO (@chatally/core): Registered middleware 'throws' | ||
| [09:53:53.155] INFO (@chatally/core): Registered middleware 'throws' | ||
| [32m✓[39m lib/application.test.js [2m ([22m[2m7 tests[22m[2m)[22m[90m 15[2mms[22m[39m | ||
| [2m Test Files [22m [1m[32m1 passed[39m[22m[90m (1)[39m | ||
| [2m Tests [22m [1m[32m7 passed[39m[22m[90m (7)[39m | ||
| [2m Start at [22m 09:53:52 | ||
| [2m Duration [22m 847ms[2m (transform 151ms, setup 0ms, collect 138ms, tests 15ms, environment 0ms, prepare 183ms)[22m | ||
| [2m Start at [22m 15:15:11 | ||
| [2m Duration [22m 1.09s[2m (transform 269ms, setup 0ms, collect 317ms, tests 22ms, environment 0ms, prepare 322ms)[22m | ||
+8
-0
| # @chatally/core | ||
| ## 0.0.10 | ||
| ### Patch Changes | ||
| - 457fb65: Refactored media handling | ||
| - Updated dependencies [457fb65] | ||
| - @chatally/logger@0.0.8 | ||
| ## 0.0.9 | ||
@@ -4,0 +12,0 @@ |
+23
-20
@@ -1,7 +0,8 @@ | ||
| import type { Level, Logger } from "@chatally/logger"; | ||
| import type { EventEmitter } from "node:events"; | ||
| import type { Context, Middleware } from "./middleware.d.ts"; | ||
| import type { Server } from "./server.d.ts"; | ||
| import { IResponse } from "./response.js"; | ||
| import { IRequest } from "./request.js"; | ||
| import type { EventEmitter } from 'node:events' | ||
| import type { Level, Logger, LoggerOptions } from '@chatally/logger' | ||
| import type { ChatRequest } from './chat-request.d.ts' | ||
| import type { ChatResponse } from './chat-response.d.ts' | ||
| import type { MediaOptions } from './media.js' | ||
| import type { Context, Middleware } from './middleware.d.ts' | ||
| import type { Server } from './server.d.ts' | ||
@@ -17,6 +18,6 @@ /** | ||
| /** | ||
| * Create a Chatally application that dispatches incoming chat requests from | ||
| * Create a ChatAlly application that dispatches incoming chat requests from | ||
| * all registered servers to all registered middleware. | ||
| */ | ||
| constructor(options?: ApplicationOptions<D>); | ||
| constructor(options?: ApplicationOptions<D>) | ||
@@ -37,3 +38,3 @@ /** | ||
| */ | ||
| use(m: Middleware<D> | Server, name?: string): this; | ||
| use(m: Middleware<D> | Server, name?: string): this | ||
@@ -51,3 +52,3 @@ /** | ||
| */ | ||
| dispatch(req: IRequest, res: IResponse): Promise<void>; | ||
| dispatch(req: ChatRequest, res: ChatResponse): Promise<void> | ||
@@ -59,3 +60,3 @@ /** | ||
| */ | ||
| listen(): void; | ||
| listen(): void | ||
@@ -69,8 +70,8 @@ /** | ||
| */ | ||
| getLogger(name: string, level?: Level): Logger; | ||
| getLogger(name: string, level?: Level): Logger | ||
| } | ||
| type ApplicationEvents<D> = { | ||
| error: [Error & Record<string, unknown>, Omit<Context<D>, "next">]; | ||
| }; | ||
| interface ApplicationEvents<D> { | ||
| error: [Error & Record<string, unknown>, Omit<Context<D>, 'next'>] | ||
| } | ||
@@ -83,3 +84,3 @@ export interface ApplicationOptions<D> { | ||
| */ | ||
| data?: D; | ||
| data?: D | ||
@@ -89,6 +90,6 @@ /** | ||
| * | ||
| * [`default=new BaseLogger()`] If you want to turn of logging use `NoLogger` | ||
| * or `false`. | ||
| * [`default=new BaseLogger()`] | ||
| * If you want to turn of logging use `NoLogger` or `false`. | ||
| */ | ||
| log?: Logger | false; | ||
| log?: false | LoggerOptions | Logger | ||
@@ -100,3 +101,5 @@ /** | ||
| */ | ||
| dev?: boolean; | ||
| dev?: boolean | ||
| media?: MediaOptions | ||
| } |
+92
-74
@@ -1,11 +0,10 @@ | ||
| import { BaseLogger, NoLogger } from "@chatally/logger"; | ||
| import { EventEmitter } from "node:events"; | ||
| import { isServer } from "./server.js"; | ||
| import { BaseLogger, NoLogger, isLogger } from '@chatally/logger' | ||
| // eslint-disable-next-line import/order | ||
| import { EventEmitter } from 'node:events' | ||
| import { MediaServer } from './media.js' | ||
| import { isServer } from './server.js' | ||
| /** | ||
| * @typedef {import("./server.d.ts").Server} Server | ||
| * @typedef {import("./request.d.ts").IRequest} IRequest | ||
| * @typedef {import("./response.d.ts").IResponse} IResponse | ||
| * @typedef {import("@chatally/logger").Logger} Logger | ||
| * @typedef {import("@chatally/logger").Level} Level | ||
| * @typedef {import('./server.d.ts').Server} Server | ||
| * @typedef {import('@chatally/logger').Logger} Logger | ||
| */ | ||
@@ -15,3 +14,3 @@ | ||
| * @template {Record<string, unknown>} D | ||
| * @typedef {import("./middleware.d.ts").Middleware<D>} Middleware | ||
| * @typedef {import('./middleware.d.ts').Middleware<D>} Middleware | ||
| */ | ||
@@ -21,3 +20,3 @@ | ||
| * @template {Record<string, unknown>} D | ||
| * @typedef {import("./middleware.d.ts").Context<D>} Context | ||
| * @typedef {import('./middleware.d.ts').Context<D>} Context | ||
| */ | ||
@@ -33,4 +32,5 @@ | ||
| * Main logger for the application | ||
| * @type {Logger} */ | ||
| #log; | ||
| * @type {Logger} | ||
| */ | ||
| #log | ||
@@ -41,3 +41,3 @@ /** | ||
| */ | ||
| #middlewares = []; | ||
| #middlewares = [] | ||
@@ -48,9 +48,15 @@ /** | ||
| */ | ||
| #servers = []; | ||
| #servers = [] | ||
| /** | ||
| * Media server | ||
| * @type {MediaServer} | ||
| */ | ||
| #media | ||
| /** | ||
| * Child loggers, one for each middleware | ||
| * @type {Logger[]} | ||
| */ | ||
| #middlewareLogs = []; | ||
| #middlewareLogs = [] | ||
@@ -61,21 +67,30 @@ /** | ||
| */ | ||
| #data; | ||
| #data | ||
| /** | ||
| * @param {import("./application.js").ApplicationOptions<D>} [options] | ||
| * @param {import('./application.js').ApplicationOptions<D>} [options] | ||
| */ | ||
| constructor(options = {}) { | ||
| super(); | ||
| this.#data = options.data; | ||
| if (options.log === undefined) { | ||
| const level = | ||
| // eslint-disable-next-line turbo/no-undeclared-env-vars | ||
| options.dev || process.env.NODE_ENV === "development" | ||
| ? "debug" | ||
| : "info"; | ||
| this.#log = new BaseLogger({ level, name: "@chatally/core" }); | ||
| super() | ||
| this.#data = options.data | ||
| if (options.log === false) { | ||
| this.#log = new NoLogger() | ||
| } else if (isLogger(options.log)) { | ||
| this.#log = options.log | ||
| } else { | ||
| this.#log = options.log || new NoLogger(); | ||
| const config = typeof options.log === 'object' ? options.log : {} | ||
| const name = config.name || '@chatally/core' | ||
| const level | ||
| = config.level || options.dev || process.env.NODE_ENV === 'development' | ||
| ? 'debug' | ||
| : 'info' | ||
| const data = config.data | ||
| this.#log = new BaseLogger({ level, name, data }) | ||
| } | ||
| this.#log.debug("Application logging level: %s", this.#log.level); | ||
| this.#log.debug('Application logging level: %s', this.#log.level) | ||
| this.#media = new MediaServer({ | ||
| log: this.#log.child({ name: 'Media' }), | ||
| ...options.media, | ||
| }) | ||
| } | ||
@@ -85,6 +100,6 @@ | ||
| * @param {string} name | ||
| * @param {Level} [level] | ||
| * @param {import('@chatally/logger').Level} [level] | ||
| */ | ||
| getLogger(name, level) { | ||
| return this.#log.child({ name, level }); | ||
| return this.#log.child({ name, level }) | ||
| } | ||
@@ -94,28 +109,30 @@ | ||
| * @param {Middleware<D> | Server} module | ||
| * @param {String} [name] | ||
| * @param {string} [name] | ||
| */ | ||
| use(module, name) { | ||
| name ??= module.name; | ||
| name ??= module.name | ||
| if (!name) { | ||
| name = "<unnamed>"; | ||
| name = '<unnamed>' | ||
| this.#log.warn(`⚠️ For better traceability, prefer using named functions | ||
| instead of arrow functions or provide an optional | ||
| 'name' parameter when registering it with 'use'.`); | ||
| 'name' parameter when registering it with 'use'.`) | ||
| } | ||
| if (isServer(module)) { | ||
| module.on("dispatch", this.dispatch.bind(this)); | ||
| module.on('dispatch', this.dispatch.bind(this)) | ||
| if (module.log === undefined) { | ||
| module.log = this.#log.child({ name: module.name }); | ||
| module.log = this.#log.child({ name: module.name }) | ||
| } | ||
| this.#servers.push(module); | ||
| this.#log.info("Registered server '%s'", name); | ||
| } else if (typeof module === "function") { | ||
| this.#middlewareLogs.push(this.#log.child({ name })); | ||
| this.#middlewares.push(module); | ||
| this.#log.info("Registered middleware '%s'", name); | ||
| this.#servers.push(module) | ||
| this.#media.addServer(module) | ||
| this.#log.info('Registered server \'%s\'', name) | ||
| } else if (typeof module === 'function') { | ||
| this.#middlewareLogs.push(this.#log.child({ name })) | ||
| this.#middlewares.push(module) | ||
| this.#log.info('Registered middleware \'%s\'', name) | ||
| } else { | ||
| throw new TypeError(`Ineffective application module '${name}'. | ||
| Middleware must be a function, servers must provide a method 'listen' and a method 'on' to register an event callback.`); | ||
| Middleware must be a function, servers must provide a method 'listen' and a method 'on' to register an event callback.`) | ||
| } | ||
| return this; | ||
| return this | ||
| } | ||
@@ -127,6 +144,6 @@ | ||
| * This asynchronous method is used internally to trigger handling the | ||
| * "dispatch" events from registered servers. It decouples the servers' event | ||
| * 'dispatch' events from registered servers. It decouples the servers' event | ||
| * loop from message handling. The method resolves after the last middleware | ||
| * finished, but a server could send responses earlier, by registering the | ||
| * `on("write")` event on the response. | ||
| * `on('write')` event on the response. | ||
| * | ||
@@ -138,27 +155,28 @@ * The application creates a context from request and response and passes it | ||
| * | ||
| * @param {IRequest} req | ||
| * @param {IResponse} res | ||
| * @param {import('./chat-request.d.ts').ChatRequest} req | ||
| * @param {import('./chat-response.d.ts').ChatResponse} res | ||
| */ | ||
| async dispatch(req, res) { | ||
| const data = Object.assign(Object.create(this.#data || {}), this.#data); | ||
| const log = this.#log; | ||
| const context = { req, res, data, log }; | ||
| const data = Object.assign(Object.create(this.#data || {}), this.#data) | ||
| const media = this.#media | ||
| const log = this.#log | ||
| const context = { req, res, media, data, log } | ||
| try { | ||
| let current = 0; | ||
| let current = 0 | ||
| const next = async () => { | ||
| while (current < this.#middlewares.length) { | ||
| try { | ||
| const log = this.#middlewareLogs[current]; | ||
| await this.#middlewares[current++]({ ...context, log, next }); | ||
| const log = this.#middlewareLogs[current] | ||
| await this.#middlewares[current++]({ ...context, log, next }) | ||
| } catch (err) { | ||
| this.#handleError(err, context); | ||
| this.#handleError(err, context) | ||
| } | ||
| } | ||
| }; | ||
| await next(); | ||
| } | ||
| await next() | ||
| if (res.isWritable) { | ||
| res.end(); | ||
| res.end() | ||
| } | ||
| } catch (err) { | ||
| this.#handleError(err, context); | ||
| this.#handleError(err, context) | ||
| } | ||
@@ -170,3 +188,3 @@ } | ||
| * | ||
| * You can modify this behavior by registering a callback to the "error" | ||
| * You can modify this behavior by registering a callback to the 'error' | ||
| * event. If you rethrow an error, this will end the dispatch loop and be | ||
@@ -180,6 +198,6 @@ * handed back to your error handler. If you rethrow it again, it will crash | ||
| * ```js | ||
| * .on("error", (err, { log }) => { | ||
| * if (err.message !== "STOP_DISPATCHING") { | ||
| * .on('error', (err, { log }) => { | ||
| * if (err.message !== 'STOP_DISPATCHING') { | ||
| * log.error(err); | ||
| * throw new Error("STOP_DISPATCHING"); | ||
| * throw new Error('STOP_DISPATCHING'); | ||
| * } | ||
@@ -190,3 +208,3 @@ * }) | ||
| * @param {unknown} err | ||
| * @param {Omit<Context<D>, "next">} context | ||
| * @param {Omit<Context<D>, 'next'>} context | ||
| */ | ||
@@ -196,12 +214,12 @@ #handleError(err, context) { | ||
| function uncaught(err) { | ||
| context.log.error("Uncaught", err); | ||
| context.log.error('Uncaught', err) | ||
| } | ||
| if (err instanceof Error) { | ||
| if (this.listenerCount("error") === 0) { | ||
| uncaught(err); | ||
| if (this.listenerCount('error') === 0) { | ||
| uncaught(err) | ||
| } else { | ||
| this.emit("error", err, context); | ||
| this.emit('error', err, context) | ||
| } | ||
| } else { | ||
| uncaught(err); | ||
| uncaught(err) | ||
| } | ||
@@ -214,11 +232,11 @@ } | ||
| listen() { | ||
| for (let server of this.#servers) { | ||
| for (const server of this.#servers) { | ||
| // overcome blocking `listen()` calls | ||
| new Promise((res) => { | ||
| server.listen(); | ||
| res(undefined); | ||
| new Promise((_resolve) => { | ||
| server.listen() | ||
| _resolve(undefined) | ||
| // TODO: Use this.#handleError instead | ||
| }).catch((err) => this.#log.error(err)); | ||
| }).catch(err => this.#log.error(err)) | ||
| } | ||
| } | ||
| } |
+120
-86
@@ -1,131 +0,165 @@ | ||
| import { BaseLogger } from "@chatally/logger"; | ||
| import { StringWritable, TestError } from "@internal/test-utils"; | ||
| import { Application } from "./application.js"; | ||
| import { Request } from "./request.js"; | ||
| import { Response } from "./response.js"; | ||
| import { BaseLogger } from '@chatally/logger' | ||
| import { StringWritable, TestError } from '@internal/test-utils' | ||
| import { nanoid } from 'nanoid' | ||
| import { Application } from './application.js' | ||
| import { ChatResponse } from './chat-response.js' | ||
| /** @type {import("./middleware.d.ts").Middleware<unknown>} */ | ||
| /** @type {import('./middleware.d.ts').Middleware<unknown>} */ | ||
| const echo = ({ req, res }) => { | ||
| if (res.isWritable && req.message.type === "text") { | ||
| res.write(`Echo: '${req.message.text}'`); | ||
| if (res.isWritable && req.type === 'text') { | ||
| res.write(`Echo: '${req.content}'`) | ||
| } | ||
| }; | ||
| } | ||
| describe("Application", function () { | ||
| it("dispatches to middleware", async () => { | ||
| const app = new Application().use(echo); | ||
| describe('application', () => { | ||
| it('dispatches to middleware', async () => { | ||
| const app = new Application().use(echo) | ||
| const res = new Response(); | ||
| await app.dispatch(new Request("test: foo"), res); | ||
| expect(res.text).toStrictEqual(["Echo: 'foo'"]); | ||
| }); | ||
| const res = new ChatResponse() | ||
| await app.dispatch(textRequest('foo', 'test'), res) | ||
| const actual = res.messages.map(toText) | ||
| expect(actual).toStrictEqual(['Echo: \'foo\'']) | ||
| }) | ||
| it("dispatches in order of registration", async () => { | ||
| it('dispatches in order of registration', async () => { | ||
| const app = new Application() | ||
| .use(function a({ res }) { | ||
| // should run before all middlewares | ||
| res.write("a"); | ||
| res.write('a') | ||
| }) | ||
| .use(async function b({ res, next }) { | ||
| // should run after all following middlewares | ||
| await next(); | ||
| res.write("b"); | ||
| await next() | ||
| res.write('b') | ||
| }) | ||
| .use(async function c({ res, next }) { | ||
| // should run before and after all following middlewares | ||
| res.write("c-pre"); | ||
| await next(); | ||
| res.write("c-post"); | ||
| res.write('c-pre') | ||
| await next() | ||
| res.write('c-post') | ||
| }) | ||
| .use(async function d({ res }) { | ||
| await new Promise((resolve) => setTimeout(resolve, 1)); | ||
| res.write("d"); | ||
| await new Promise(resolve => setTimeout(resolve, 1)) | ||
| res.write('d') | ||
| }) | ||
| .use(function e({ res }) { | ||
| res.write("e"); | ||
| }); | ||
| res.write('e') | ||
| }) | ||
| const res = new Response(); | ||
| await app.dispatch(new Request("test: foo"), res); | ||
| expect(res.text).toStrictEqual(["a", "c-pre", "d", "e", "c-post", "b"]); | ||
| }); | ||
| const res = new ChatResponse() | ||
| await app.dispatch(textRequest('foo', 'test'), res) | ||
| const actual = res.messages.map(toText) | ||
| expect(actual).toStrictEqual(['a', 'c-pre', 'd', 'e', 'c-post', 'b']) | ||
| }) | ||
| it("catches sync middleware errors", async () => { | ||
| it('catches sync middleware errors', async () => { | ||
| /** @type {Error} */ | ||
| let error = new Error("Init"); | ||
| let error = new Error('Init') | ||
| const app = new Application() | ||
| .use(echo) | ||
| .use(function throws() { | ||
| throw new Error("Boom"); | ||
| .use(() => { | ||
| throw new Error('Boom') | ||
| }) | ||
| .on("error", (err) => { | ||
| error = err; | ||
| }); | ||
| const res = new Response(); | ||
| await app.dispatch(new Request("test: foo"), res); | ||
| expect(res.text).toStrictEqual(["Echo: 'foo'"]); | ||
| expect(error?.message).toBe("Boom"); | ||
| }); | ||
| .on('error', (err) => { | ||
| error = err | ||
| }) | ||
| const res = new ChatResponse() | ||
| await app.dispatch(textRequest('foo', 'test'), res) | ||
| const actual = res.messages.map(toText) | ||
| expect(actual).toStrictEqual(['Echo: \'foo\'']) | ||
| expect(error?.message).toBe('Boom') | ||
| }) | ||
| it("catches async middleware errors", async () => { | ||
| it('catches async middleware errors', async () => { | ||
| async function throwsAsync() { | ||
| throw new Error("Boom Async"); | ||
| throw new Error('Boom Async') | ||
| } | ||
| const app = new Application().use(echo).use(function throws({ res }) { | ||
| throwsAsync().catch((e) => res.write(e.message)); | ||
| res.write("First"); | ||
| }); | ||
| const res = new Response(); | ||
| await app.dispatch(new Request("test: foo"), res); | ||
| expect(res.text).toStrictEqual(["Echo: 'foo'", "First", "Boom Async"]); | ||
| }); | ||
| const app = new Application() | ||
| .use(echo) | ||
| .use(function throws({ res }) { | ||
| throwsAsync().catch(e => res.write(e.message)) | ||
| res.write('First') | ||
| }) | ||
| const res = new ChatResponse() | ||
| await app.dispatch(textRequest('foo', 'test'), res) | ||
| const actual = res.messages.map(toText) | ||
| expect(actual).toStrictEqual(['Echo: \'foo\'', 'First', 'Boom Async']) | ||
| }) | ||
| it("exposes errors if demanded", async () => { | ||
| it('exposes errors if demanded', async () => { | ||
| const app = new Application() | ||
| .use(echo) | ||
| .use(function throws() { | ||
| throw new Error("Bang"); | ||
| .use(() => { | ||
| throw new Error('Bang') | ||
| }) | ||
| .use(function throws() { | ||
| throw new TestError("Boom", { expose: true }); | ||
| .use(() => { | ||
| throw new TestError('Boom', { expose: true }) | ||
| }) | ||
| .on("error", (err, { res }) => { | ||
| .on('error', (err, { res }) => { | ||
| if (err.expose) { | ||
| res.write(err.message); | ||
| res.write(err.message) | ||
| } | ||
| }); | ||
| }) | ||
| const res = new Response(); | ||
| await app.dispatch(new Request("test: foo"), res); | ||
| expect(res.text).toStrictEqual(["Echo: 'foo'", "Boom"]); | ||
| }); | ||
| const res = new ChatResponse() | ||
| await app.dispatch(textRequest('foo', 'test'), res) | ||
| const actual = res.messages.map(toText) | ||
| expect(actual).toStrictEqual(['Echo: \'foo\'', 'Boom']) | ||
| }) | ||
| it("warns about unnamed middleware", async () => { | ||
| const out = new StringWritable(); | ||
| const log = new BaseLogger({ name: "root", level: "warn" }); | ||
| log.out = out; | ||
| log.timestamp = false; | ||
| it('warns about unnamed middleware', async () => { | ||
| const log = new BaseLogger({ name: 'root', level: 'warn' }) | ||
| const logged = new StringWritable() | ||
| log.out = logged | ||
| log.timestamp = false | ||
| new Application({ log }) | ||
| // unnamed middleware | ||
| .use(() => {}); | ||
| expect(out.data.startsWith("WARN (root):")).toBeTruthy(); | ||
| }); | ||
| .use(() => { }) | ||
| expect(logged.data.startsWith('WARN (root):')).toBeTruthy() | ||
| }) | ||
| it("logs middleware output", async () => { | ||
| const out = new StringWritable(); | ||
| const log = new BaseLogger({ level: "warn" }); | ||
| log.out = out; | ||
| log.timestamp = false; | ||
| it('logs middleware output', async () => { | ||
| const log = new BaseLogger({ level: 'warn' }) | ||
| const logged = new StringWritable() | ||
| log.out = logged | ||
| log.timestamp = false | ||
| const app = new Application({ log }) // | ||
| .use(function logs({ log }) { | ||
| log.level = "debug"; | ||
| log.debug("Hello"); | ||
| }); | ||
| log.level = 'debug' | ||
| log.debug('Hello') | ||
| }) | ||
| const res = new Response(); | ||
| await app.dispatch(new Request("test: foo"), res); | ||
| expect(out.data).toBe("DEBUG (logs): Hello\n"); | ||
| }); | ||
| }); | ||
| const res = new ChatResponse() | ||
| await app.dispatch(textRequest('foo', 'test'), res) | ||
| expect(logged.data).toBe('DEBUG (logs): Hello\n') | ||
| }) | ||
| }) | ||
| /** | ||
| * @param {any} content | ||
| * @param {any} from | ||
| * @returns {import('./chat-request.js').ChatRequest} | ||
| * Text request with the given content | ||
| */ | ||
| function textRequest(content, from) { | ||
| return { | ||
| source: 'test', | ||
| type: 'text', | ||
| content, | ||
| timestamp: Date.now(), | ||
| from, | ||
| id: nanoid(), | ||
| } | ||
| } | ||
| /** | ||
| * @param {import('./chat-message.js').ChatMessage} msg | ||
| */ | ||
| function toText(msg) { | ||
| if (msg.type === 'text') { | ||
| return msg.content | ||
| } | ||
| return `<${msg.type}>` | ||
| } |
+7
-11
@@ -1,11 +0,7 @@ | ||
| export { Application } from "./application.js"; | ||
| export { Request } from "./request.js"; | ||
| export { Response } from "./response.js"; | ||
| export type { Middleware } from "./middleware.d.ts"; | ||
| export type * from "./message.d.ts"; | ||
| export type { Server } from "./server.d.ts"; | ||
| export { BaseServer } from "./server.js"; | ||
| export { text } from "./text.js"; | ||
| export { Application } from './application.js' | ||
| export type * from './chat-message.js' | ||
| export type { ChatRequest } from './chat-request.d.ts' | ||
| export type { ChatResponse } from './chat-response.js' | ||
| export type { Middleware } from './middleware.d.ts' | ||
| export type { Server } from './server.d.ts' | ||
| export { BaseServer } from './server.js' |
+3
-5
@@ -1,5 +0,3 @@ | ||
| export { Application } from "./application.js"; | ||
| export { Request } from "./request.js"; | ||
| export { Response } from "./response.js"; | ||
| export { BaseServer } from "./server.js"; | ||
| export { text } from "./text.js"; | ||
| export { Application } from './application.js' | ||
| export { ChatResponse } from './chat-response.js' | ||
| export { BaseServer } from './server.js' |
+13
-10
@@ -1,4 +0,5 @@ | ||
| import type { Logger } from "@chatally/logger"; | ||
| import type { IRequest } from "./request.d.ts"; | ||
| import type { IResponse } from "./response.d.ts"; | ||
| import type { Logger } from '@chatally/logger' | ||
| import type { ChatRequest } from './chat-request.d.ts' | ||
| import type { ChatResponse } from './chat-response.d.ts' | ||
| import type { IMediaServer } from './media.js' | ||
@@ -9,4 +10,4 @@ /** | ||
| export type Middleware<D> = | ||
| | ((params: Context<D>) => void | unknown) | ||
| | ((params: Context<D>) => Promise<void | unknown>); | ||
| | ((params: Context<D>) => unknown) | ||
| | ((params: Context<D>) => Promise<unknown>) | ||
@@ -18,11 +19,13 @@ /** | ||
| /** Request that triggered the handling */ | ||
| readonly req: IRequest; | ||
| readonly req: ChatRequest | ||
| /** Response for the request */ | ||
| readonly res: IResponse; | ||
| readonly res: ChatResponse | ||
| /** Access to media assets */ | ||
| readonly media: IMediaServer | ||
| /** Trigger dispatching to the next middleware. */ | ||
| readonly next: () => Promise<void>; | ||
| readonly next: () => Promise<void> | ||
| /** Context-specific logger for the middleware */ | ||
| readonly log: Logger; | ||
| readonly log: Logger | ||
| /** Arbitrary data storage */ | ||
| readonly data: D & Record<string, unknown>; | ||
| readonly data: D & Record<string, unknown> | ||
| } |
+29
-24
@@ -1,6 +0,6 @@ | ||
| import type { Logger } from "@chatally/logger"; | ||
| import type { EventEmitter } from "node:events"; | ||
| import type { IRequest } from "./request.d.ts"; | ||
| import type { IResponse } from "./response.d.ts"; | ||
| import { IncomingMessage, OutgoingMessage } from "./message.js"; | ||
| import type { EventEmitter } from 'node:events' | ||
| import type { Logger } from '@chatally/logger' | ||
| import type { ChatMessage } from './chat-message.d.ts' | ||
| import type { ChatRequest } from './chat-request.d.ts' | ||
| import type { ChatResponse } from './chat-response.d.ts' | ||
@@ -15,3 +15,3 @@ /** | ||
| /** The server's name */ | ||
| name: string; | ||
| name: string | ||
@@ -27,4 +27,4 @@ /** | ||
| */ | ||
| get log(): Logger | undefined; | ||
| set log(log: Logger | undefined); | ||
| get log(): Logger | undefined | ||
| set log(log: Logger | undefined) | ||
@@ -37,3 +37,6 @@ /** | ||
| */ | ||
| listen: () => void; | ||
| listen: () => void | ||
| canDownload: (url: string) => boolean | ||
| download: (url: string) => Promise<Buffer> | ||
| } | ||
@@ -43,9 +46,8 @@ | ||
| extends EventEmitter<ServerEvents> | ||
| implements Server | ||
| { | ||
| name: string; | ||
| get log(): Logger | undefined; | ||
| set log(log: Logger | undefined); | ||
| constructor(name: string); | ||
| abstract listen(): void; | ||
| implements Server { | ||
| name: string | ||
| get log(): Logger | undefined | ||
| set log(log: Logger | undefined) | ||
| constructor(name: string) | ||
| abstract listen(): void | ||
| /** | ||
@@ -61,14 +63,17 @@ * Dispatch an incoming message to all event listeners on the "dispatch" | ||
| dispatch( | ||
| incoming: IncomingMessage | string, | ||
| incoming: ChatRequest | string, | ||
| callbacks?: { | ||
| onWrite?: (msg: OutgoingMessage) => void; | ||
| onFinished?: (res: IResponse) => void; | ||
| onWrite?: (msg: ChatMessage) => void | ||
| onFinished?: (res: ChatResponse) => void | ||
| } | ||
| ): void; | ||
| ): void | ||
| canDownload(url: string): boolean | ||
| download(url: string): Promise<Buffer> | ||
| } | ||
| export type ServerEvents = { | ||
| dispatch: [IRequest, IResponse]; | ||
| }; | ||
| export interface ServerEvents { | ||
| dispatch: [ChatRequest, ChatResponse] | ||
| } | ||
| export function isServer(obj: unknown): obj is Server; | ||
| export function isServer(obj: unknown): obj is Server |
+73
-36
@@ -1,27 +0,27 @@ | ||
| import { EventEmitter } from "node:events"; | ||
| import { Request } from "./request.js"; | ||
| import { Response } from "./response.js"; | ||
| import { EventEmitter } from 'node:events' | ||
| import { nanoid } from 'nanoid' | ||
| import { ChatResponse } from './chat-response.js' | ||
| /** | ||
| * @typedef {import("./message.d.ts").IncomingMessage} IncomingMessage | ||
| * @typedef {import("./message.d.ts").OutgoingMessage} OutgoingMessage | ||
| * @typedef {import('./chat-request.d.ts').ChatRequest} ChatRequest | ||
| * @typedef {import('./chat-message.d.ts').ChatMessage} ChatMessage | ||
| */ | ||
| /** | ||
| * @typedef {import("./server.d.ts").Server} Server | ||
| * @typedef {import('./server.d.ts').Server} Server | ||
| * | ||
| * @class | ||
| * @extends {EventEmitter<import("./server.d.ts").ServerEvents>} | ||
| * @extends {EventEmitter<import('./server.d.ts').ServerEvents>} | ||
| * @implements {Server} | ||
| */ | ||
| export class BaseServer extends EventEmitter { | ||
| /** @type {import("@chatally/logger").Logger | undefined} */ | ||
| #log; | ||
| /** @type {import('@chatally/logger').Logger | undefined} */ | ||
| #log | ||
| get log() { | ||
| return this.#log; | ||
| return this.#log | ||
| } | ||
| set log(log) { | ||
| this.#log = log; | ||
| this.#log = log | ||
| } | ||
@@ -33,4 +33,4 @@ | ||
| constructor(name) { | ||
| super(); | ||
| this.name = name; | ||
| super() | ||
| this.name = name | ||
| } | ||
@@ -40,35 +40,67 @@ | ||
| throw new Error( | ||
| "The method `listen()` in BaseServer is abstract and must be overridden" | ||
| ); | ||
| 'The method `listen()` in BaseServer is abstract and must be overridden', | ||
| ) | ||
| } | ||
| /** | ||
| * @param {string | IncomingMessage} incoming | ||
| * @param {string | ChatRequest} req | ||
| * @param {object} callbacks | ||
| * @param {((msg: OutgoingMessage) => void) | ((msg: OutgoingMessage) => Promise<void>)} [callbacks.onWrite] | ||
| * @param {((res: Response) => void) | ((res: Response) => Promise<void>)} [callbacks.onFinished] | ||
| * @param {((msg: ChatMessage) => void) | ((msg: ChatMessage) => Promise<void>)} [callbacks.onWrite] | ||
| * @param {((res: ChatResponse) => void) | ((res: ChatResponse) => Promise<void>)} [callbacks.onFinished] | ||
| */ | ||
| dispatch(incoming, { onWrite, onFinished }) { | ||
| const req = new Request(incoming); | ||
| const res = new Response(); | ||
| dispatch(req, { onWrite, onFinished }) { | ||
| if (typeof req === 'string') { | ||
| let [from, ...rest] = req.split(': ') | ||
| let content = '' | ||
| if (rest.length === 0) { | ||
| content = from | ||
| from = 'unknown' | ||
| } else { | ||
| content = rest.join(': ') | ||
| } | ||
| req = /** @type {ChatRequest} */ { | ||
| type: 'text', | ||
| source: this.name, | ||
| id: nanoid(), | ||
| timestamp: Date.now(), | ||
| from, | ||
| content, | ||
| } | ||
| } | ||
| const res = new ChatResponse() | ||
| if (onWrite) { | ||
| res.on("write", async (msg) => { | ||
| res.on('write', async (msg) => { | ||
| try { | ||
| await onWrite(msg); | ||
| await onWrite(msg) | ||
| } catch (err) { | ||
| this.log?.error(err); | ||
| this.log?.error(err) | ||
| } | ||
| }); | ||
| }) | ||
| } | ||
| if (onFinished) { | ||
| res.on("finished", async (res) => { | ||
| res.on('finished', async (res) => { | ||
| try { | ||
| await onFinished(res); | ||
| await onFinished(res) | ||
| } catch (err) { | ||
| this.log?.error(err); | ||
| this.log?.error(err) | ||
| } | ||
| }); | ||
| }) | ||
| } | ||
| this.emit("dispatch", req, res); | ||
| this.emit('dispatch', req, res) | ||
| } | ||
| /** | ||
| * @param {string} _url | ||
| */ | ||
| canDownload(_url) { | ||
| return false | ||
| } | ||
| /** | ||
| * @param {string} _url | ||
| * @returns {Promise<Buffer>} Never returns, always throws an error | ||
| */ | ||
| async download(_url) { | ||
| throw new Error('Method not implemented') | ||
| } | ||
| } | ||
@@ -78,11 +110,16 @@ | ||
| * @param {any} object | ||
| * @returns {object is import("./index.d.ts").Server} | ||
| * @returns {object is import('./index.d.ts').Server} | ||
| * True if the provided object is a ChatAlly server | ||
| */ | ||
| export function isServer(object) { | ||
| if (!object) return false; | ||
| if (object instanceof BaseServer) return true; | ||
| if (!object) | ||
| return false | ||
| if (object instanceof BaseServer) | ||
| return true | ||
| if (typeof object.listen !== "function") return false; | ||
| if (typeof object.on !== "function") return false; | ||
| return true; | ||
| if (typeof object.listen !== 'function') | ||
| return false | ||
| if (typeof object.on !== 'function') | ||
| return false | ||
| return true | ||
| } |
+8
-7
| { | ||
| "name": "@chatally/core", | ||
| "version": "0.0.9", | ||
| "type": "module", | ||
| "version": "0.0.10", | ||
| "description": "ChatAlly Core Modules", | ||
@@ -14,3 +15,2 @@ "license": "MIT", | ||
| ], | ||
| "type": "module", | ||
| "exports": { | ||
@@ -25,11 +25,12 @@ ".": { | ||
| "scripts": { | ||
| "tsc": "tsc", | ||
| "lint": "eslint .", | ||
| "repl": "nodemon repl.js", | ||
| "test": "vitest" | ||
| "lint:fix": "eslint . --fix", | ||
| "test": "vitest", | ||
| "tsc": "tsc" | ||
| }, | ||
| "dependencies": { | ||
| "@chatally/logger": "*", | ||
| "nanoid": "^5.0.7" | ||
| "nanoid": "^5.0.7", | ||
| "node-cache": "^5.1.2" | ||
| } | ||
| } | ||
| } |
+5
-5
@@ -17,13 +17,13 @@ # @chatally/core | ||
| // index.js | ||
| import { Application } from "@chatally/core"; | ||
| import { ConsoleServer } from "@chatally/console"; | ||
| import { Application } from '@chatally/core' | ||
| import { ConsoleServer } from '@chatally/console' | ||
| new Application({ log: false }) // | ||
| .use(new ConsoleServer()) | ||
| .use(function echo({ req, res }) { | ||
| .use(({ req, res }) => { | ||
| if (res.isWritable) { | ||
| res.write(`You said '${req.text}'`); | ||
| res.write(`You said '${req.text}'`) | ||
| } | ||
| }) | ||
| .listen(); | ||
| .listen() | ||
| ``` | ||
@@ -30,0 +30,0 @@ |
-171
| /** | ||
| * Generic message type (incoming and outgoing). | ||
| */ | ||
| export type Message = IncomingMessage | OutgoingMessage; | ||
| /** | ||
| * Incoming message. | ||
| */ | ||
| export type IncomingMessage = { | ||
| /** Arrival time of message */ | ||
| timestamp: number; | ||
| /** Id of message */ | ||
| id: string; | ||
| /** Id of sender */ | ||
| from: string; | ||
| /** [Optional] Id of message that this message is a reply to. */ | ||
| replyTo?: string; | ||
| } & ( | ||
| | Action // Incoming only | ||
| | BidiMessage | ||
| ); | ||
| /** Bidirectional message types */ | ||
| type BidiMessage = | ||
| | Audio | ||
| | Custom | ||
| | Document | ||
| | Image | ||
| | Location | ||
| | Reaction | ||
| | Text | ||
| | Video; | ||
| /** | ||
| * Incoming message | ||
| */ | ||
| export type OutgoingMessage = { | ||
| /** [Optional] Id of message that this message is a reply to. */ | ||
| readonly replyTo?: string; | ||
| } & ( | ||
| | Buttons // Outgoing only | ||
| | Menu // Outgoing only | ||
| | BidiMessage | ||
| ); | ||
| export type Action = { | ||
| readonly type: "action"; | ||
| readonly action: _Action; | ||
| }; | ||
| export type Audio = { | ||
| readonly type: "audio"; | ||
| readonly audio: { | ||
| /** Location of audio data */ | ||
| url: string; | ||
| /** MIME type */ | ||
| mimeType: string; | ||
| /** [Optional] Caption */ | ||
| caption?: string; | ||
| /** [Optional] transcript of the audio */ | ||
| transcript?: string; | ||
| }; | ||
| }; | ||
| export type Buttons = { | ||
| readonly type: "buttons"; | ||
| /** The content of the message */ | ||
| readonly buttons: { | ||
| text: string; | ||
| actions: _Action[]; | ||
| }; | ||
| }; | ||
| type _Action = { | ||
| id: string; | ||
| title: string; | ||
| description?: string; | ||
| }; | ||
| export type Custom = { | ||
| readonly type: "custom"; | ||
| readonly schema: string; | ||
| readonly custom: unknown; | ||
| }; | ||
| export type Document = { | ||
| readonly type: "document"; | ||
| readonly document: { | ||
| /** Location of document data */ | ||
| url: string; | ||
| /** MIME type */ | ||
| mimeType: string; | ||
| /** [Optional] Caption */ | ||
| caption?: string; | ||
| /** [Optional] Name for the file on the device. */ | ||
| filename?: string; | ||
| /** [Optional] description of the document */ | ||
| description?: string; | ||
| }; | ||
| }; | ||
| export type Image = { | ||
| readonly type: "image"; | ||
| readonly image: { | ||
| /** Location of image data */ | ||
| url: string; | ||
| /** MIME type */ | ||
| mimeType: string; | ||
| /** [Optional] Caption */ | ||
| caption?: string; | ||
| /** [Optional] Image description */ | ||
| description?: string; | ||
| }; | ||
| }; | ||
| export type Location = { | ||
| readonly type: "location"; | ||
| readonly location: { | ||
| /** Longitude of the location. */ | ||
| longitude: number; | ||
| /** Latitude of the location. */ | ||
| latitude: number; | ||
| /** Name of the location. */ | ||
| name?: string; | ||
| /** Address of the location. */ | ||
| address?: string; | ||
| }; | ||
| }; | ||
| export type Menu = { | ||
| readonly type: "menu"; | ||
| /** The content of the message */ | ||
| readonly menu: { | ||
| title: string; | ||
| text: string; | ||
| sections: Array<{ | ||
| title?: string; | ||
| actions: _Action[]; | ||
| }>; | ||
| }; | ||
| }; | ||
| export type Reaction = { | ||
| readonly type: "reaction"; | ||
| readonly reaction: { | ||
| /** The ID of the message the customer reacted to. */ | ||
| replyTo: string; | ||
| /** The emoji the customer reacted with. */ | ||
| emoji: string; | ||
| }; | ||
| }; | ||
| export type Text = { | ||
| readonly type: "text"; | ||
| /** The content of the message */ | ||
| readonly text: string; | ||
| }; | ||
| export type Video = { | ||
| readonly type: "video"; | ||
| readonly video: { | ||
| /** Location of video data */ | ||
| url: string; | ||
| /** MIME type */ | ||
| mimeType: string; | ||
| /** [Optional] Caption */ | ||
| caption?: string; | ||
| /** [Optional] transcript of the video */ | ||
| transcript?: string; | ||
| }; | ||
| }; |
| import type { IncomingMessage } from "./message.d.ts"; | ||
| /** | ||
| * Chat request with incoming message | ||
| */ | ||
| export declare class Request implements IRequest { | ||
| /** | ||
| * Create a chat request for an incoming message. | ||
| * | ||
| * @param message Fully typed message or a string that can optionally contain | ||
| * a sender before a colon, i.e. `"<from>: <message>"` will create a text message with properties `from=<from>` and `text="<message>"`. | ||
| */ | ||
| constructor(message: string | IncomingMessage); | ||
| get message(): IncomingMessage; | ||
| get text(): string; | ||
| } | ||
| /** | ||
| * Chat request with incoming message. | ||
| */ | ||
| export interface IRequest { | ||
| /** Incoming message */ | ||
| get message(): IncomingMessage; | ||
| /** Textual content of incoming message */ | ||
| get text(): string; | ||
| } |
| import { nanoid } from "nanoid"; | ||
| import { text } from "./text.js"; | ||
| /** | ||
| * @typedef {import("./message.d.ts").IncomingMessage} IncomingMessage | ||
| */ | ||
| /** | ||
| * @type {import("./request.d.ts").Request} | ||
| */ | ||
| export class Request { | ||
| /** @type {IncomingMessage} */ | ||
| #message; | ||
| /** @param {IncomingMessage | string} message */ | ||
| constructor(message) { | ||
| if (typeof message === "string") { | ||
| let [from, text] = message.split(": "); | ||
| if (!text) { | ||
| text = from; | ||
| from = ""; | ||
| } | ||
| this.#message = { | ||
| type: "text", | ||
| text, | ||
| timestamp: Date.now(), | ||
| from, | ||
| id: nanoid(), | ||
| }; | ||
| } else { | ||
| this.#message = message; | ||
| } | ||
| } | ||
| get message() { | ||
| return this.#message; | ||
| } | ||
| get text() { | ||
| return text(this.#message); | ||
| } | ||
| } |
| import type { EventEmitter } from "node:events"; | ||
| import type { OutgoingMessage } from "./message.js"; | ||
| /** | ||
| * Chat response. | ||
| */ | ||
| export declare class Response | ||
| extends EventEmitter<ResponseEvents> | ||
| implements IResponse | ||
| { | ||
| /** | ||
| * Create a new chat response. | ||
| * | ||
| * @param onFinished | ||
| * [Optional] Handler to be called, when response `end()` is called. | ||
| */ | ||
| constructor(onFinished?: (r: Response) => void); | ||
| messages: OutgoingMessage[]; | ||
| isWritable: Readonly<boolean>; | ||
| write: (msg: string | OutgoingMessage) => void; | ||
| end: (msg?: string | OutgoingMessage | undefined) => void; | ||
| get text(): string[]; | ||
| } | ||
| type ResponseEvents = { | ||
| finished: [Response]; | ||
| write: [OutgoingMessage]; | ||
| }; | ||
| /** | ||
| * Chat response. | ||
| */ | ||
| export interface IResponse { | ||
| /** Messages to send as response. */ | ||
| messages: OutgoingMessage[]; | ||
| /** True if no middleware called end. */ | ||
| isWritable: Readonly<boolean>; | ||
| /** Write a message. */ | ||
| write: (msg: string | OutgoingMessage) => void; | ||
| /** End the response, optionally with a message. */ | ||
| end: (msg?: string | OutgoingMessage) => void; | ||
| /** Textual content of outgoing messages */ | ||
| get text(): string[]; | ||
| } |
| import { EventEmitter } from "node:events"; | ||
| import { text } from "./text.js"; | ||
| /** | ||
| * @typedef {import("./message.d.ts").OutgoingMessage} OutgoingMessage | ||
| */ | ||
| /** @type {import("./response.d.ts").Response} */ | ||
| export class Response extends EventEmitter { | ||
| /** @type {OutgoingMessage[]} */ | ||
| #messages = []; | ||
| #finished = false; | ||
| constructor() { | ||
| super(); | ||
| } | ||
| get messages() { | ||
| return this.#messages; | ||
| } | ||
| get isWritable() { | ||
| return !this.#finished; | ||
| } | ||
| /** @param {string | OutgoingMessage} [msg] */ | ||
| end(msg) { | ||
| this.write(msg); | ||
| this.#finished = true; | ||
| this.emit("finished", this); | ||
| } | ||
| /** @param {string | OutgoingMessage} [msg] */ | ||
| write(msg) { | ||
| if (!msg) return; | ||
| if (this.#finished) { | ||
| throw new Error("Cannot write anymore, response is finished."); | ||
| } | ||
| if (typeof msg === "string") { | ||
| msg = { type: "text", text: msg }; | ||
| } | ||
| if (Array.isArray(msg)) { | ||
| this.#messages.push(...msg); | ||
| } else { | ||
| this.#messages.push(msg); | ||
| } | ||
| this.emit("write", msg); | ||
| } | ||
| get text() { | ||
| return this.#messages.map(text); | ||
| } | ||
| } |
| import type { Message } from "./message.d.ts"; | ||
| export declare function text(message: Message): string; |
-56
| /** | ||
| * @param {import("./message.d.ts").Message} msg | ||
| * @returns {string} | ||
| */ | ||
| export function text(msg) { | ||
| switch (msg.type) { | ||
| case "audio": | ||
| return ( | ||
| msg.audio.transcript || | ||
| msg.audio.caption || | ||
| `audio: ${msg.audio.url} (${msg.audio.mimeType})` | ||
| ); | ||
| case "buttons": | ||
| return ( | ||
| msg.buttons.text || | ||
| msg.buttons.actions.map((a) => a.title).join(", ") || | ||
| "<buttons>" | ||
| ); | ||
| case "custom": | ||
| return "custom:" + JSON.stringify(msg.custom); | ||
| case "document": | ||
| return ( | ||
| msg.document.description || | ||
| msg.document.caption || | ||
| msg.document.filename || | ||
| "<document>" | ||
| ); | ||
| case "image": | ||
| return ( | ||
| msg.image.description || | ||
| msg.image.caption || | ||
| `image: ${msg.image.url} (${msg.image.mimeType}))` | ||
| ); | ||
| case "location": | ||
| return ( | ||
| msg.location.name || | ||
| msg.location.address || | ||
| `location: lon ${msg.location.longitude} lat ${msg.location.latitude}` | ||
| ); | ||
| case "menu": | ||
| return `${msg.menu.title}: ${msg.menu.text}`; | ||
| case "reaction": | ||
| return msg.reaction.emoji; | ||
| case "action": | ||
| return `${msg.action.id}: ${msg.action.title}`; | ||
| case "text": | ||
| return msg.text; | ||
| case "video": | ||
| return ( | ||
| msg.video.transcript || | ||
| msg.video.caption || | ||
| `video: ${msg.video.url} (${msg.video.mimeType})` | ||
| ); | ||
| } | ||
| // return "unknown:" + JSON.stringify(msg); | ||
| } |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
34500
10.15%1036
10.57%3
50%+ Added
+ Added
+ Added