@bjoernboss/mws 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -6
- package/dist/base.d.ts +7 -6
- package/dist/base.js +2 -2
- package/dist/builder.d.ts +14 -0
- package/dist/builder.js +10 -10
- package/dist/cache.d.ts +42 -2
- package/dist/cache.js +81 -41
- package/dist/client.d.ts +144 -19
- package/dist/client.js +177 -153
- package/dist/handler.d.ts +70 -9
- package/dist/handler.js +71 -71
- package/dist/helper.d.ts +24 -1
- package/dist/helper.js +23 -23
- package/dist/log.d.ts +25 -14
- package/dist/log.js +21 -57
- package/dist/server.d.ts +70 -13
- package/dist/server.js +237 -163
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -13,8 +13,6 @@ Only depends on [`ws`](https://github.com/websockets/ws) at runtime.
|
|
|
13
13
|
|
|
14
14
|
Requires Node.js 22 or later.
|
|
15
15
|
|
|
16
|
-
Note: Look into the source `TypeScript` code on [`GitHub`](https://github.com/BjoernBoss/mws).
|
|
17
|
-
|
|
18
16
|
## Quick Start
|
|
19
17
|
|
|
20
18
|
```typescript
|
|
@@ -45,12 +43,34 @@ server.listen(new HelloModule(), {
|
|
|
45
43
|
});
|
|
46
44
|
```
|
|
47
45
|
|
|
46
|
+
## Listeners
|
|
47
|
+
|
|
48
|
+
`server.listen()` returns a `Listener` that emits `'listening'`, `'failed'`, and `'stopped'` events:
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
const listener = server.listen(handler, { port: 8080 });
|
|
52
|
+
|
|
53
|
+
listener.on('listening', (address) => console.log(`Listening on port ${address.port}`));
|
|
54
|
+
listener.on('failed', (err) => console.error(`Failed: ${err.message}`));
|
|
55
|
+
listener.on('stopped', () => console.log('Stopped'));
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A serverless listener does not bind to a port. Instead, connections are passed to it manually via `listener.handleRequest()` and `listener.handleUpgrade()` (can also be used for other listeners):
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
const listener = server.listen(handler, { serverless: { secure: false } });
|
|
62
|
+
|
|
63
|
+
/* forward requests from an external HTTP server */
|
|
64
|
+
externalServer.on('request', (req, resp) => listener.handleRequest(req, resp));
|
|
65
|
+
externalServer.on('upgrade', (req, sock, head) => listener.handleUpgrade(req, sock, head));
|
|
66
|
+
```
|
|
67
|
+
|
|
48
68
|
## Writing Modules
|
|
49
69
|
|
|
50
70
|
A module extends `ModuleHandler` and implements up to three lifecycle hooks:
|
|
51
71
|
|
|
52
72
|
```typescript
|
|
53
|
-
import { ModuleHandler, ClientRequest, Server } from "@bjoernboss/mws";
|
|
73
|
+
import { ModuleHandler, ClientRequest, Server, Params } from "@bjoernboss/mws";
|
|
54
74
|
|
|
55
75
|
export class MyModule extends ModuleHandler {
|
|
56
76
|
constructor() {
|
|
@@ -63,7 +83,7 @@ export class MyModule extends ModuleHandler {
|
|
|
63
83
|
}
|
|
64
84
|
|
|
65
85
|
/* Called for every incoming request routed to this module */
|
|
66
|
-
protected override async handleRequest(client: ClientRequest, params?:
|
|
86
|
+
protected override async handleRequest(client: ClientRequest, params?: Params): Promise<void> {
|
|
67
87
|
/* respond to the client */
|
|
68
88
|
}
|
|
69
89
|
|
|
@@ -221,6 +241,8 @@ if (!await client.tryRespondFile('/var/www/index.html'))
|
|
|
221
241
|
await client.respondHtml(page, { status: Status.Ok });
|
|
222
242
|
```
|
|
223
243
|
|
|
244
|
+
**Important:** Streams returned by `receiveData()` and `respondData()` can emit `'error'` events. Always register an `'error'` handler on these streams to prevent unhandled errors from crashing the process. Generally: correspondingly tagged functions may throw or error and must handle errors accordingly, to prevent crashing the process.
|
|
245
|
+
|
|
224
246
|
Convenience methods: `respondOk`, `respondNotFound`, `respondBadRequest`, `respondForbidden`, `respondInternalError`, `respondConflict`, `respondSeeOther`, `respondTemporaryRedirect`, `respondPermanentRedirect`, `respondCreated`, and others.
|
|
225
247
|
|
|
226
248
|
## WebSocket
|
|
@@ -259,22 +281,25 @@ File paths can be tagged with a unique version identifier so clients can cache t
|
|
|
259
281
|
|
|
260
282
|
```typescript
|
|
261
283
|
/* style.css becomes style.<id>.css — the id changes when the file changes */
|
|
262
|
-
const versionedPath = server.cache.immutable('my-
|
|
284
|
+
const versionedPath = server.cache.immutable('my-module', '/static/style.css');
|
|
263
285
|
```
|
|
264
286
|
|
|
265
287
|
When a request arrives for an immutable path, the cache strips the version tag, serves the underlying file, and redirects stale version tags to the current one. Set `CacheConfig.immutableStatePath` to persist version mappings across restarts.
|
|
266
288
|
|
|
267
289
|
### Direct Cache Access
|
|
268
290
|
|
|
291
|
+
These methods are designed for modules to be used, and allows directly to write to the disk, and update the cache at the same time. May throw exceptions.
|
|
292
|
+
|
|
269
293
|
```typescript
|
|
270
294
|
const buffer = await server.cache.read('/path/to/data.json');
|
|
271
295
|
await server.cache.write('/path/to/output.json', JSON.stringify(data));
|
|
296
|
+
await server.cache.remove('/path/to/old.json');
|
|
272
297
|
server.cache.flush();
|
|
273
298
|
```
|
|
274
299
|
|
|
275
300
|
## HTML Building
|
|
276
301
|
|
|
277
|
-
The `build` namespace provides programmatic HTML construction with automatic escaping:
|
|
302
|
+
The `build` namespace provides programmatic HTML construction with automatic escaping. It allows parent modules to use the `patchHtmlPage` function, to build around the HTML, before serving it:
|
|
278
303
|
|
|
279
304
|
```typescript
|
|
280
305
|
import { build } from "@bjoernboss/mws";
|
package/dist/base.d.ts
CHANGED
|
@@ -146,7 +146,7 @@ export declare const Media: {
|
|
|
146
146
|
readonly Svg: {
|
|
147
147
|
readonly fileEnding: ["svg"];
|
|
148
148
|
readonly mediaType: "image/svg+xml";
|
|
149
|
-
readonly encoding: "";
|
|
149
|
+
readonly encoding: "charset=utf-8";
|
|
150
150
|
readonly compressible: true;
|
|
151
151
|
};
|
|
152
152
|
readonly Unknown: {
|
|
@@ -167,32 +167,33 @@ export declare const Encoding: {
|
|
|
167
167
|
readonly name: "br";
|
|
168
168
|
readonly makeDecode: () => libZlib.BrotliDecompress;
|
|
169
169
|
readonly makeEncode: () => libZlib.BrotliCompress;
|
|
170
|
-
readonly encodeBuffer: (buffer: Buffer) =>
|
|
170
|
+
readonly encodeBuffer: (buffer: Buffer) => Buffer;
|
|
171
171
|
};
|
|
172
172
|
readonly Zstd: {
|
|
173
173
|
readonly name: "zstd";
|
|
174
174
|
readonly makeDecode: () => libZlib.ZstdDecompress;
|
|
175
175
|
readonly makeEncode: () => libZlib.ZstdCompress;
|
|
176
|
-
readonly encodeBuffer: (buffer: Buffer) =>
|
|
176
|
+
readonly encodeBuffer: (buffer: Buffer) => Buffer;
|
|
177
177
|
};
|
|
178
178
|
readonly Gzip: {
|
|
179
179
|
readonly name: "gzip";
|
|
180
180
|
readonly makeDecode: () => libZlib.Gunzip;
|
|
181
181
|
readonly makeEncode: () => libZlib.Gzip;
|
|
182
|
-
readonly encodeBuffer: (buffer: Buffer) =>
|
|
182
|
+
readonly encodeBuffer: (buffer: Buffer) => Buffer;
|
|
183
183
|
};
|
|
184
184
|
readonly Deflate: {
|
|
185
185
|
readonly name: "deflate";
|
|
186
186
|
readonly makeDecode: () => libZlib.Inflate;
|
|
187
187
|
readonly makeEncode: () => libZlib.Deflate;
|
|
188
|
-
readonly encodeBuffer: (buffer: Buffer) =>
|
|
188
|
+
readonly encodeBuffer: (buffer: Buffer) => Buffer;
|
|
189
189
|
};
|
|
190
190
|
readonly Identity: {
|
|
191
191
|
readonly name: "identity";
|
|
192
192
|
readonly makeDecode: () => libStream.PassThrough;
|
|
193
193
|
readonly makeEncode: () => libStream.PassThrough;
|
|
194
|
-
readonly encodeBuffer: (buffer: Buffer) => Buffer
|
|
194
|
+
readonly encodeBuffer: (buffer: Buffer) => Buffer;
|
|
195
195
|
};
|
|
196
196
|
};
|
|
197
|
+
/** minimum size for content is considered encodable */
|
|
197
198
|
export declare const MIN_ENCODING_SIZE: number;
|
|
198
199
|
//# sourceMappingURL=base.d.ts.map
|
package/dist/base.js
CHANGED
|
@@ -33,7 +33,7 @@ export const Media = {
|
|
|
33
33
|
Png: { fileEnding: ['png'], mediaType: 'image/png', encoding: '', compressible: false },
|
|
34
34
|
Gif: { fileEnding: ['gif'], mediaType: 'image/gif', encoding: '', compressible: false },
|
|
35
35
|
Jpg: { fileEnding: ['jpg', 'jpeg'], mediaType: 'image/jpeg', encoding: '', compressible: false },
|
|
36
|
-
Svg: { fileEnding: ['svg'], mediaType: 'image/svg+xml', encoding: '', compressible: true },
|
|
36
|
+
Svg: { fileEnding: ['svg'], mediaType: 'image/svg+xml', encoding: 'charset=utf-8', compressible: true },
|
|
37
37
|
Unknown: { fileEnding: [], mediaType: 'application/octet-stream', encoding: '', compressible: false }
|
|
38
38
|
};
|
|
39
39
|
export const Encoding = {
|
|
@@ -68,6 +68,6 @@ export const Encoding = {
|
|
|
68
68
|
encodeBuffer: (buffer) => buffer
|
|
69
69
|
}
|
|
70
70
|
};
|
|
71
|
-
|
|
71
|
+
/** minimum size for content is considered encodable */
|
|
72
72
|
export const MIN_ENCODING_SIZE = 1_000;
|
|
73
73
|
//# sourceMappingURL=base.js.map
|
package/dist/builder.d.ts
CHANGED
|
@@ -1,27 +1,36 @@
|
|
|
1
|
+
/** wrapper around a string that has been verified as safe for direct html insertion;
|
|
2
|
+
* plain strings are treated as untrusted and will be html-escaped before wrapping */
|
|
1
3
|
export type HtmlString = HtmlGuard | string;
|
|
2
4
|
export declare class HtmlGuard {
|
|
3
5
|
content: string;
|
|
4
6
|
private constructor();
|
|
7
|
+
/** ensure the value is safe for html insertion; escapes plain strings, passes through HtmlGuard as-is */
|
|
5
8
|
static get(str: HtmlString): HtmlGuard;
|
|
9
|
+
/** wrap a string for html insertion; if safe is false, it will be html-escaped first */
|
|
6
10
|
static make(str: string, safe: boolean): HtmlGuard;
|
|
7
11
|
}
|
|
12
|
+
/** create a secure string [if safe is true, content is taken as-is; otherwise it is html escaped] */
|
|
8
13
|
export declare function Safe(content: string, safe?: boolean): HtmlGuard;
|
|
14
|
+
/** html component building interface (simple should return true for small one liners) */
|
|
9
15
|
export interface HtmlComponent {
|
|
10
16
|
finalize(indent: string): string;
|
|
11
17
|
simple(): boolean;
|
|
12
18
|
}
|
|
19
|
+
/** raw text content to be embedded into an html tree */
|
|
13
20
|
export declare class EmbeddedContent implements HtmlComponent {
|
|
14
21
|
private content;
|
|
15
22
|
constructor(content: string, safe: boolean);
|
|
16
23
|
simple(): boolean;
|
|
17
24
|
finalize(_: string): string;
|
|
18
25
|
}
|
|
26
|
+
/** self-closing html tag (e.g. <meta/>, <link/>, <br/>) */
|
|
19
27
|
export declare class SingleTag implements HtmlComponent {
|
|
20
28
|
private content;
|
|
21
29
|
constructor(name: string, properties: Record<string, HtmlString>);
|
|
22
30
|
simple(): boolean;
|
|
23
31
|
finalize(indent: string): string;
|
|
24
32
|
}
|
|
33
|
+
/** html tag with children (e.g. <div>...</div>, <p>text</p>) */
|
|
25
34
|
export declare class DualTag implements HtmlComponent {
|
|
26
35
|
private openTag;
|
|
27
36
|
private closeTag;
|
|
@@ -30,6 +39,7 @@ export declare class DualTag implements HtmlComponent {
|
|
|
30
39
|
simple(): boolean;
|
|
31
40
|
finalize(indent: string): string;
|
|
32
41
|
}
|
|
42
|
+
/** full html page; automatically adds utf-8 charset and defaults language to 'en' */
|
|
33
43
|
export declare class HtmlPage {
|
|
34
44
|
private _head;
|
|
35
45
|
private _body;
|
|
@@ -45,12 +55,16 @@ export declare class HtmlPage {
|
|
|
45
55
|
set body(value: HtmlComponent | HtmlComponent[]);
|
|
46
56
|
finalize(): string;
|
|
47
57
|
}
|
|
58
|
+
/** embed raw content; if safe is true, the content is trusted and
|
|
59
|
+
* inserted verbatim; otherwise it is html-escaped before insertion */
|
|
48
60
|
export declare function Embed(content: string, safe: boolean): HtmlComponent;
|
|
49
61
|
export declare function Meta(name: HtmlString, content: HtmlString): HtmlComponent;
|
|
50
62
|
export declare function Title(name: HtmlString): HtmlComponent;
|
|
51
63
|
export declare function LoadStyle(path: HtmlString): HtmlComponent;
|
|
52
64
|
export declare function LoadScript(path: HtmlString, properties?: Record<string, HtmlString>): HtmlComponent;
|
|
65
|
+
/** inline css; content is inserted verbatim as <style> is a raw text element */
|
|
53
66
|
export declare function AddStyle(content: string, properties?: Record<string, HtmlString>): HtmlComponent;
|
|
67
|
+
/** inline javascript; content is inserted verbatim as <script> is a raw text element */
|
|
54
68
|
export declare function AddScript(content: string, properties?: Record<string, HtmlString>): HtmlComponent;
|
|
55
69
|
export declare function Text(text: HtmlString, properties?: Record<string, HtmlString>): HtmlComponent;
|
|
56
70
|
export declare function Div(properties?: Record<string, HtmlString>, children?: HtmlComponent[] | HtmlComponent): HtmlComponent;
|
package/dist/builder.js
CHANGED
|
@@ -6,20 +6,20 @@ export class HtmlGuard {
|
|
|
6
6
|
constructor(content) {
|
|
7
7
|
this.content = content;
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
/** ensure the value is safe for html insertion; escapes plain strings, passes through HtmlGuard as-is */
|
|
10
10
|
static get(str) {
|
|
11
11
|
return (str instanceof HtmlGuard ? str : new HtmlGuard(libHelper.escapeHtml(str)));
|
|
12
12
|
}
|
|
13
|
-
|
|
13
|
+
/** wrap a string for html insertion; if safe is false, it will be html-escaped first */
|
|
14
14
|
static make(str, safe) {
|
|
15
15
|
return new HtmlGuard(safe ? str : libHelper.escapeHtml(str));
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
/** create a secure string [if safe is true, content is taken as-is; otherwise it is html escaped] */
|
|
19
19
|
export function Safe(content, safe = true) {
|
|
20
20
|
return HtmlGuard.make(content, safe);
|
|
21
21
|
}
|
|
22
|
-
|
|
22
|
+
/** raw text content to be embedded into an html tree */
|
|
23
23
|
export class EmbeddedContent {
|
|
24
24
|
content;
|
|
25
25
|
constructor(content, safe) {
|
|
@@ -32,7 +32,7 @@ export class EmbeddedContent {
|
|
|
32
32
|
return this.content;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
/** self-closing html tag (e.g. <meta/>, <link/>, <br/>) */
|
|
36
36
|
export class SingleTag {
|
|
37
37
|
content;
|
|
38
38
|
constructor(name, properties) {
|
|
@@ -46,7 +46,7 @@ export class SingleTag {
|
|
|
46
46
|
return `${indent}${this.content}`;
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
/** html tag with children (e.g. <div>...</div>, <p>text</p>) */
|
|
50
50
|
export class DualTag {
|
|
51
51
|
openTag;
|
|
52
52
|
closeTag;
|
|
@@ -73,7 +73,7 @@ export class DualTag {
|
|
|
73
73
|
return `${indent}${this.openTag}${body}\n${indent}${this.closeTag}`;
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
/** full html page; automatically adds utf-8 charset and defaults language to 'en' */
|
|
77
77
|
export class HtmlPage {
|
|
78
78
|
_head;
|
|
79
79
|
_body;
|
|
@@ -109,7 +109,7 @@ export class HtmlPage {
|
|
|
109
109
|
return `<!DOCTYPE html>\n${page.finalize('')}`;
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
/** embed raw content; if safe is true, the content is trusted and
|
|
113
113
|
* inserted verbatim; otherwise it is html-escaped before insertion */
|
|
114
114
|
export function Embed(content, safe) {
|
|
115
115
|
return new EmbeddedContent(content, safe);
|
|
@@ -126,11 +126,11 @@ export function LoadStyle(path) {
|
|
|
126
126
|
export function LoadScript(path, properties = {}) {
|
|
127
127
|
return new DualTag('script', { ...properties, src: path }, []);
|
|
128
128
|
}
|
|
129
|
-
|
|
129
|
+
/** inline css; content is inserted verbatim as <style> is a raw text element */
|
|
130
130
|
export function AddStyle(content, properties = {}) {
|
|
131
131
|
return new DualTag('style', properties, [new EmbeddedContent(content, true)]);
|
|
132
132
|
}
|
|
133
|
-
|
|
133
|
+
/** inline javascript; content is inserted verbatim as <script> is a raw text element */
|
|
134
134
|
export function AddScript(content, properties = {}) {
|
|
135
135
|
return new DualTag('script', properties, new EmbeddedContent(content, true));
|
|
136
136
|
}
|
package/dist/cache.d.ts
CHANGED
|
@@ -2,56 +2,96 @@ import * as libLog from "./log.js";
|
|
|
2
2
|
import * as libBase from "./base.js";
|
|
3
3
|
import * as libStream from "stream";
|
|
4
4
|
export interface Cached {
|
|
5
|
+
/** check if this is an immutable file entry */
|
|
5
6
|
isImmutable(): boolean;
|
|
7
|
+
/** path of the file */
|
|
6
8
|
filePath(): string;
|
|
9
|
+
/** size in bytes of the file */
|
|
7
10
|
fileSize(): number;
|
|
11
|
+
/** fetch the last modified time formatted for the network */
|
|
8
12
|
lastModified(): string;
|
|
13
|
+
/** fetch the unique-id to identify this version of the cached file (constructed,
|
|
14
|
+
* just like the cache identifies them as equivalent: size+last-modified) */
|
|
9
15
|
uniqueId(): string;
|
|
16
|
+
/** [no-throw but errors] object or encoded entry must not be used anymore after reading or streaming from it */
|
|
10
17
|
stream(options?: {
|
|
11
18
|
start?: number;
|
|
12
19
|
end?: number;
|
|
13
20
|
}): libStream.Readable;
|
|
21
|
+
/** [throws] object or encoded entry must not be used anymore after reading or streaming from it */
|
|
14
22
|
read(): Promise<Buffer>;
|
|
23
|
+
/** [throws] object or encoded entry must not be used anymore after reading or streaming from it */
|
|
15
24
|
readSync(): Buffer;
|
|
25
|
+
/** create an encoded version of this cached entry (no encoding is equivalent to identity) */
|
|
16
26
|
encoded(encoding?: libBase.EncodingType): EncodedCache;
|
|
17
27
|
}
|
|
18
28
|
export interface EncodedCache {
|
|
29
|
+
/** size in bytes of the encoding (null if not yet determined) */
|
|
19
30
|
contentSize(): number | null;
|
|
31
|
+
/** [no-throw but errors] object or encoded entry must not be used anymore after reading or streaming from it */
|
|
20
32
|
stream(): libStream.Readable;
|
|
33
|
+
/** [throws] object or encoded entry must not be used anymore after reading or streaming from it */
|
|
21
34
|
read(): Promise<Buffer>;
|
|
35
|
+
/** [throws] object or encoded entry must not be used anymore after reading or streaming from it */
|
|
22
36
|
readSync(): Buffer;
|
|
23
37
|
}
|
|
38
|
+
/** all cache host operations which may throw errors and will not log them, and dont guarantee to contain the failing file path */
|
|
24
39
|
export declare class CacheHost extends libLog.Logger {
|
|
25
40
|
private _cacheManager;
|
|
26
41
|
private _immutableManager;
|
|
27
42
|
private _config;
|
|
28
43
|
constructor(config?: CacheConfig | BurntCacheConfig);
|
|
29
44
|
private resolveCache;
|
|
45
|
+
/** configuration used by this cache host */
|
|
30
46
|
get config(): BurntCacheConfig;
|
|
47
|
+
/** [throws] if [checkFreshness] is true, re-validate the file stats on disk before serving from cache (defaults
|
|
48
|
+
* to false); resolve immutable ids automatically (Cached to interact with cache; null, if it does not exist,
|
|
49
|
+
* string if the immutable path has been permanently moved to the new path in source space) */
|
|
31
50
|
fetchImmutable(path: string, options?: {
|
|
32
51
|
checkFreshness?: boolean;
|
|
33
52
|
}): Cached | string | null;
|
|
53
|
+
/** [throws] if [checkFreshness] is true re-validate the file stats on disk before serving from cache (defaults
|
|
54
|
+
* to false); no immutable ids are resolved (Cached to interact with cache; null, if it does not exist) */
|
|
34
55
|
fetchDirect(path: string, options?: {
|
|
35
56
|
checkFreshness?: boolean;
|
|
36
57
|
}): Cached | null;
|
|
37
|
-
|
|
58
|
+
/** generate a unique tagged path for the given query path, which will change whenever the underlying file changes;
|
|
59
|
+
* [checkFreshness]: if true, re-validate the file stats on disk to detect changes (defaults to false); creates
|
|
60
|
+
* a path to a file, which looks similar to the source, except that the name includes a unique id, which will be used
|
|
61
|
+
* to identity the given file state (will be removed from the final target path to be served, to identify the actual source) */
|
|
62
|
+
immutable(module: string, path: string, options?: {
|
|
38
63
|
checkFreshness?: boolean;
|
|
39
64
|
}): string;
|
|
65
|
+
/** flush all cached data and invalidate immutable stats so they are re-checked on next access */
|
|
40
66
|
flush(): void;
|
|
67
|
+
/** [throws] read the data directly into a buffer (designed for modules to interact with) */
|
|
41
68
|
read(path: string, options?: {
|
|
42
69
|
checkFreshness?: boolean;
|
|
43
70
|
}): Promise<Buffer | null>;
|
|
71
|
+
/** [throws] write data atomically to the disk and update the cache (designed for modules to interact with;
|
|
72
|
+
* writes as utf-8; writes data first to temporary file and then replaces the file atomically; for create,
|
|
73
|
+
* must not replace an existing file; returns false if the file already existed and could not be created) */
|
|
44
74
|
write(path: string, data: Buffer | string, options?: {
|
|
45
75
|
what?: string;
|
|
46
76
|
temporary?: string;
|
|
47
|
-
|
|
77
|
+
create?: boolean;
|
|
78
|
+
}): Promise<boolean>;
|
|
79
|
+
/** [throws] remove the data from the physical disk and from the cache (returns false if it did not exist) */
|
|
80
|
+
remove(path: string): Promise<boolean>;
|
|
48
81
|
}
|
|
82
|
+
/** simple wrapper function to create a cache */
|
|
49
83
|
export declare function createCache(config?: CacheConfig | BurntCacheConfig): CacheHost;
|
|
50
84
|
export interface CacheConfig {
|
|
85
|
+
/** immutable state path is used to ensure the immutable state uses persistent ids across
|
|
86
|
+
* restarts (will be read upon loading; if not set, ids will be lost after a server restart) [Default: ''] */
|
|
51
87
|
immutableStatePath?: string;
|
|
88
|
+
/** total cachable size [Default: 50_000_000] */
|
|
52
89
|
cacheSize?: number;
|
|
90
|
+
/** upper limit for files considered cachable, others will just be streamed through [Default: 10_000_000] */
|
|
53
91
|
fileSizeLimit?: number;
|
|
92
|
+
/** always validate file freshness before providing them [Default: false] */
|
|
54
93
|
alwaysValidate?: boolean;
|
|
94
|
+
/** tag served content with immutable ids to encode freshness into the path [Default: true] */
|
|
55
95
|
immutableTagging?: boolean;
|
|
56
96
|
}
|
|
57
97
|
export declare class BurntCacheConfig {
|
package/dist/cache.js
CHANGED
|
@@ -7,7 +7,7 @@ import * as libFsPromises from "fs/promises";
|
|
|
7
7
|
import * as libStream from "stream";
|
|
8
8
|
import * as libCrypto from "crypto";
|
|
9
9
|
const UNIQUE_ID_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
10
|
-
const UNIQUE_ID_LENGTH =
|
|
10
|
+
const UNIQUE_ID_LENGTH = 10;
|
|
11
11
|
const ID_EXTENSION_REGEX = RegExp(`^\\.[${UNIQUE_ID_CHARS}]{${UNIQUE_ID_LENGTH}}$`, 'i');
|
|
12
12
|
function readStats(path) {
|
|
13
13
|
try {
|
|
@@ -18,26 +18,31 @@ function readStats(path) {
|
|
|
18
18
|
catch (err) {
|
|
19
19
|
if (err.code == 'ENOENT')
|
|
20
20
|
return null;
|
|
21
|
-
throw
|
|
21
|
+
throw err;
|
|
22
22
|
}
|
|
23
23
|
return null;
|
|
24
24
|
}
|
|
25
25
|
async function atomicWrite(path, content, logger, options) {
|
|
26
26
|
const tempPath = (options?.temporary ?? `${path}.temp`);
|
|
27
|
+
logger.trace(`Writing ${options?.what ?? 'data'} to [${path}]`);
|
|
28
|
+
if (options?.create === true) {
|
|
29
|
+
try {
|
|
30
|
+
await libFsPromises.writeFile(path, content, { flag: 'wx' });
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (err.code == 'EEXIST')
|
|
34
|
+
return false;
|
|
35
|
+
throw new Error(`Failed to create file: ${err.message}`);
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
27
39
|
let written = false;
|
|
28
40
|
try {
|
|
29
|
-
logger.trace(`Writing ${options?.what ?? 'data'} to [${path}]`);
|
|
30
|
-
/* write the content to the temporary file */
|
|
31
41
|
await libFsPromises.writeFile(tempPath, content);
|
|
32
42
|
written = true;
|
|
33
43
|
await libFsPromises.rename(tempPath, path);
|
|
34
|
-
return true;
|
|
35
44
|
}
|
|
36
45
|
catch (err) {
|
|
37
|
-
if (written)
|
|
38
|
-
logger.error(`Failed to replace the original file [${path}]: ${err.message}`);
|
|
39
|
-
else
|
|
40
|
-
logger.error(`Failed to write to temporary file [${tempPath}]: ${err.message}`);
|
|
41
46
|
try {
|
|
42
47
|
await libFsPromises.unlink(tempPath);
|
|
43
48
|
}
|
|
@@ -45,8 +50,11 @@ async function atomicWrite(path, content, logger, options) {
|
|
|
45
50
|
if (err.code != 'ENOENT')
|
|
46
51
|
logger.warning(`Failed to remove temporary file [${tempPath}]: ${err.message}`);
|
|
47
52
|
}
|
|
53
|
+
if (written)
|
|
54
|
+
throw new Error(`Failed to replace the original file: ${err.message}`);
|
|
55
|
+
throw new Error(`Failed to write to temporary file [${tempPath}]: ${err.message}`);
|
|
48
56
|
}
|
|
49
|
-
return
|
|
57
|
+
return true;
|
|
50
58
|
}
|
|
51
59
|
class CacheManager {
|
|
52
60
|
logger;
|
|
@@ -83,9 +91,9 @@ class CacheManager {
|
|
|
83
91
|
if (this.allocated + data.byteLength > this.totalCapacity)
|
|
84
92
|
this.reduce(this.totalCapacity - data.byteLength);
|
|
85
93
|
/* add the entry to the cache */
|
|
86
|
-
this.logger.log(`Added [${path}] to the cache (Size: ${data.byteLength})`);
|
|
87
94
|
this.allocated += data.byteLength;
|
|
88
95
|
this.map[path] = { data, encodings: {}, mtime, touched: ++this.nextStamp, age };
|
|
96
|
+
this.logger.log(`Added [${path}] to the cache (Size: ${data.byteLength} / Allocated: ${this.allocated})`);
|
|
89
97
|
}
|
|
90
98
|
addEncoding(path, data, age, name) {
|
|
91
99
|
if (!this.cacheable(data.byteLength))
|
|
@@ -102,20 +110,20 @@ class CacheManager {
|
|
|
102
110
|
if (!(path in this.map))
|
|
103
111
|
return;
|
|
104
112
|
/* add the entry to the cache */
|
|
105
|
-
this.logger.log(`Added encoding [${name}] of [${path}] to the cache (Size: ${data.byteLength})`);
|
|
106
113
|
this.allocated += data.byteLength;
|
|
107
114
|
this.map[path].encodings[name] = data;
|
|
115
|
+
this.logger.log(`Added encoding [${name}] of [${path}] to the cache (Size: ${data.byteLength} / Allocated: ${this.allocated})`);
|
|
108
116
|
}
|
|
109
117
|
drop(path) {
|
|
110
118
|
if (!(path in this.map))
|
|
111
119
|
return;
|
|
112
|
-
this.logger.log(`Dropped [${path}] and encodings from the cache`);
|
|
113
120
|
/* remove all cached encodings and the entry itself */
|
|
114
121
|
const entry = this.map[path];
|
|
115
122
|
for (const key in entry.encodings)
|
|
116
123
|
this.allocated -= entry.encodings[key].byteLength;
|
|
117
124
|
this.allocated -= entry.data.byteLength;
|
|
118
125
|
delete this.map[path];
|
|
126
|
+
this.logger.log(`Dropped [${path}] and encodings from the cache (Allocated: ${this.allocated})`);
|
|
119
127
|
}
|
|
120
128
|
cacheable(size) {
|
|
121
129
|
return (size <= this.largestSize && size <= this.totalCapacity);
|
|
@@ -195,7 +203,12 @@ class ImmutableManager {
|
|
|
195
203
|
}
|
|
196
204
|
const content = JSON.stringify(output);
|
|
197
205
|
/* ignore any read/write failures */
|
|
198
|
-
|
|
206
|
+
try {
|
|
207
|
+
await atomicWrite(this.writeBack.path, Buffer.from(content, 'utf-8'), this.logger, { what: 'immutable state' });
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
this.logger.error(`Failed to write immutable state to [${this.writeBack.path}]: ${err.message}`);
|
|
211
|
+
}
|
|
199
212
|
}
|
|
200
213
|
this.writeBack.writing = null;
|
|
201
214
|
resolver();
|
|
@@ -286,8 +299,8 @@ class ImmutableManager {
|
|
|
286
299
|
if (performInitialWriteBack)
|
|
287
300
|
this.storeState();
|
|
288
301
|
}
|
|
289
|
-
make(
|
|
290
|
-
const identifier = `${
|
|
302
|
+
make(module, path, checkFreshness) {
|
|
303
|
+
const identifier = `${module}:${path}`;
|
|
291
304
|
if (!this.immutableTagging)
|
|
292
305
|
return path;
|
|
293
306
|
while (true) {
|
|
@@ -593,6 +606,7 @@ function EncodedCache(cache, reader, entry, age, encoding) {
|
|
|
593
606
|
}
|
|
594
607
|
};
|
|
595
608
|
}
|
|
609
|
+
/** all cache host operations which may throw errors and will not log them, and dont guarantee to contain the failing file path */
|
|
596
610
|
export class CacheHost extends libLog.Logger {
|
|
597
611
|
_cacheManager;
|
|
598
612
|
_immutableManager;
|
|
@@ -630,51 +644,60 @@ export class CacheHost extends libLog.Logger {
|
|
|
630
644
|
return new AlreadyCached(this._cacheManager, path, entry, isImmutable);
|
|
631
645
|
return new NotCached(this._cacheManager, path, fileSize, mtime, this._cacheManager.allocAge(), isImmutable);
|
|
632
646
|
}
|
|
633
|
-
|
|
647
|
+
/** configuration used by this cache host */
|
|
634
648
|
get config() {
|
|
635
649
|
return this._config;
|
|
636
650
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
651
|
+
/** [throws] if [checkFreshness] is true, re-validate the file stats on disk before serving from cache (defaults
|
|
652
|
+
* to false); resolve immutable ids automatically (Cached to interact with cache; null, if it does not exist,
|
|
653
|
+
* string if the immutable path has been permanently moved to the new path in source space) */
|
|
640
654
|
fetchImmutable(path, options) {
|
|
641
655
|
return this.resolveCache(path, options?.checkFreshness ?? false, true);
|
|
642
656
|
}
|
|
643
|
-
|
|
644
|
-
|
|
657
|
+
/** [throws] if [checkFreshness] is true re-validate the file stats on disk before serving from cache (defaults
|
|
658
|
+
* to false); no immutable ids are resolved (Cached to interact with cache; null, if it does not exist) */
|
|
645
659
|
fetchDirect(path, options) {
|
|
646
660
|
return this.resolveCache(path, options?.checkFreshness ?? false, false);
|
|
647
661
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
immutable(
|
|
653
|
-
return this._immutableManager.make(
|
|
662
|
+
/** generate a unique tagged path for the given query path, which will change whenever the underlying file changes;
|
|
663
|
+
* [checkFreshness]: if true, re-validate the file stats on disk to detect changes (defaults to false); creates
|
|
664
|
+
* a path to a file, which looks similar to the source, except that the name includes a unique id, which will be used
|
|
665
|
+
* to identity the given file state (will be removed from the final target path to be served, to identify the actual source) */
|
|
666
|
+
immutable(module, path, options) {
|
|
667
|
+
return this._immutableManager.make(module, path, options?.checkFreshness ?? false);
|
|
654
668
|
}
|
|
655
|
-
|
|
669
|
+
/** flush all cached data and invalidate immutable stats so they are re-checked on next access */
|
|
656
670
|
flush() {
|
|
657
671
|
this.info('Flushing cache and invalidating immutable entries');
|
|
658
672
|
this._cacheManager.flush();
|
|
659
673
|
this._immutableManager.invalidate();
|
|
660
674
|
}
|
|
661
|
-
|
|
675
|
+
/** [throws] read the data directly into a buffer (designed for modules to interact with) */
|
|
662
676
|
async read(path, options) {
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
677
|
+
try {
|
|
678
|
+
const entry = this.resolveCache(path, options?.checkFreshness ?? false, false);
|
|
679
|
+
if (entry == null)
|
|
680
|
+
return null;
|
|
681
|
+
return entry.read();
|
|
682
|
+
}
|
|
683
|
+
/* special abstraction to ensure check-before-use does not result in not-found */
|
|
684
|
+
catch (err) {
|
|
685
|
+
if (err.code == 'ENOENT')
|
|
686
|
+
return null;
|
|
687
|
+
throw err;
|
|
688
|
+
}
|
|
667
689
|
}
|
|
668
|
-
|
|
669
|
-
|
|
690
|
+
/** [throws] write data atomically to the disk and update the cache (designed for modules to interact with;
|
|
691
|
+
* writes as utf-8; writes data first to temporary file and then replaces the file atomically; for create,
|
|
692
|
+
* must not replace an existing file; returns false if the file already existed and could not be created) */
|
|
670
693
|
async write(path, data, options) {
|
|
671
694
|
if (typeof data == 'string')
|
|
672
695
|
data = Buffer.from(data, 'utf-8');
|
|
673
|
-
/* write the data atomically to the destination */
|
|
674
|
-
if (!await atomicWrite(path, data, this, { what: (options?.what ?? 'via cache'), temporary: options?.temporary }))
|
|
675
|
-
|
|
696
|
+
/* write the data atomically to the destination (let errors propagate out) */
|
|
697
|
+
if (!await atomicWrite(path, data, this, { what: (options?.what ?? 'via cache'), temporary: options?.temporary, create: options?.create }))
|
|
698
|
+
return false;
|
|
676
699
|
if (!this._cacheManager.cacheable(data.byteLength))
|
|
677
|
-
return;
|
|
700
|
+
return true;
|
|
678
701
|
const age = this._cacheManager.allocAge();
|
|
679
702
|
/* fetch the new state and update the cache (let errors propagate out; only if the write-state seems consistent) */
|
|
680
703
|
const stats = readStats(path);
|
|
@@ -682,9 +705,26 @@ export class CacheHost extends libLog.Logger {
|
|
|
682
705
|
this._cacheManager.drop(path);
|
|
683
706
|
else if (stats[0] == data.byteLength)
|
|
684
707
|
this._cacheManager.add(path, data, stats[1], age);
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
/** [throws] remove the data from the physical disk and from the cache (returns false if it did not exist) */
|
|
711
|
+
async remove(path) {
|
|
712
|
+
let existed = true;
|
|
713
|
+
/* try to remove the physical file */
|
|
714
|
+
try {
|
|
715
|
+
await libFsPromises.unlink(path);
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
if (err.code != 'ENOENT')
|
|
719
|
+
throw err;
|
|
720
|
+
existed = false;
|
|
721
|
+
}
|
|
722
|
+
/* remove the data from the cache */
|
|
723
|
+
this._cacheManager.drop(path);
|
|
724
|
+
return existed;
|
|
685
725
|
}
|
|
686
726
|
}
|
|
687
|
-
|
|
727
|
+
/** simple wrapper function to create a cache */
|
|
688
728
|
export function createCache(config) {
|
|
689
729
|
return new CacheHost(config);
|
|
690
730
|
}
|