@occultist/occultist 0.0.1
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/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/accept.d.ts +41 -0
- package/dist/accept.js +110 -0
- package/dist/accept.test.d.ts +1 -0
- package/dist/accept.test.js +44 -0
- package/dist/action.test.d.ts +1 -0
- package/dist/action.test.js +1 -0
- package/dist/actions/actionSets.d.ts +23 -0
- package/dist/actions/actionSets.js +49 -0
- package/dist/actions/actions.d.ts +163 -0
- package/dist/actions/actions.js +436 -0
- package/dist/actions/context.d.ts +78 -0
- package/dist/actions/context.js +112 -0
- package/dist/actions/meta.d.ts +49 -0
- package/dist/actions/meta.js +177 -0
- package/dist/actions/path.d.ts +21 -0
- package/dist/actions/path.js +83 -0
- package/dist/actions/path.test.d.ts +1 -0
- package/dist/actions/path.test.js +9 -0
- package/dist/actions/spec.d.ts +214 -0
- package/dist/actions/spec.js +1 -0
- package/dist/actions/types.d.ts +112 -0
- package/dist/actions/types.js +2 -0
- package/dist/actions/writer.d.ts +27 -0
- package/dist/actions/writer.js +140 -0
- package/dist/actions/writer.test.d.ts +1 -0
- package/dist/actions/writer.test.js +42 -0
- package/dist/auth/types.d.ts +14 -0
- package/dist/auth/types.js +1 -0
- package/dist/cache/cache.d.ts +30 -0
- package/dist/cache/cache.js +220 -0
- package/dist/cache/etag.d.ts +17 -0
- package/dist/cache/etag.js +83 -0
- package/dist/cache/etag.test.d.ts +1 -0
- package/dist/cache/etag.test.js +91 -0
- package/dist/cache/memory.d.ts +12 -0
- package/dist/cache/memory.js +36 -0
- package/dist/cache/types.d.ts +175 -0
- package/dist/cache/types.js +4 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +54 -0
- package/dist/jsonld.d.ts +43 -0
- package/dist/jsonld.js +1 -0
- package/dist/makeTypeDefs.d.ts +27 -0
- package/dist/makeTypeDefs.js +70 -0
- package/dist/merge.d.ts +61 -0
- package/dist/merge.js +1 -0
- package/dist/mod.d.ts +14 -0
- package/dist/mod.js +14 -0
- package/dist/processAction.d.ts +15 -0
- package/dist/processAction.js +512 -0
- package/dist/registry.d.ts +88 -0
- package/dist/registry.js +314 -0
- package/dist/registry.test.d.ts +1 -0
- package/dist/registry.test.js +133 -0
- package/dist/request.d.ts +29 -0
- package/dist/request.js +118 -0
- package/dist/scopes.d.ts +35 -0
- package/dist/scopes.js +121 -0
- package/dist/scopes.test.d.ts +1 -0
- package/dist/scopes.test.js +55 -0
- package/dist/transformers/fileTransformer.d.ts +1 -0
- package/dist/transformers/fileTransformer.js +8 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.js +1 -0
- package/dist/utils/alwaysArray.d.ts +1 -0
- package/dist/utils/alwaysArray.js +9 -0
- package/dist/utils/contextBuilder.d.ts +9 -0
- package/dist/utils/contextBuilder.js +82 -0
- package/dist/utils/getActionContext.d.ts +7 -0
- package/dist/utils/getActionContext.js +48 -0
- package/dist/utils/getInternalName.d.ts +6 -0
- package/dist/utils/getInternalName.js +7 -0
- package/dist/utils/getParamLocation.d.ts +2 -0
- package/dist/utils/getParamLocation.js +6 -0
- package/dist/utils/getPropertyValueSpecifications.d.ts +2 -0
- package/dist/utils/getPropertyValueSpecifications.js +49 -0
- package/dist/utils/getRequestBodyValues.d.ts +11 -0
- package/dist/utils/getRequestBodyValues.js +122 -0
- package/dist/utils/getRequestIRIValues.d.ts +14 -0
- package/dist/utils/getRequestIRIValues.js +133 -0
- package/dist/utils/isBodyInit.d.ts +1 -0
- package/dist/utils/isBodyInit.js +21 -0
- package/dist/utils/isNil.d.ts +1 -0
- package/dist/utils/isNil.js +4 -0
- package/dist/utils/isObject.d.ts +6 -0
- package/dist/utils/isObject.js +6 -0
- package/dist/utils/isPopulatedObject.d.ts +5 -0
- package/dist/utils/isPopulatedObject.js +8 -0
- package/dist/utils/isPopulatedString.d.ts +1 -0
- package/dist/utils/isPopulatedString.js +4 -0
- package/dist/utils/joinPaths.d.ts +1 -0
- package/dist/utils/joinPaths.js +31 -0
- package/dist/utils/makeAppendProblemDetails.d.ts +14 -0
- package/dist/utils/makeAppendProblemDetails.js +26 -0
- package/dist/utils/makeURLPattern.d.ts +5 -0
- package/dist/utils/makeURLPattern.js +12 -0
- package/dist/utils/normalizeURL.d.ts +4 -0
- package/dist/utils/normalizeURL.js +11 -0
- package/dist/utils/parseSearchParams.d.ts +3 -0
- package/dist/utils/parseSearchParams.js +24 -0
- package/dist/utils/preferredMediaTypes.d.ts +42 -0
- package/dist/utils/preferredMediaTypes.js +149 -0
- package/dist/utils/urlToIRI.d.ts +1 -0
- package/dist/utils/urlToIRI.js +8 -0
- package/dist/utils/validateSpecValue.d.ts +1 -0
- package/dist/utils/validateSpecValue.js +1 -0
- package/dist/validators.d.ts +16 -0
- package/dist/validators.js +134 -0
- package/lib/accept.test.ts +55 -0
- package/lib/accept.ts +147 -0
- package/lib/action.test.ts +2 -0
- package/lib/actions/actionSets.ts +88 -0
- package/lib/actions/actions.ts +795 -0
- package/lib/actions/context.ts +170 -0
- package/lib/actions/meta.ts +251 -0
- package/lib/actions/path.test.ts +15 -0
- package/lib/actions/path.ts +99 -0
- package/lib/actions/spec.ts +545 -0
- package/lib/actions/types.ts +146 -0
- package/lib/actions/writer.test.ts +57 -0
- package/lib/actions/writer.ts +176 -0
- package/lib/auth/types.ts +22 -0
- package/lib/cache/cache.ts +291 -0
- package/lib/cache/etag.test.ts +122 -0
- package/lib/cache/etag.ts +106 -0
- package/lib/cache/memory.ts +52 -0
- package/lib/cache/types.ts +240 -0
- package/lib/errors.ts +66 -0
- package/lib/jsonld.ts +67 -0
- package/lib/makeTypeDefs.ts +138 -0
- package/lib/merge.ts +86 -0
- package/lib/mod.ts +14 -0
- package/lib/processAction.ts +690 -0
- package/lib/registry.test.ts +174 -0
- package/lib/registry.ts +455 -0
- package/lib/request.ts +153 -0
- package/lib/scopes.test.ts +70 -0
- package/lib/scopes.ts +178 -0
- package/lib/transformers/fileTransformer.ts +10 -0
- package/lib/types.ts +13 -0
- package/lib/utils/alwaysArray.ts +10 -0
- package/lib/utils/contextBuilder.ts +111 -0
- package/lib/utils/getActionContext.ts +76 -0
- package/lib/utils/getInternalName.ts +15 -0
- package/lib/utils/getParamLocation.ts +14 -0
- package/lib/utils/getPropertyValueSpecifications.ts +76 -0
- package/lib/utils/getRequestBodyValues.ts +155 -0
- package/lib/utils/getRequestIRIValues.ts +201 -0
- package/lib/utils/isBodyInit.ts +22 -0
- package/lib/utils/isNil.ts +4 -0
- package/lib/utils/isObject.ts +8 -0
- package/lib/utils/isPopulatedObject.ts +9 -0
- package/lib/utils/isPopulatedString.ts +4 -0
- package/lib/utils/joinPaths.ts +36 -0
- package/lib/utils/makeAppendProblemDetails.ts +57 -0
- package/lib/utils/makeURLPattern.ts +18 -0
- package/lib/utils/normalizeURL.ts +15 -0
- package/lib/utils/parseSearchParams.ts +36 -0
- package/lib/utils/preferredMediaTypes.ts +220 -0
- package/lib/utils/urlToIRI.ts +11 -0
- package/lib/utils/validateSpecValue.ts +0 -0
- package/lib/validators.ts +186 -0
- package/package.json +41 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ServerResponse } from 'node:http';
|
|
2
|
+
function isHintLink(hint) {
|
|
3
|
+
return hint.href != null;
|
|
4
|
+
}
|
|
5
|
+
;
|
|
6
|
+
export class ResponseWriter {
|
|
7
|
+
#res;
|
|
8
|
+
#hints;
|
|
9
|
+
#status;
|
|
10
|
+
#statusText;
|
|
11
|
+
#headers = new Headers();
|
|
12
|
+
#body;
|
|
13
|
+
constructor(res) {
|
|
14
|
+
this.#res = res;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Writes early hints to the request.
|
|
18
|
+
*
|
|
19
|
+
* Runtimes which do not support writing early hints will have the
|
|
20
|
+
* headers added to the headers of the main response instead.
|
|
21
|
+
*/
|
|
22
|
+
writeEarlyHints(args) {
|
|
23
|
+
const links = [];
|
|
24
|
+
if (Array.isArray(args)) {
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
links.push(this.#formatEarlyHint(args[i]));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else if (typeof args === 'function') {
|
|
30
|
+
const res = args();
|
|
31
|
+
if (Array.isArray(res)) {
|
|
32
|
+
for (let i = 0; i < res.length; i++) {
|
|
33
|
+
links.push(this.#formatEarlyHint(res[i]));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (isHintLink(res)) {
|
|
37
|
+
links.push(this.#formatEarlyHint(res));
|
|
38
|
+
}
|
|
39
|
+
else if (Array.isArray(res.link)) {
|
|
40
|
+
for (let i = 0; i < res.link.length; i++) {
|
|
41
|
+
links.push(this.#formatEarlyHint(res.link[i]));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
links.push(this.#formatEarlyHint(res.link));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (isHintLink(args)) {
|
|
49
|
+
links.push(this.#formatEarlyHint(args));
|
|
50
|
+
}
|
|
51
|
+
else if (Array.isArray(args.link)) {
|
|
52
|
+
for (let i = 0; i < args.link.length; i++) {
|
|
53
|
+
links.push(this.#formatEarlyHint(args.link[i]));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
links.push(this.#formatEarlyHint(args.link));
|
|
58
|
+
}
|
|
59
|
+
if (this.#res == null) {
|
|
60
|
+
this.#headers.append('Link', links.join(', '));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
this.#res.writeEarlyHints({ 'Link': links.join(', ') }, resolve);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
mergeHeaders(headersInit) {
|
|
69
|
+
this.#setHeaders(new Headers(headersInit));
|
|
70
|
+
}
|
|
71
|
+
writeHead(status, headers) {
|
|
72
|
+
const res = this.#res;
|
|
73
|
+
this.#status = status;
|
|
74
|
+
if (headers != null) {
|
|
75
|
+
this.#setHeaders(headers);
|
|
76
|
+
}
|
|
77
|
+
if (res instanceof ServerResponse && this.#hints != null) {
|
|
78
|
+
res.writeHead(status, this.#hints);
|
|
79
|
+
}
|
|
80
|
+
else if (res instanceof ServerResponse) {
|
|
81
|
+
res.writeHead(status);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
writeBody(body) {
|
|
85
|
+
if (this.#res instanceof ServerResponse) {
|
|
86
|
+
this.#res.write(body);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
this.#body = body;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
response() {
|
|
93
|
+
if (this.#res instanceof ServerResponse) {
|
|
94
|
+
this.#res.end();
|
|
95
|
+
return this.#res;
|
|
96
|
+
}
|
|
97
|
+
if (this.#body instanceof Uint8Array) {
|
|
98
|
+
}
|
|
99
|
+
return new Response(this.#body, {
|
|
100
|
+
status: this.#status,
|
|
101
|
+
statusText: this.#statusText,
|
|
102
|
+
headers: this.#headers,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
#setHeaders(headers) {
|
|
106
|
+
for (const [header, value] of headers.entries()) {
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
for (const item of value) {
|
|
109
|
+
this.#headers.append(header, item);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
this.#headers.append(header, value);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
#formatEarlyHint(hint) {
|
|
118
|
+
let link = `<${encodeURI(hint.href)}>`;
|
|
119
|
+
if (hint.preload) {
|
|
120
|
+
link += `; rel=preload`;
|
|
121
|
+
}
|
|
122
|
+
if (Array.isArray(hint.rel)) {
|
|
123
|
+
link += '; ' + hint.rel.map((rel) => `rel=${rel}`)
|
|
124
|
+
.join('; ') + '';
|
|
125
|
+
}
|
|
126
|
+
else if (hint.rel != null) {
|
|
127
|
+
link += `; rel=${hint.rel}`;
|
|
128
|
+
}
|
|
129
|
+
if (hint.as != null) {
|
|
130
|
+
link += `; as=${hint.as}`;
|
|
131
|
+
}
|
|
132
|
+
if (hint.fetchPriority != null) {
|
|
133
|
+
link += `; fetchpriority=${hint.fetchPriority}`;
|
|
134
|
+
}
|
|
135
|
+
if (hint.crossOrigin != null) {
|
|
136
|
+
link += `; crossorigin=${hint.crossOrigin}`;
|
|
137
|
+
}
|
|
138
|
+
return link;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import { ResponseWriter } from './writer.js';
|
|
5
|
+
test('Writer writes hints', () => {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const server = createServer();
|
|
8
|
+
server.on('request', async (_req, res) => {
|
|
9
|
+
const writer = new ResponseWriter(res);
|
|
10
|
+
await writer.writeEarlyHints({
|
|
11
|
+
link: [
|
|
12
|
+
{
|
|
13
|
+
href: 'https://example.com/main.css',
|
|
14
|
+
as: 'stylesheet',
|
|
15
|
+
preload: true,
|
|
16
|
+
fetchPriority: 'high',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
href: 'https://example.com/main.js',
|
|
20
|
+
as: 'script',
|
|
21
|
+
preload: true,
|
|
22
|
+
fetchPriority: 'low',
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
writer.writeHead(200);
|
|
27
|
+
res.end();
|
|
28
|
+
});
|
|
29
|
+
server.on('error', (err) => {
|
|
30
|
+
reject(err);
|
|
31
|
+
});
|
|
32
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
33
|
+
const { port, address } = server.address();
|
|
34
|
+
const res = await fetch(`http://${address}:${port}`);
|
|
35
|
+
await res.text();
|
|
36
|
+
assert(res.headers.get('link'), `</https://example.com/main.css>; rel=preload; as=stylesheet; fetchpriority=high, `
|
|
37
|
+
+ `</https://example.com/main.js>; rel=preload; as=script; fetchpriority=low`);
|
|
38
|
+
server.close();
|
|
39
|
+
resolve();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type UnauthenticatedAuthContext = {
|
|
2
|
+
authenticated: false;
|
|
3
|
+
authKey: undefined;
|
|
4
|
+
};
|
|
5
|
+
export type AuthenticatedAuthContext = {
|
|
6
|
+
authenticated: true;
|
|
7
|
+
authKey: string;
|
|
8
|
+
};
|
|
9
|
+
export type AuthState = Record<string, any>;
|
|
10
|
+
export type AuthMiddlewareResponse<State extends AuthState = AuthState> = {
|
|
11
|
+
authKey?: string;
|
|
12
|
+
allowPublic?: boolean;
|
|
13
|
+
state: State;
|
|
14
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CacheContext, NextFn, Registry } from '../mod.js';
|
|
2
|
+
import type { CacheBuilder, CacheEntryDescriptor, CacheETagArgs, CacheETagInstanceArgs, CacheHTTPArgs, CacheHTTPInstanceArgs, CacheMeta, CacheStorage, CacheStoreArgs, CacheStoreInstanceArgs, UpstreamCache } from './types.js';
|
|
3
|
+
export declare class Cache implements CacheBuilder {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(registry: Registry, cacheMeta: CacheMeta, storage: CacheStorage, upstream?: UpstreamCache);
|
|
6
|
+
get registry(): Registry;
|
|
7
|
+
get meta(): CacheMeta;
|
|
8
|
+
get storage(): CacheStorage;
|
|
9
|
+
get upstream(): UpstreamCache;
|
|
10
|
+
/**
|
|
11
|
+
* Add HTTP headers to the request.
|
|
12
|
+
*/
|
|
13
|
+
http(args?: CacheHTTPArgs): CacheHTTPInstanceArgs;
|
|
14
|
+
/**
|
|
15
|
+
* Stores an etag value of the response and adds HTTP headers to the request.
|
|
16
|
+
* Requests made to an endpoint implementing etag cache can use `If-None-Match`
|
|
17
|
+
* or `If-Modified-Since` headers to test
|
|
18
|
+
*/
|
|
19
|
+
etag(args?: CacheETagArgs): CacheETagInstanceArgs;
|
|
20
|
+
/**
|
|
21
|
+
* Caches the body of the response, stores and etag and adds HTTP headers to the request.
|
|
22
|
+
*/
|
|
23
|
+
store(args?: CacheStoreArgs): CacheStoreInstanceArgs;
|
|
24
|
+
push(_req: Request): Promise<void>;
|
|
25
|
+
invalidate(_req: Request): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare class CacheMiddleware {
|
|
28
|
+
#private;
|
|
29
|
+
use(descriptors: CacheEntryDescriptor[], ctx: CacheContext, next: NextFn): Promise<void>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { EtagConditions } from './etag.js';
|
|
3
|
+
export class Cache {
|
|
4
|
+
#registry;
|
|
5
|
+
#cacheMeta;
|
|
6
|
+
#storage;
|
|
7
|
+
#upstream;
|
|
8
|
+
constructor(registry, cacheMeta, storage, upstream) {
|
|
9
|
+
this.#registry = registry;
|
|
10
|
+
this.#cacheMeta = cacheMeta;
|
|
11
|
+
this.#storage = storage;
|
|
12
|
+
this.#upstream = upstream;
|
|
13
|
+
}
|
|
14
|
+
get registry() {
|
|
15
|
+
return this.#registry;
|
|
16
|
+
}
|
|
17
|
+
get meta() {
|
|
18
|
+
return this.#cacheMeta;
|
|
19
|
+
}
|
|
20
|
+
get storage() {
|
|
21
|
+
return this.#storage;
|
|
22
|
+
}
|
|
23
|
+
get upstream() {
|
|
24
|
+
return this.#upstream;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Add HTTP headers to the request.
|
|
28
|
+
*/
|
|
29
|
+
http(args) {
|
|
30
|
+
return Object.assign(Object.create(null), args, {
|
|
31
|
+
strategy: 'http',
|
|
32
|
+
cache: this,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Stores an etag value of the response and adds HTTP headers to the request.
|
|
37
|
+
* Requests made to an endpoint implementing etag cache can use `If-None-Match`
|
|
38
|
+
* or `If-Modified-Since` headers to test
|
|
39
|
+
*/
|
|
40
|
+
etag(args) {
|
|
41
|
+
return Object.assign(Object.create(null), args, {
|
|
42
|
+
strategy: 'etag',
|
|
43
|
+
cache: this,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Caches the body of the response, stores and etag and adds HTTP headers to the request.
|
|
48
|
+
*/
|
|
49
|
+
store(args) {
|
|
50
|
+
return Object.assign(Object.create(null), args, {
|
|
51
|
+
strategy: 'store',
|
|
52
|
+
cache: this,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async push(_req) {
|
|
56
|
+
}
|
|
57
|
+
async invalidate(_req) {
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export class CacheMiddleware {
|
|
61
|
+
async use(descriptors, ctx, next) {
|
|
62
|
+
const descriptor = descriptors.find((descriptor) => {
|
|
63
|
+
const when = descriptor.args.when;
|
|
64
|
+
if (when == null) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
else if (when === 'always') {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
else if (when === 'public' && ctx.authKey == null) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
else if (when === 'private' && ctx.authKey != null) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
else if (typeof when === 'function') {
|
|
77
|
+
return when(ctx);
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
});
|
|
81
|
+
if (descriptor == null) {
|
|
82
|
+
return await next();
|
|
83
|
+
}
|
|
84
|
+
switch (descriptor.args.strategy) {
|
|
85
|
+
case 'http': {
|
|
86
|
+
await this.#useHTTP(descriptor, ctx, next);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case 'etag': {
|
|
90
|
+
await this.#useEtag(descriptor, ctx, next);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case 'store': {
|
|
94
|
+
await this.#useStore(descriptor, ctx, next);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* @todo Implement vary rules.
|
|
101
|
+
*/
|
|
102
|
+
#makeKey(descriptor) {
|
|
103
|
+
const { contentType } = descriptor;
|
|
104
|
+
const { version } = descriptor.args;
|
|
105
|
+
const { name } = descriptor.action;
|
|
106
|
+
const { url } = descriptor.request;
|
|
107
|
+
return 'v' + (version ?? 0) + '|' + name + '|' + contentType.toLowerCase() + '|' + url.toString();
|
|
108
|
+
}
|
|
109
|
+
#setHeaders(descriptor, ctx) {
|
|
110
|
+
const {} = descriptor.args;
|
|
111
|
+
}
|
|
112
|
+
async #useHTTP(descriptor, ctx, next) {
|
|
113
|
+
this.#setHeaders(descriptor, ctx);
|
|
114
|
+
await next();
|
|
115
|
+
}
|
|
116
|
+
async #useEtag(descriptor, ctx, next) {
|
|
117
|
+
const key = this.#makeKey(descriptor);
|
|
118
|
+
const rules = new EtagConditions(ctx.req.headers);
|
|
119
|
+
const resourceState = await descriptor.args.cache.meta.get(key);
|
|
120
|
+
if (resourceState.type === 'cache-hit') {
|
|
121
|
+
if (rules.ifMatch(resourceState.etag)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
else if (!rules.ifNoneMatch(resourceState.etag)) {
|
|
125
|
+
ctx.hit = true;
|
|
126
|
+
ctx.status = 304;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
this.#setHeaders(descriptor, ctx);
|
|
131
|
+
await next();
|
|
132
|
+
}
|
|
133
|
+
async #useStore(descriptor, ctx, next) {
|
|
134
|
+
const key = this.#makeKey(descriptor);
|
|
135
|
+
let resourceState;
|
|
136
|
+
if (typeof descriptor.args.cache.meta.getOrLock === 'function' &&
|
|
137
|
+
descriptor.args.lock) {
|
|
138
|
+
try {
|
|
139
|
+
resourceState = await descriptor.args.cache.meta.getOrLock(key);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
resourceState = await descriptor.args.cache.meta.get(key);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
resourceState = await descriptor.args.cache.meta.get(key);
|
|
147
|
+
}
|
|
148
|
+
if (resourceState?.type === 'cache-hit') {
|
|
149
|
+
if (this.#isNotModified(ctx.req.headers, resourceState.etag)) {
|
|
150
|
+
ctx.hit = true;
|
|
151
|
+
ctx.status = 304;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (resourceState.hasContent) {
|
|
155
|
+
try {
|
|
156
|
+
ctx.hit = true;
|
|
157
|
+
ctx.status = resourceState.status;
|
|
158
|
+
ctx.body = await descriptor.args.cache.storage.get(key);
|
|
159
|
+
for (const [key, value] of resourceState.headers.entries()) {
|
|
160
|
+
ctx.headers.set(key, value);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
console.log(err);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
this.#setHeaders(descriptor, ctx);
|
|
171
|
+
await next();
|
|
172
|
+
const body = await new Response(ctx.body).blob();
|
|
173
|
+
const etag = await this.#createEtag(body);
|
|
174
|
+
ctx.etag = etag;
|
|
175
|
+
ctx.headers.set('Etag', etag);
|
|
176
|
+
await descriptor.args.cache.meta.set(key, {
|
|
177
|
+
key,
|
|
178
|
+
authKey: ctx.authKey,
|
|
179
|
+
iri: ctx.url,
|
|
180
|
+
status: ctx.status ?? 200,
|
|
181
|
+
hasContent: ctx.body != null,
|
|
182
|
+
headers: ctx.headers,
|
|
183
|
+
contentType: ctx.contentType,
|
|
184
|
+
etag,
|
|
185
|
+
});
|
|
186
|
+
if (ctx.body != null) {
|
|
187
|
+
await descriptor.args.cache.storage.set(key, body);
|
|
188
|
+
}
|
|
189
|
+
if (resourceState.type === 'locked-cache-miss') {
|
|
190
|
+
await resourceState.release();
|
|
191
|
+
}
|
|
192
|
+
if (this.#isNotModified(ctx.req.headers, etag)) {
|
|
193
|
+
ctx.hit = true;
|
|
194
|
+
ctx.status = 304;
|
|
195
|
+
ctx.body = null;
|
|
196
|
+
ctx.headers.delete('Etag');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (resourceState.type === 'locked-cache-miss') {
|
|
202
|
+
await resourceState.release();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Returns true if the request has conditional headers
|
|
208
|
+
* which should result in a not modified response.
|
|
209
|
+
*/
|
|
210
|
+
#isNotModified(headers, etag) {
|
|
211
|
+
const rules = new EtagConditions(headers);
|
|
212
|
+
return rules.isNotModified(etag);
|
|
213
|
+
}
|
|
214
|
+
async #createEtag(body, weak = true) {
|
|
215
|
+
const buff = await body.bytes();
|
|
216
|
+
const hash = createHash('sha1').update(buff).digest('hex');
|
|
217
|
+
const quoted = `"${hash}"`;
|
|
218
|
+
return weak ? `W/${quoted}` : quoted;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class EtagConditions {
|
|
2
|
+
headers: Headers;
|
|
3
|
+
constructor(headers: Headers);
|
|
4
|
+
/**
|
|
5
|
+
* Returns true if the representation is not modified based of
|
|
6
|
+
* conditional headers and the value of the current representation's
|
|
7
|
+
* etag. True results should result in a 304 response.
|
|
8
|
+
*
|
|
9
|
+
* @param representationEtag - The current etag value for this representation.
|
|
10
|
+
*/
|
|
11
|
+
isNotModified(representationEtag?: string): boolean;
|
|
12
|
+
ifMatch(representationEtag?: string): boolean;
|
|
13
|
+
ifNoneMatch(representationEtag?: string): boolean;
|
|
14
|
+
ifModifiedSince(): never;
|
|
15
|
+
ifUnmodifiedSince(): never;
|
|
16
|
+
ifRange(): never;
|
|
17
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export class EtagConditions {
|
|
2
|
+
headers;
|
|
3
|
+
constructor(headers) {
|
|
4
|
+
this.headers = headers;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Returns true if the representation is not modified based of
|
|
8
|
+
* conditional headers and the value of the current representation's
|
|
9
|
+
* etag. True results should result in a 304 response.
|
|
10
|
+
*
|
|
11
|
+
* @param representationEtag - The current etag value for this representation.
|
|
12
|
+
*/
|
|
13
|
+
isNotModified(representationEtag) {
|
|
14
|
+
if (this.headers.has('If-Match')) {
|
|
15
|
+
return this.ifMatch(representationEtag);
|
|
16
|
+
}
|
|
17
|
+
else if (this.headers.has('If-None-Match')) {
|
|
18
|
+
return !this.ifNoneMatch(representationEtag);
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
ifMatch(representationEtag) {
|
|
23
|
+
let etag;
|
|
24
|
+
const header = this.headers.get('If-Match');
|
|
25
|
+
if (header == null) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
else if (header.trim() === '*') {
|
|
29
|
+
return representationEtag != null;
|
|
30
|
+
}
|
|
31
|
+
const etags = header.split?.(',');
|
|
32
|
+
if (etags.length === 0) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
else if (representationEtag == null) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
for (let i = 0; i < etags.length; i++) {
|
|
39
|
+
etag = etags[i].trim();
|
|
40
|
+
// If-Match ignores weak etags
|
|
41
|
+
if (etag.startsWith('W\/')) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
else if (etag === representationEtag) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
ifNoneMatch(representationEtag) {
|
|
51
|
+
let etag;
|
|
52
|
+
const header = this.headers.get('If-None-Match');
|
|
53
|
+
if (header == null || representationEtag == null) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
const etags = header.split?.(',');
|
|
57
|
+
if (etags.length === 0) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (etags[0] === '*') {
|
|
61
|
+
return representationEtag == null;
|
|
62
|
+
}
|
|
63
|
+
for (let i = 0; i < etags.length; i++) {
|
|
64
|
+
etag = etags[i].trim();
|
|
65
|
+
if (!etag.startsWith('W\/')) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
else if (etag === representationEtag) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
ifModifiedSince() {
|
|
75
|
+
throw new Error('Not implemented');
|
|
76
|
+
}
|
|
77
|
+
ifUnmodifiedSince() {
|
|
78
|
+
throw new Error('Not implemented');
|
|
79
|
+
}
|
|
80
|
+
ifRange() {
|
|
81
|
+
throw new Error('Not implemented');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { EtagConditions } from './etag.js';
|
|
4
|
+
test.describe('conditions.ifMatch()', () => {
|
|
5
|
+
test('returns true when the header has the value of "*" and an etag is present', () => {
|
|
6
|
+
const conditions = new EtagConditions(new Headers({
|
|
7
|
+
'If-Match': '*',
|
|
8
|
+
}));
|
|
9
|
+
assert(conditions.ifMatch('"xxxx"'));
|
|
10
|
+
});
|
|
11
|
+
test('returns false when the header has the value of "*" and an etag is not present', () => {
|
|
12
|
+
const conditions = new EtagConditions(new Headers({
|
|
13
|
+
'If-Match': '*',
|
|
14
|
+
}));
|
|
15
|
+
assert(!conditions.ifMatch());
|
|
16
|
+
});
|
|
17
|
+
test('returns false when the header provides an etag and an etag is not present', () => {
|
|
18
|
+
const conditions = new EtagConditions(new Headers({
|
|
19
|
+
'If-Match': '"xxxx"',
|
|
20
|
+
}));
|
|
21
|
+
assert(!conditions.ifMatch());
|
|
22
|
+
});
|
|
23
|
+
test('returns false when the header provides an etag and a different etag is present', () => {
|
|
24
|
+
const conditions = new EtagConditions(new Headers({
|
|
25
|
+
'If-Match': '"xxxx"',
|
|
26
|
+
}));
|
|
27
|
+
assert(!conditions.ifMatch('"yyyy"'));
|
|
28
|
+
});
|
|
29
|
+
test('returns false when the header provides a matching but weak etag', () => {
|
|
30
|
+
const conditions = new EtagConditions(new Headers({
|
|
31
|
+
'If-Match': 'W\/"xxxx"',
|
|
32
|
+
}));
|
|
33
|
+
assert(!conditions.ifMatch('W\/"xxxx"'));
|
|
34
|
+
});
|
|
35
|
+
test('returns true when the header provides a matching etag', () => {
|
|
36
|
+
const conditions = new EtagConditions(new Headers({
|
|
37
|
+
'If-Match': '"xxxx"',
|
|
38
|
+
}));
|
|
39
|
+
assert(conditions.ifMatch('"xxxx"'));
|
|
40
|
+
});
|
|
41
|
+
test('returns true when the header provides a list containing a matching etag', () => {
|
|
42
|
+
const conditions = new EtagConditions(new Headers({
|
|
43
|
+
'If-Match': '"xxxx", "yyyy", "zzzz"',
|
|
44
|
+
}));
|
|
45
|
+
assert(conditions.ifMatch('"yyyy"'));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
test.describe('conditions.ifNoneMatch()', () => {
|
|
49
|
+
test('returns true when the header has the value of "*" and an etag is not present', () => {
|
|
50
|
+
const conditions = new EtagConditions(new Headers({
|
|
51
|
+
'If-None-Match': '*',
|
|
52
|
+
}));
|
|
53
|
+
assert(conditions.ifNoneMatch());
|
|
54
|
+
});
|
|
55
|
+
test('returns false when the header has the value of "*" and an etag is present', () => {
|
|
56
|
+
const conditions = new EtagConditions(new Headers({
|
|
57
|
+
'If-None-Match': '*',
|
|
58
|
+
}));
|
|
59
|
+
assert(!conditions.ifNoneMatch('W\/"xxxx"'));
|
|
60
|
+
});
|
|
61
|
+
test('returns true when the header provides an etag and no representation etag is present', () => {
|
|
62
|
+
const conditions = new EtagConditions(new Headers({
|
|
63
|
+
'If-None-Match': 'W\/"xxxx"',
|
|
64
|
+
}));
|
|
65
|
+
assert(conditions.ifNoneMatch());
|
|
66
|
+
});
|
|
67
|
+
test('returns true when the header provides a non-matching etag', () => {
|
|
68
|
+
const conditions = new EtagConditions(new Headers({
|
|
69
|
+
'If-None-Match': 'W\/"xxxx"',
|
|
70
|
+
}));
|
|
71
|
+
assert(conditions.ifNoneMatch('W\/"yyyy"'));
|
|
72
|
+
});
|
|
73
|
+
test('returns true when the header provides a non-matching but strong etag', () => {
|
|
74
|
+
const conditions = new EtagConditions(new Headers({
|
|
75
|
+
'If-None-Match': '"yyyy"',
|
|
76
|
+
}));
|
|
77
|
+
assert(conditions.ifNoneMatch('"xxxx"'));
|
|
78
|
+
});
|
|
79
|
+
test('returns false when the header provides a matching etag', () => {
|
|
80
|
+
const conditions = new EtagConditions(new Headers({
|
|
81
|
+
'If-None-Match': 'W\/"xxxx"',
|
|
82
|
+
}));
|
|
83
|
+
assert(!conditions.ifNoneMatch('W\/"xxxx"'));
|
|
84
|
+
});
|
|
85
|
+
test('returns false when the header provides a list containing a matching etag', () => {
|
|
86
|
+
const conditions = new EtagConditions(new Headers({
|
|
87
|
+
'If-None-Match': 'W\/"xxxx", W\/"yyyy", W\/"zzzz"',
|
|
88
|
+
}));
|
|
89
|
+
assert(!conditions.ifNoneMatch('W\/"yyyy"'));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CacheDetails, CacheHitHandle, CacheMeta, CacheStorage, CacheMissHandle } from './types.js';
|
|
2
|
+
export declare class InMemoryCacheMeta implements CacheMeta {
|
|
3
|
+
#private;
|
|
4
|
+
get(key: string): Promise<CacheHitHandle | CacheMissHandle>;
|
|
5
|
+
set(key: string, details: CacheDetails): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare class InMemoryCacheStorage implements CacheStorage {
|
|
8
|
+
#private;
|
|
9
|
+
get(key: string): Blob;
|
|
10
|
+
set(key: string, value: Blob): void;
|
|
11
|
+
invalidate(key: string): void;
|
|
12
|
+
}
|