diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a64a5..46d0ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## 1.0.0 - Replaced Jest with vitest -- Updated script injector to support `connected` and `disconnected` events +- Updated script injector to support `connected` and `disconnected` events in scripts +- Updated script injector scripts to use `setup` and `teardown` lifecycle - Updated Node.js constraint to 18.17 - Updated dependencies - Added `proxyWebSocket` to exported utilities diff --git a/docs/script-injector.md b/docs/script-injector.md index 0d1f1e8..11676b8 100644 --- a/docs/script-injector.md +++ b/docs/script-injector.md @@ -71,6 +71,8 @@ interface Script { | KrasAnswer | Promise | undefined; + setup?(ctx: ScriptContextData): void; + teardown?(ctx: ScriptContextData): void; connected?(ctx: ScriptContextData, e: KrasWebSocketEvent): void; disconnected?(ctx: ScriptContextData, e: KrasWebSocketEvent): void; } @@ -207,3 +209,5 @@ export interface KrasAnswer { ``` This allows also specifying `connected` and `disconnected` functions to handle WebSocket connections. + +The `setup` and `teardown` functions are used to properly initialize or dispose relevant resources. They are called when the script is first discovered or removed / replaced, e.g., in case of a file change. diff --git a/src/server/helpers/build-options.ts b/src/server/helpers/build-options.ts index 8e0fcb8..7f21141 100644 --- a/src/server/helpers/build-options.ts +++ b/src/server/helpers/build-options.ts @@ -2,7 +2,7 @@ import { basename } from 'path'; import { KrasInjectorOption } from '../types'; export interface FileInfo { - file: string; + path: string; active: boolean; error?: string; } @@ -12,7 +12,7 @@ export interface DescribeEntry { } function getFile(fileInfo: FileInfo) { - const fileName = fileInfo.file; + const fileName = fileInfo.path; return { id: Buffer.from(fileName).toString('base64'), name: fileName, @@ -23,7 +23,7 @@ function getFile(fileInfo: FileInfo) { } function getEntry(fileInfos: Array, desc: DescribeEntry) { - const fileName = fileInfos[0].file; + const fileName = fileInfos[0].path; return { id: Buffer.from(fileName).toString('base64'), name: fileName, diff --git a/src/server/helpers/io.ts b/src/server/helpers/io.ts index 8956d0b..5d48790 100644 --- a/src/server/helpers/io.ts +++ b/src/server/helpers/io.ts @@ -112,44 +112,22 @@ function installWatcher( function watchSingle( directory: string, extensions: Array, - callback: (type: string, file: string, position: number) => void, + callback: (type: string, file: string) => void, watched: Array, ): SingleWatcher { - const getPosition = (fn: string) => { - const idx = watched.indexOf(fn); - - if (idx === -1) { - let i = 0; - - while (i < watched.length) { - const w = watched[i]; - - if (w.localeCompare(fn) > 0) { - break; - } - - i++; - } - - watched.splice(i, 0, fn); - return i; - } - - return idx; - }; const updateFile = (file: string) => { const fn = resolve(directory, file); - callback('update', fn, getPosition(fn)); + callback('update', fn); }; const deleteFile = (file: string) => { const fn = resolve(directory, file); const idx = watched.indexOf(fn); idx !== -1 && watched.splice(idx, 1); - callback('delete', fn, -1); + callback('delete', fn); }; const loadFile = (file: string) => { const fn = resolve(directory, file); - callback('create', fn, getPosition(fn)); + callback('create', fn); }; const w = installWatcher(directory, extensions, loadFile, updateFile, deleteFile); return { @@ -162,7 +140,7 @@ function watchSingle( const fn = resolve(directory, dir, file); const idx = watched.indexOf(fn); idx !== -1 && watched.splice(idx, 1); - callback('delete', fn, -1); + callback('delete', fn); } } @@ -174,7 +152,7 @@ function watchSingle( export function watch( directory: string | Array, extensions: Array, - callback: (type: string, file: string, position: number) => void, + callback: (type: string, file: string) => void, watched: Array = [], ): Watcher { if (Array.isArray(directory)) { diff --git a/src/server/injectors/har-injector.ts b/src/server/injectors/har-injector.ts index 20bd42a..3d9d472 100644 --- a/src/server/injectors/har-injector.ts +++ b/src/server/injectors/har-injector.ts @@ -71,7 +71,7 @@ interface HttpArchive { } interface HarFileEntry { - file: string; + path: string; active: boolean; request: { method: string; @@ -125,11 +125,11 @@ 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) => { switch (ev) { case 'create': case 'update': - return this.load(fileName, position); + return this.load(fileName); case 'delete': return this.unload(fileName); } @@ -153,7 +153,7 @@ export default class HarInjector implements KrasInjector { this.config.delay = options.delay; for (const { name, entries } of options.files) { - const files = this.files.find((m) => m[0].file === name); + const files = this.files.find((m) => m[0].path === name); if (entries) { for (let i = 0; i < entries.length; i++) { @@ -183,21 +183,21 @@ export default class HarInjector implements KrasInjector { } private unload(fileName: string) { - const index = this.files.findIndex((m) => m[0].file === fileName); + const index = this.files.findIndex((m) => m[0].path === fileName); if (index !== -1) { this.files.splice(index, 1); } } - private load(fileName: string, position: number) { + private load(fileName: string) { const content = asJson(fileName, undefined); const entries = findEntries(content); const files = entries.map((entry) => this.transformEntry(fileName, entry)); this.unload(fileName); if (files.length > 0) { - this.files.splice(position, 0, files); + this.files.push(files); } } @@ -211,7 +211,7 @@ export default class HarInjector implements KrasInjector { return undefined; } - private transformEntry(file: string, entry: HttpArchive) { + private transformEntry(path: string, entry: HttpArchive) { const original = entry.request; const response = entry.response; const content = (original.postData || {}).text || ''; @@ -228,7 +228,7 @@ export default class HarInjector implements KrasInjector { delete request.headers._; return { - file, + path, active: true, time: entry.time, request, @@ -244,7 +244,7 @@ export default class HarInjector implements KrasInjector { let i = 0; for (const files of this.files) { - for (const { file, active, time, request, response } of files) { + for (const { path, active, time, request, response } of files) { if (active) { const name = this.name; @@ -253,7 +253,7 @@ export default class HarInjector implements KrasInjector { fromHar(request.url, response, { name, file: { - name: file, + name: path, entry: i, }, }), diff --git a/src/server/injectors/json-injector.ts b/src/server/injectors/json-injector.ts index 61f4e8c..27a40d3 100644 --- a/src/server/injectors/json-injector.ts +++ b/src/server/injectors/json-injector.ts @@ -32,7 +32,7 @@ function find(response: KrasAnswer | Array, randomize: boolean) { } interface JsonFileItem { - file: string; + path: string; active: boolean; request: KrasRequest; response: KrasAnswer | Array; @@ -68,11 +68,11 @@ 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) => { switch (ev) { case 'create': case 'update': - return this.load(fileName, position); + return this.load(fileName); case 'delete': return this.unload(fileName); } @@ -96,7 +96,7 @@ export default class JsonInjector implements KrasInjector { this.config.randomize = options.randomize; for (const { name, entries } of options.files) { - const files = this.files.find((m) => m[0].file === name); + const files = this.files.find((m) => m[0].path === name); if (entries) { for (let i = 0; i < entries.length; i++) { @@ -126,14 +126,14 @@ export default class JsonInjector implements KrasInjector { } private unload(fileName: string) { - const index = this.files.findIndex((m) => m[0].file === fileName); + const index = this.files.findIndex((m) => m[0].path === fileName); if (index !== -1) { this.files.splice(index, 1); } } - private load(fileName: string, position: number) { + private load(fileName: string) { const content = asJson(fileName, []); const items = Array.isArray(content) ? content : [content]; @@ -153,7 +153,7 @@ export default class JsonInjector implements KrasInjector { this.unload(fileName); if (items.length > 0) { - this.files.splice(position, 0, items); + this.files.push(items); } } @@ -198,7 +198,7 @@ export default class JsonInjector implements KrasInjector { let i = 0; for (const files of this.files) { - for (const { file, active, request, response } of files) { + for (const { path, active, request, response } of files) { if (active) { if (compareRequests(request, req)) { const rand = this.config.randomize; @@ -209,7 +209,7 @@ export default class JsonInjector implements KrasInjector { return fromJson(request.url, res.status.code, res.status.text, res.headers, content, { name, file: { - name: file, + name: path, entry: i, }, }); diff --git a/src/server/injectors/script-injector.ts b/src/server/injectors/script-injector.ts index 25eda02..162425d 100644 --- a/src/server/injectors/script-injector.ts +++ b/src/server/injectors/script-injector.ts @@ -33,30 +33,39 @@ export interface DynamicScriptInjectorConfig { }>; } +export interface ScriptExports { + ( + ctx: ScriptContextData, + req: KrasRequest, + builder: ScriptResponseBuilder, + ): KrasAnswer | Promise | undefined; + setup?(ctx: ScriptContextData): void; + teardown?(ctx: ScriptContextData): void; + connected?(ctx: ScriptContextData, e: KrasWebSocketEvent): void; + disconnected?(ctx: ScriptContextData, e: KrasWebSocketEvent): void; +} + export interface ScriptFileEntry { - file: string; + path: string; active: boolean; error?: string; - handler?: { - (ctx: ScriptContextData, req: KrasRequest, builder: ScriptResponseBuilder): - | KrasAnswer - | Promise - | undefined; - connected?(ctx: ScriptContextData, e: KrasWebSocketEvent): void; - disconnected?(ctx: ScriptContextData, e: KrasWebSocketEvent): void; - }; + handler?: ScriptExports; } type ScriptFiles = Array; -export async function tryEvaluate(script: ScriptFileEntry) { +export async function tryEvaluate(script: ScriptFileEntry, ctx: ScriptContextData) { try { - const handler = await asScript(script.file); + const handler = await asScript(script.path); if (typeof handler !== 'function') { throw new Error('Does not export a function - it will be ignored.'); } + if (typeof handler?.setup === 'function') { + handler.setup(ctx); + } + script.error = undefined; script.handler = handler; } catch (e) { @@ -79,11 +88,11 @@ export default class ScriptInjector implements KrasInjector { this.core = core; this.krasConfig = config; - this.watcher = watch(directory, ['.js', '.mjs', '.cjs'], (ev, fileName, position) => { + this.watcher = watch(directory, ['.js', '.mjs', '.cjs'], (ev, fileName) => { switch (ev) { case 'create': case 'update': - return this.load(fileName, position); + return this.load(fileName); case 'delete': return this.unload(fileName); } @@ -143,7 +152,7 @@ export default class ScriptInjector implements KrasInjector { setOptions(options: DynamicScriptInjectorConfig): void { for (const { name, active } of options.files) { - const script = this.files.find((f) => f.file === name); + const script = this.files.find((f) => f.path === name); if (script && typeof active === 'boolean') { script.active = active; @@ -167,26 +176,43 @@ export default class ScriptInjector implements KrasInjector { } private unload(fileName: string) { - const index = this.files.findIndex(({ file }) => file === fileName); + const index = this.files.findIndex(({ path }) => path === fileName); if (index !== -1) { + const file = this.files[index]; + const handler = file.handler; + + if (typeof handler?.teardown === 'function') { + const ctx = this.getContext(); + + try { + handler.teardown(ctx); + } catch (err) { + this.core.emit('error', err); + } + } + this.files.splice(index, 1); } } - private async load(fileName: string, position: number) { - const file = this.files.find(({ file }) => file === fileName); + private async load(fileName: string) { + const file = this.files.find(({ path }) => path === fileName); const active = file?.active ?? true; - const script: ScriptFileEntry = { file: fileName, active }; + const script: ScriptFileEntry = { path: fileName, active }; + const ctx = this.getContext(); + + if (file) { + this.unload(fileName); + } - await tryEvaluate(script); + await tryEvaluate(script, ctx); if (script.error) { this.core.emit('error', script.error); + } else { + this.files.push(script); } - - this.unload(fileName); - this.files.splice(position, 0, script); } dispose() { @@ -194,7 +220,7 @@ export default class ScriptInjector implements KrasInjector { } handle(req: KrasRequest): Promise | KrasAnswer { - for (const { file, active, handler } of this.files) { + for (const { path, active, handler } of this.files) { const name = this.name; if (active) { @@ -202,7 +228,7 @@ export default class ScriptInjector implements KrasInjector { fromJson(req.url, statusCode, statusText, headers, content, { name, file: { - name: file, + name: path, }, }); const ctx = this.getContext();