diff --git a/CHANGELOG.md b/CHANGELOG.md index ffcebf3..e6a64a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Updated Node.js constraint to 18.17 - Updated dependencies - Added `proxyWebSocket` to exported utilities +- Added support for ESM-based scripts ## 0.17.0 diff --git a/docs/script-injector.md b/docs/script-injector.md index 6f97153..0d1f1e8 100644 --- a/docs/script-injector.md +++ b/docs/script-injector.md @@ -2,6 +2,8 @@ The script injector allows using Node modules to dynamically respond to a request. In order to work as intended each script is a Node module that exports a single function, which takes up to three arguments (a context variable containing *extended* settings of the script injector), the current request, as well as the response builder. +## Basic Usage + A simple "hello world"-like example looks as follows: ```js @@ -14,8 +16,20 @@ module.exports = function (ctx, req, res) { It is important to return either a `Promise` resolving with the result of calling the response builder or directly the response. If nothing (i.e., undefined) is returned, then the next script (or injector) is being tried. +Instead of the CommonJS syntax used in the example above a file could also have an `mjs` extension (i.e., instead of `example.js` it's named `example.mjs`), which allows using ESM syntax: + +```js +export default function (ctx, req, res) { + return res({ + content: `Hello World coming from ${req.url}!`, + }); +}; +``` + Since these scripts are standard Node modules, we are free to `require` any stuff we'd like to. Other files or installed modules. +## Configuration + The configuration of the script injector is defined to be: ```ts @@ -46,3 +60,150 @@ interface ScriptInjectorResponseBuilder { ``` The directory with the script files is watched, such that any change is trigger an evaluation of the changed file, which is then either removed, replaced, or added. Evaluation errors are shown in the client interface. + +## Advanced Details + +The signature of the function in a script is: + +```ts +interface Script { + (ctx: ScriptContextData, req: KrasRequest, builder: ScriptResponseBuilder): + | KrasAnswer + | Promise + | undefined; + connected?(ctx: ScriptContextData, e: KrasWebSocketEvent): void; + disconnected?(ctx: ScriptContextData, e: KrasWebSocketEvent): void; +} +``` + +where + +```ts +export interface ScriptContextData { + $server: EventEmitter; + $options: ScriptInjectorConfig; + $config: KrasConfiguration; + [prop: string]: any; +} + +export interface ScriptInjectorConfig { + /** + * Determins if the injector is active. + */ + active: boolean; + /** + * Optionally sets the targets to ignore. + * Otherwise, no targets are ignored. + */ + ignore?: Array; + /** + * Optionally sets explicitly the targets to handle. + * Otherwise, all targets are handled. + */ + handle?: Array; + /** + * Optionally sets the base dir of the injector, i.e., + * the directory where the injector could be found. + */ + baseDir?: string; + /** + * The directories where the scripts are located. + */ + directory?: string | Array; + /** + * The extended configuration that is forwarded / can be used by the scripts. + */ + extended?: Record; + /** + * Defines some additional configurations which are then + * handled by the specific injector. + */ + [config: string]: any; +} + +export interface KrasRequest { + /** + * Indicates if the request has been encrypted. + */ + encrypted: boolean; + /** + * The remote address triggering the request. + */ + remoteAddress: string; + /** + * The port used for the request. + */ + port: string; + /** + * The URL used for the request. + */ + url: string; + /** + * The target path of the request. + */ + target: string; + /** + * The query parameters of the request. + */ + query: KrasRequestQuery; + /** + * The method to trigger the request. + */ + method: string; + /** + * The headers used for the request. + */ + headers: IncomingHttpHeaders; + /** + * The content of the request. + */ + content: string | FormData; + /** + * The raw content of the request. + */ + rawContent: Buffer; + /** + * The form data, in case a form was given. + */ + formData?: FormData; +} + +export interface ScriptResponseBuilder { + (data: ScriptResponseBuilderData): KrasAnswer; +} + +export type Headers = Dict>; + +export interface ScriptResponseBuilderData { + statusCode?: number; + statusText?: string; + headers?: Headers; + content?: string; +} + +export interface KrasInjectorInfo { + name?: string; + host?: { + target: string; + address: string; + }; + file?: { + name: string; + entry?: number; + }; +} + +export interface KrasAnswer { + headers: Headers; + status: { + code: number; + text?: string; + }; + url: string; + redirectUrl?: string; + content: string | Buffer; + injector?: KrasInjectorInfo; +} +``` + +This allows also specifying `connected` and `disconnected` functions to handle WebSocket connections. diff --git a/src/server/helpers/io.test.ts b/src/server/helpers/io.test.ts index 7af4608..75a47ea 100644 --- a/src/server/helpers/io.test.ts +++ b/src/server/helpers/io.test.ts @@ -27,7 +27,7 @@ describe('io helpers', () => { return this; }, })); - const w = io.watch('foo', '*.jpg', (_, file) => { + const w = io.watch('foo', ['.jpg'], (_, file) => { found.push(file); }); expect(w.directories).toEqual(['foo']); @@ -45,7 +45,7 @@ describe('io helpers', () => { return this; }, })); - const w = io.watch('foo', '*.jpg', (_, file) => { + const w = io.watch('foo', ['.jpg'], (_, file) => { found.push(file); }); expect(w.directories).toEqual(['foo']); @@ -60,7 +60,7 @@ describe('io helpers', () => { return this; }, })); - const w = io.watch(['foo', 'bar'], '*.jpg', (_, file) => { + const w = io.watch(['foo', 'bar'], ['.jpg'], (_, file) => { found.push(file); }); expect(w.directories).toEqual(['foo', 'bar']); diff --git a/src/server/helpers/io.ts b/src/server/helpers/io.ts index 00bb29e..8956d0b 100644 --- a/src/server/helpers/io.ts +++ b/src/server/helpers/io.ts @@ -49,11 +49,17 @@ export function asJson(file: string, defaultValue: T): T { return defaultValue; } -export function asScript(file: string) { +export async function asScript(file: string) { if (existsSync(file)) { const key = require.resolve(file); delete require.cache[key]; - return require(file); + + if (key.endsWith('.mjs')) { + const result = await import(key); + return result.default || result; + } else { + return require(file); + } } return () => {}; @@ -87,14 +93,17 @@ export function isInDirectory(fn: string, dir: string) { function installWatcher( directory: string, - pattern: string, + extensions: Array, loadFile: WatchEvent, updateFile: WatchEvent, deleteFile: WatchEvent, ) { mk(directory); return chokidar - .watch(pattern, { cwd: directory }) + .watch('.', { + cwd: directory, + ignored: (path, stats) => stats?.isFile() && extensions.every((extension) => !path.endsWith(extension)), + }) .on('change', updateFile) .on('add', loadFile) .on('unlink', deleteFile); @@ -102,7 +111,7 @@ function installWatcher( function watchSingle( directory: string, - pattern: string, + extensions: Array, callback: (type: string, file: string, position: number) => void, watched: Array, ): SingleWatcher { @@ -142,7 +151,7 @@ function watchSingle( const fn = resolve(directory, file); callback('create', fn, getPosition(fn)); }; - const w = installWatcher(directory, pattern, loadFile, updateFile, deleteFile); + const w = installWatcher(directory, extensions, loadFile, updateFile, deleteFile); return { directory, close() { @@ -164,12 +173,12 @@ function watchSingle( export function watch( directory: string | Array, - pattern: string, + extensions: Array, callback: (type: string, file: string, position: number) => void, watched: Array = [], ): Watcher { if (Array.isArray(directory)) { - const ws = directory.map((dir) => watchSingle(dir, pattern, callback, watched)); + const ws = directory.map((dir) => watchSingle(dir, extensions, callback, watched)); return { get directories() { @@ -206,7 +215,7 @@ export function watch( } if (add) { - added.push(watchSingle(v, pattern, callback, watched)); + added.push(watchSingle(v, extensions, callback, watched)); } } @@ -217,6 +226,6 @@ export function watch( }, }; } else if (typeof directory === 'string') { - return watch([directory], pattern, callback, watched); + return watch([directory], extensions, callback, watched); } } diff --git a/src/server/helpers/proxy-websocket.ts b/src/server/helpers/proxy-websocket.ts index 7580c8f..c02738f 100644 --- a/src/server/helpers/proxy-websocket.ts +++ b/src/server/helpers/proxy-websocket.ts @@ -17,55 +17,91 @@ function releaseFrom(buffer: Array, ws: WebSocket) { } } -export interface ProxyWebSocketOptions { - id: string; - ws: KrasWebSocket; - url: string; - headers: Headers; - core: EventEmitter; +interface CurrentWebSocket { + open: boolean; + ws: KrasWebSocket | undefined; + buffer: Array; + signal: AbortSignal; + log(err: Error): void; } -export interface WebSocketDisposer { - (): void; -} - -export function proxyWebSocket(options: ProxyWebSocketOptions): WebSocketDisposer { - let open = false; - const buffer: Array = []; +function connect(current: CurrentWebSocket, options: ProxyWebSocketOptions) { const { url, core } = options; + const protocol = options.ws.protocol || undefined; - const ws = new WebSocket(url, options.ws.protocol, { + const ws = new WebSocket(url, protocol, { rejectUnauthorized: false, headers: options.headers, }); - ws.on('error', (err) => this.logError(err)); + ws.on('error', current.log); ws.on('open', () => { - open = true; + current.open = true; - if (buffer.length) { - releaseFrom(buffer, ws); + if (current.buffer.length) { + releaseFrom(current.buffer, ws); } }); ws.on('close', (e) => { - open = false; - core.emit('ws-closed', { reason: e }); + const retry = !current.signal.aborted; + current.open = false; + core.emit('ws-closed', { reason: e, retry }); + + if (retry) { + core.emit('info', `WebSocket connection interrupted. Retrying in 5s.`); + + setTimeout(() => { + if (!current.signal.aborted) { + connect(current, options); + } + }, 5_000); + } }); ws.on('message', (data) => { core.emit('message', { content: data, from: url, to: options.id, remote: true }); - options.ws.send(data, (err) => this.logError(err)); + options.ws.send(data, current.log); }); - options.ws.on('message', (data: WebSocket.Data) => { - core.emit('message', { content: data, to: url, from: options.id, remote: false }); + current.signal.onabort = () => ws.close(); + current.ws = ws; +} + +export interface ProxyWebSocketOptions { + id: string; + ws: KrasWebSocket; + url: string; + headers: Headers; + core: EventEmitter; +} + +export interface WebSocketDisposer { + (): void; +} + +export function proxyWebSocket(client: ProxyWebSocketOptions): WebSocketDisposer { + const ac = new AbortController(); + const server: CurrentWebSocket = { + open: false, + ws: undefined, + buffer: [], + signal: ac.signal, + log(err) { + err && client.core.emit('error', err); + }, + }; + + connect(server, client); + + client.ws.on('message', (data: WebSocket.Data) => { + client.core.emit('message', { content: data, to: client.url, from: client.id, remote: false }); - if (open) { - ws.send(data, (err) => this.logError(err)); + if (server.open) { + server.ws.send(data, server.log); } else { - buffer.push({ + server.buffer.push({ time: Date.now(), data, }); @@ -73,6 +109,6 @@ export function proxyWebSocket(options: ProxyWebSocketOptions): WebSocketDispose }); return () => { - ws.close(); + ac.abort(); }; } diff --git a/src/server/injectors/har-injector.ts b/src/server/injectors/har-injector.ts index dc37e2a..20bd42a 100644 --- a/src/server/injectors/har-injector.ts +++ b/src/server/injectors/har-injector.ts @@ -125,7 +125,7 @@ export default class HarInjector implements KrasInjector { address: config.map[target] as string, })); - this.watcher = watch(directory, '**/*.har', (ev, fileName, position) => { + this.watcher = watch(directory, ['.har'], (ev, fileName, position) => { switch (ev) { case 'create': case 'update': diff --git a/src/server/injectors/json-injector.ts b/src/server/injectors/json-injector.ts index 68e4c89..61f4e8c 100644 --- a/src/server/injectors/json-injector.ts +++ b/src/server/injectors/json-injector.ts @@ -68,7 +68,7 @@ export default class JsonInjector implements KrasInjector { const directory = options.directory || config.sources || config.directory; this.config = options; - this.watcher = watch(directory, '**/*.json', (ev, fileName, position) => { + this.watcher = watch(directory, ['.json'], (ev, fileName, position) => { switch (ev) { case 'create': case 'update': diff --git a/src/server/injectors/script-injector.ts b/src/server/injectors/script-injector.ts index 1cac07b..25eda02 100644 --- a/src/server/injectors/script-injector.ts +++ b/src/server/injectors/script-injector.ts @@ -49,9 +49,9 @@ export interface ScriptFileEntry { type ScriptFiles = Array; -export function tryEvaluate(script: ScriptFileEntry) { +export async function tryEvaluate(script: ScriptFileEntry) { try { - const handler = asScript(script.file); + const handler = await asScript(script.file); if (typeof handler !== 'function') { throw new Error('Does not export a function - it will be ignored.'); @@ -79,7 +79,7 @@ export default class ScriptInjector implements KrasInjector { this.core = core; this.krasConfig = config; - this.watcher = watch(directory, '**/*.js', (ev, fileName, position) => { + this.watcher = watch(directory, ['.js', '.mjs', '.cjs'], (ev, fileName, position) => { switch (ev) { case 'create': case 'update': @@ -93,7 +93,12 @@ export default class ScriptInjector implements KrasInjector { for (const { handler } of this.files) { if (handler && typeof handler.connected === 'function') { const ctx = this.getContext(); - handler.connected(ctx, e); + + try { + handler.connected(ctx, e); + } catch (err) { + core.emit('error', err); + } } } }); @@ -102,7 +107,12 @@ export default class ScriptInjector implements KrasInjector { for (const { handler } of this.files) { if (handler && typeof handler.disconnected === 'function') { const ctx = this.getContext(); - handler.disconnected(ctx, e); + + try { + handler.disconnected(ctx, e); + } catch (err) { + core.emit('error', err); + } } } }); @@ -164,12 +174,12 @@ export default class ScriptInjector implements KrasInjector { } } - private load(fileName: string, position: number) { + private async load(fileName: string, position: number) { const file = this.files.find(({ file }) => file === fileName); const active = file?.active ?? true; const script: ScriptFileEntry = { file: fileName, active }; - tryEvaluate(script); + await tryEvaluate(script); if (script.error) { this.core.emit('error', script.error); @@ -196,10 +206,15 @@ export default class ScriptInjector implements KrasInjector { }, }); const ctx = this.getContext(); - const res = handler(ctx, req, builder); - if (res) { - return res; + try { + const res = handler(ctx, req, builder); + + if (res) { + return res; + } + } catch (err) { + this.core.emit('error', err); } } }