Skip to content

Commit

Permalink
Improved websocket proxying
Browse files Browse the repository at this point in the history
  • Loading branch information
FlorianRappl committed Dec 15, 2024
1 parent 3432ae8 commit f0e6550
Show file tree
Hide file tree
Showing 8 changed files with 274 additions and 52 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
161 changes: 161 additions & 0 deletions docs/script-injector.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<KrasAnswer>
| 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<string>;
/**
* Optionally sets explicitly the targets to handle.
* Otherwise, all targets are handled.
*/
handle?: Array<string>;
/**
* 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<string>;
/**
* The extended configuration that is forwarded / can be used by the scripts.
*/
extended?: Record<string, any>;
/**
* 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<string | Array<string>>;

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.
6 changes: 3 additions & 3 deletions src/server/helpers/io.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand All @@ -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']);
Expand All @@ -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']);
Expand Down
29 changes: 19 additions & 10 deletions src/server/helpers/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,17 @@ export function asJson<T = {}>(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 () => {};
Expand Down Expand Up @@ -87,22 +93,25 @@ export function isInDirectory(fn: string, dir: string) {

function installWatcher(
directory: string,
pattern: string,
extensions: Array<string>,
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);
}

function watchSingle(
directory: string,
pattern: string,
extensions: Array<string>,
callback: (type: string, file: string, position: number) => void,
watched: Array<string>,
): SingleWatcher {
Expand Down Expand Up @@ -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() {
Expand All @@ -164,12 +173,12 @@ function watchSingle(

export function watch(
directory: string | Array<string>,
pattern: string,
extensions: Array<string>,
callback: (type: string, file: string, position: number) => void,
watched: Array<string> = [],
): 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() {
Expand Down Expand Up @@ -206,7 +215,7 @@ export function watch(
}

if (add) {
added.push(watchSingle(v, pattern, callback, watched));
added.push(watchSingle(v, extensions, callback, watched));
}
}

Expand All @@ -217,6 +226,6 @@ export function watch(
},
};
} else if (typeof directory === 'string') {
return watch([directory], pattern, callback, watched);
return watch([directory], extensions, callback, watched);
}
}
Loading

0 comments on commit f0e6550

Please sign in to comment.