@snail-js/api 0.1.27 → 1.0.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/LICENSE +21 -0
- package/README.md +125 -605
- package/dist/adapter/plain.d.ts +12 -0
- package/dist/adapter/plain.d.ts.map +1 -0
- package/dist/adapter/react.d.ts +14 -0
- package/dist/adapter/react.d.ts.map +1 -0
- package/dist/adapter/registry.d.ts +16 -0
- package/dist/adapter/registry.d.ts.map +1 -0
- package/dist/adapter/vue.d.ts +15 -0
- package/dist/adapter/vue.d.ts.map +1 -0
- package/dist/chunks/custom-UchOSg_s.js +466 -0
- package/dist/chunks/custom-UchOSg_s.js.map +1 -0
- package/dist/chunks/download-lBC7oOTT.js +160 -0
- package/dist/chunks/download-lBC7oOTT.js.map +1 -0
- package/dist/chunks/object-Ct0w-h4i.js +74 -0
- package/dist/chunks/object-Ct0w-h4i.js.map +1 -0
- package/dist/chunks/plugin-D3RDffDG.js +742 -0
- package/dist/chunks/plugin-D3RDffDG.js.map +1 -0
- package/dist/chunks/react-C1em-gxy.js +62 -0
- package/dist/chunks/react-C1em-gxy.js.map +1 -0
- package/dist/chunks/request-qeRhihEF.js +40 -0
- package/dist/chunks/request-qeRhihEF.js.map +1 -0
- package/dist/chunks/response-D-ubkAFl.js +127 -0
- package/dist/chunks/response-D-ubkAFl.js.map +1 -0
- package/dist/chunks/use-download-FAjtdJ58.js +1816 -0
- package/dist/chunks/use-download-FAjtdJ58.js.map +1 -0
- package/dist/chunks/vue-Dd9EhkQ3.js +34 -0
- package/dist/chunks/vue-Dd9EhkQ3.js.map +1 -0
- package/dist/core/args.d.ts +54 -0
- package/dist/core/args.d.ts.map +1 -0
- package/dist/core/connection.d.ts +15 -0
- package/dist/core/connection.d.ts.map +1 -0
- package/dist/core/context.d.ts +124 -0
- package/dist/core/context.d.ts.map +1 -0
- package/dist/core/http-stream.d.ts +28 -0
- package/dist/core/http-stream.d.ts.map +1 -0
- package/dist/core/logger.d.ts +22 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/metadata.d.ts +92 -0
- package/dist/core/metadata.d.ts.map +1 -0
- package/dist/core/metadata.keys.d.ts +41 -0
- package/dist/core/metadata.keys.d.ts.map +1 -0
- package/dist/core/method.d.ts +177 -0
- package/dist/core/method.d.ts.map +1 -0
- package/dist/core/plugin-manager.d.ts +106 -0
- package/dist/core/plugin-manager.d.ts.map +1 -0
- package/dist/core/plugin.d.ts +113 -0
- package/dist/core/plugin.d.ts.map +1 -0
- package/dist/core/reconnect.d.ts +22 -0
- package/dist/core/reconnect.d.ts.map +1 -0
- package/dist/core/resolve-stream.d.ts +45 -0
- package/dist/core/resolve-stream.d.ts.map +1 -0
- package/dist/core/resolve.d.ts +50 -0
- package/dist/core/resolve.d.ts.map +1 -0
- package/dist/core/response.d.ts +60 -0
- package/dist/core/response.d.ts.map +1 -0
- package/dist/core/server.d.ts +132 -0
- package/dist/core/server.d.ts.map +1 -0
- package/dist/core/sse.d.ts +27 -0
- package/dist/core/sse.d.ts.map +1 -0
- package/dist/core/state-bag.d.ts +36 -0
- package/dist/core/state-bag.d.ts.map +1 -0
- package/dist/core/websocket.d.ts +29 -0
- package/dist/core/websocket.d.ts.map +1 -0
- package/dist/decorators/api.d.ts +24 -4
- package/dist/decorators/api.d.ts.map +1 -0
- package/dist/decorators/args.d.ts +77 -14
- package/dist/decorators/args.d.ts.map +1 -0
- package/dist/decorators/custom.d.ts +81 -0
- package/dist/decorators/custom.d.ts.map +1 -0
- package/dist/decorators/header.d.ts +22 -0
- package/dist/decorators/header.d.ts.map +1 -0
- package/dist/decorators/index.d.ts +17 -0
- package/dist/decorators/index.d.ts.map +1 -0
- package/dist/decorators/methods.d.ts +33 -0
- package/dist/decorators/methods.d.ts.map +1 -0
- package/dist/decorators/progress.d.ts +24 -6
- package/dist/decorators/progress.d.ts.map +1 -0
- package/dist/decorators/server.d.ts +28 -7
- package/dist/decorators/server.d.ts.map +1 -0
- package/dist/decorators/stream.d.ts +113 -0
- package/dist/decorators/stream.d.ts.map +1 -0
- package/dist/default/options.d.ts +19 -0
- package/dist/default/options.d.ts.map +1 -0
- package/dist/error/base.d.ts +20 -0
- package/dist/error/base.d.ts.map +1 -0
- package/dist/error/decorator.d.ts +14 -0
- package/dist/error/decorator.d.ts.map +1 -0
- package/dist/error/hook.d.ts +14 -0
- package/dist/error/hook.d.ts.map +1 -0
- package/dist/error/index.d.ts +8 -0
- package/dist/error/index.d.ts.map +1 -0
- package/dist/error/options.d.ts +12 -0
- package/dist/error/options.d.ts.map +1 -0
- package/dist/error/plugin.d.ts +11 -0
- package/dist/error/plugin.d.ts.map +1 -0
- package/dist/error/request.d.ts +29 -0
- package/dist/error/request.d.ts.map +1 -0
- package/dist/error/response.d.ts +36 -0
- package/dist/error/response.d.ts.map +1 -0
- package/dist/index.d.ts +56 -11
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2034 -0
- package/dist/index.js.map +1 -0
- package/dist/locale/en.d.ts +5 -0
- package/dist/locale/en.d.ts.map +1 -0
- package/dist/locale/index.d.ts +57 -0
- package/dist/locale/index.d.ts.map +1 -0
- package/dist/locale/types.d.ts +11 -0
- package/dist/locale/types.d.ts.map +1 -0
- package/dist/locale/zh.d.ts +5 -0
- package/dist/locale/zh.d.ts.map +1 -0
- package/dist/plugins/cache/adapters/indexeddb.d.ts +52 -0
- package/dist/plugins/cache/adapters/indexeddb.d.ts.map +1 -0
- package/dist/plugins/cache/adapters/memory.d.ts +60 -0
- package/dist/plugins/cache/adapters/memory.d.ts.map +1 -0
- package/dist/plugins/cache/adapters/web-storage.d.ts +43 -0
- package/dist/plugins/cache/adapters/web-storage.d.ts.map +1 -0
- package/dist/plugins/cache/decorators.d.ts +62 -0
- package/dist/plugins/cache/decorators.d.ts.map +1 -0
- package/dist/plugins/cache/index.d.ts +41 -0
- package/dist/plugins/cache/index.d.ts.map +1 -0
- package/dist/plugins/cache/key.d.ts +48 -0
- package/dist/plugins/cache/key.d.ts.map +1 -0
- package/dist/plugins/cache/manager.d.ts +124 -0
- package/dist/plugins/cache/manager.d.ts.map +1 -0
- package/dist/plugins/cache/plugin.d.ts +64 -0
- package/dist/plugins/cache/plugin.d.ts.map +1 -0
- package/dist/plugins/cache/type.d.ts +116 -0
- package/dist/plugins/cache/type.d.ts.map +1 -0
- package/dist/plugins/index.d.ts +75 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +2265 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/interceptor/decorators.d.ts +44 -0
- package/dist/plugins/interceptor/decorators.d.ts.map +1 -0
- package/dist/plugins/interceptor/index.d.ts +31 -0
- package/dist/plugins/interceptor/index.d.ts.map +1 -0
- package/dist/plugins/interceptor/manager.d.ts +27 -0
- package/dist/plugins/interceptor/manager.d.ts.map +1 -0
- package/dist/plugins/interceptor/plugin.d.ts +65 -0
- package/dist/plugins/interceptor/plugin.d.ts.map +1 -0
- package/dist/plugins/interceptor/type.d.ts +45 -0
- package/dist/plugins/interceptor/type.d.ts.map +1 -0
- package/dist/plugins/pool/index.d.ts +26 -0
- package/dist/plugins/pool/index.d.ts.map +1 -0
- package/dist/plugins/pool/plugin.d.ts +83 -0
- package/dist/plugins/pool/plugin.d.ts.map +1 -0
- package/dist/plugins/pool/scheduler.d.ts +153 -0
- package/dist/plugins/pool/scheduler.d.ts.map +1 -0
- package/dist/plugins/pool/type.d.ts +38 -0
- package/dist/plugins/pool/type.d.ts.map +1 -0
- package/dist/plugins/react/index.d.ts +20 -0
- package/dist/plugins/react/index.d.ts.map +1 -0
- package/dist/plugins/react/index.js +145 -0
- package/dist/plugins/react/index.js.map +1 -0
- package/dist/plugins/react/plugin.d.ts +33 -0
- package/dist/plugins/react/plugin.d.ts.map +1 -0
- package/dist/plugins/react/type.d.ts +32 -0
- package/dist/plugins/react/type.d.ts.map +1 -0
- package/dist/plugins/transform/decorators.d.ts +54 -0
- package/dist/plugins/transform/decorators.d.ts.map +1 -0
- package/dist/plugins/transform/hydrate.d.ts +47 -0
- package/dist/plugins/transform/hydrate.d.ts.map +1 -0
- package/dist/plugins/transform/index.d.ts +41 -0
- package/dist/plugins/transform/index.d.ts.map +1 -0
- package/dist/plugins/transform/plugin.d.ts +20 -0
- package/dist/plugins/transform/plugin.d.ts.map +1 -0
- package/dist/plugins/transform/type.d.ts +67 -0
- package/dist/plugins/transform/type.d.ts.map +1 -0
- package/dist/plugins/validate/decorators.d.ts +24 -0
- package/dist/plugins/validate/decorators.d.ts.map +1 -0
- package/dist/plugins/validate/index.d.ts +40 -0
- package/dist/plugins/validate/index.d.ts.map +1 -0
- package/dist/plugins/validate/plugin.d.ts +13 -0
- package/dist/plugins/validate/plugin.d.ts.map +1 -0
- package/dist/plugins/validate/type.d.ts +54 -0
- package/dist/plugins/validate/type.d.ts.map +1 -0
- package/dist/plugins/version/decorators.d.ts +22 -0
- package/dist/plugins/version/decorators.d.ts.map +1 -0
- package/dist/plugins/version/index.d.ts +23 -0
- package/dist/plugins/version/index.d.ts.map +1 -0
- package/dist/plugins/version/plugin.d.ts +13 -0
- package/dist/plugins/version/plugin.d.ts.map +1 -0
- package/dist/plugins/version/type.d.ts +47 -0
- package/dist/plugins/version/type.d.ts.map +1 -0
- package/dist/plugins/vue/index.d.ts +21 -0
- package/dist/plugins/vue/index.d.ts.map +1 -0
- package/dist/plugins/vue/index.js +114 -0
- package/dist/plugins/vue/index.js.map +1 -0
- package/dist/plugins/vue/plugin.d.ts +15 -0
- package/dist/plugins/vue/plugin.d.ts.map +1 -0
- package/dist/plugins/vue/type.d.ts +34 -0
- package/dist/plugins/vue/type.d.ts.map +1 -0
- package/dist/strategies/index.d.ts +2 -0
- package/dist/strategies/index.d.ts.map +1 -0
- package/dist/strategies/index.js +29 -0
- package/dist/strategies/index.js.map +1 -0
- package/dist/strategies/plain.d.ts +2 -0
- package/dist/strategies/plain.d.ts.map +1 -0
- package/dist/strategies/plain.js +23 -0
- package/dist/strategies/plain.js.map +1 -0
- package/dist/strategies/react.d.ts +2 -0
- package/dist/strategies/react.d.ts.map +1 -0
- package/dist/strategies/react.js +30 -0
- package/dist/strategies/react.js.map +1 -0
- package/dist/strategies/shared/adapter.d.ts +23 -0
- package/dist/strategies/shared/adapter.d.ts.map +1 -0
- package/dist/strategies/shared/dom.d.ts +53 -0
- package/dist/strategies/shared/dom.d.ts.map +1 -0
- package/dist/strategies/shared/error.d.ts +30 -0
- package/dist/strategies/shared/error.d.ts.map +1 -0
- package/dist/strategies/shared/method.d.ts +51 -0
- package/dist/strategies/shared/method.d.ts.map +1 -0
- package/dist/strategies/shared/public.d.ts +33 -0
- package/dist/strategies/shared/public.d.ts.map +1 -0
- package/dist/strategies/shared/queue.d.ts +30 -0
- package/dist/strategies/shared/queue.d.ts.map +1 -0
- package/dist/strategies/shared/retry.d.ts +51 -0
- package/dist/strategies/shared/retry.d.ts.map +1 -0
- package/dist/strategies/shared/state.d.ts +117 -0
- package/dist/strategies/shared/state.d.ts.map +1 -0
- package/dist/strategies/shared/timing.d.ts +53 -0
- package/dist/strategies/shared/timing.d.ts.map +1 -0
- package/dist/strategies/shared/watcher.d.ts +37 -0
- package/dist/strategies/shared/watcher.d.ts.map +1 -0
- package/dist/strategies/use-auto-request.d.ts +77 -0
- package/dist/strategies/use-auto-request.d.ts.map +1 -0
- package/dist/strategies/use-download.d.ts +103 -0
- package/dist/strategies/use-download.d.ts.map +1 -0
- package/dist/strategies/use-fetcher.d.ts +52 -0
- package/dist/strategies/use-fetcher.d.ts.map +1 -0
- package/dist/strategies/use-pagination.d.ts +101 -0
- package/dist/strategies/use-pagination.d.ts.map +1 -0
- package/dist/strategies/use-request.d.ts +50 -0
- package/dist/strategies/use-request.d.ts.map +1 -0
- package/dist/strategies/use-retriable-request.d.ts +46 -0
- package/dist/strategies/use-retriable-request.d.ts.map +1 -0
- package/dist/strategies/use-sse.d.ts +103 -0
- package/dist/strategies/use-sse.d.ts.map +1 -0
- package/dist/strategies/use-token-auth.d.ts +88 -0
- package/dist/strategies/use-token-auth.d.ts.map +1 -0
- package/dist/strategies/use-uploader.d.ts +88 -0
- package/dist/strategies/use-uploader.d.ts.map +1 -0
- package/dist/strategies/use-watcher.d.ts +69 -0
- package/dist/strategies/use-watcher.d.ts.map +1 -0
- package/dist/typings/adapter.d.ts +61 -0
- package/dist/typings/adapter.d.ts.map +1 -0
- package/dist/typings/api.d.ts +122 -0
- package/dist/typings/api.d.ts.map +1 -0
- package/dist/typings/args.d.ts +51 -0
- package/dist/typings/args.d.ts.map +1 -0
- package/dist/typings/index.d.ts +8 -12
- package/dist/typings/index.d.ts.map +1 -0
- package/dist/typings/meta.d.ts +44 -0
- package/dist/typings/meta.d.ts.map +1 -0
- package/dist/typings/plugin.d.ts +114 -0
- package/dist/typings/plugin.d.ts.map +1 -0
- package/dist/typings/response.d.ts +118 -0
- package/dist/typings/response.d.ts.map +1 -0
- package/dist/typings/server.d.ts +83 -0
- package/dist/typings/server.d.ts.map +1 -0
- package/dist/typings/stream.d.ts +150 -0
- package/dist/typings/stream.d.ts.map +1 -0
- package/dist/utils/download.d.ts +97 -0
- package/dist/utils/download.d.ts.map +1 -0
- package/dist/utils/emitter.d.ts +36 -0
- package/dist/utils/emitter.d.ts.map +1 -0
- package/dist/utils/index.d.ts +6 -1
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/is.d.ts +22 -0
- package/dist/utils/is.d.ts.map +1 -0
- package/dist/utils/object.d.ts +44 -0
- package/dist/utils/object.d.ts.map +1 -0
- package/dist/utils/url.d.ts +44 -0
- package/dist/utils/url.d.ts.map +1 -0
- package/package.json +90 -27
- package/README_EN.md +0 -607
- package/dist/cache/index.d.ts +0 -9
- package/dist/cache/indexDBCache.d.ts +0 -13
- package/dist/cache/localstorageCache.d.ts +0 -10
- package/dist/cache/memoryCache.d.ts +0 -11
- package/dist/core/index.d.ts +0 -4
- package/dist/core/snailApi.d.ts +0 -22
- package/dist/core/snailMethod.d.ts +0 -49
- package/dist/core/snailServer.d.ts +0 -35
- package/dist/core/snailSse.d.ts +0 -20
- package/dist/decorators/cache.d.ts +0 -15
- package/dist/decorators/method.d.ts +0 -38
- package/dist/decorators/sse.d.ts +0 -30
- package/dist/decorators/strategy.d.ts +0 -10
- package/dist/decorators/versioning.d.ts +0 -16
- package/dist/eventEmmit/index.d.ts +0 -1
- package/dist/eventEmmit/snail.event.d.ts +0 -10
- package/dist/snail-api.js +0 -4868
- package/dist/snail-api.umd.cjs +0 -4872
- package/dist/strategies/jwtStrategy.d.ts +0 -4
- package/dist/typings/api.option.d.ts +0 -10
- package/dist/typings/apiProxy.d.ts +0 -8
- package/dist/typings/cache.management.option.d.ts +0 -25
- package/dist/typings/cache.type.d.ts +0 -23
- package/dist/typings/content.type.d.ts +0 -5
- package/dist/typings/request.body.d.ts +0 -3
- package/dist/typings/response.data.d.ts +0 -13
- package/dist/typings/snail.event.d.ts +0 -6
- package/dist/typings/snail.method.d.ts +0 -14
- package/dist/typings/snail.option.d.ts +0 -16
- package/dist/typings/sse.d.ts +0 -15
- package/dist/typings/strategy.d.ts +0 -5
- package/dist/typings/versioning.option.d.ts +0 -30
- package/dist/utils/function.d.ts +0 -31
- package/dist/versioning/index.d.ts +0 -1
- package/dist/versioning/versioning.d.ts +0 -12
|
@@ -0,0 +1,2265 @@
|
|
|
1
|
+
import { A as stableStringify, B as SnailError, L as isPlainObject, T as isAbsoluteURL, b as t, k as shortHash, n as createPlugin, z as SnailDecoratorError } from "../chunks/plugin-D3RDffDG.js";
|
|
2
|
+
import { n as deferred } from "../chunks/object-Ct0w-h4i.js";
|
|
3
|
+
import { i as looksLikeEnvelope, o as unwrapEnvelope, r as coerceJSONStringBody } from "../chunks/response-D-ubkAFl.js";
|
|
4
|
+
import { C as getMetadata, V as createLogger, _ as SnailPluginError, a as customMetadataKey, b as collectMethodKeys, i as createPropertyDecorator, n as createMethodDecorator, o as getClassMetadata, s as getMethodMetadata, t as createClassDecorator, x as defineMetadata } from "../chunks/custom-UchOSg_s.js";
|
|
5
|
+
import { AxiosHeaders } from "axios";
|
|
6
|
+
//#region src/plugins/cache/adapters/indexeddb.ts
|
|
7
|
+
/**
|
|
8
|
+
* L2 adapter over IndexedDB.
|
|
9
|
+
*
|
|
10
|
+
* ## Why every method degrades instead of rejecting
|
|
11
|
+
*
|
|
12
|
+
* IndexedDB is absent in Node and can be unavailable in private browsing modes.
|
|
13
|
+
* An L2 store is an optimisation: a request must succeed (and stay correct) when
|
|
14
|
+
* it is missing. Every public method therefore resolves to "nothing stored" or a
|
|
15
|
+
* no-op rather than throwing, and the connection is opened lazily so importing
|
|
16
|
+
* this module — or constructing the adapter — never touches a global.
|
|
17
|
+
*
|
|
18
|
+
* ## Why the timeout is stored per record, not as an index
|
|
19
|
+
*
|
|
20
|
+
* A TTL index would make reads of an expired key need a second query anyway. The
|
|
21
|
+
* record carries its own `expiresAt`, which makes the freshness check a pure
|
|
22
|
+
* in-memory comparison on the value already fetched.
|
|
23
|
+
*/
|
|
24
|
+
var IndexedDBCacheAdapter = class {
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.databaseName = options.databaseName ?? "snail-js-api";
|
|
27
|
+
this.storeName = options.storeName ?? "cache";
|
|
28
|
+
this.version = options.version ?? 1;
|
|
29
|
+
}
|
|
30
|
+
/** `false` when the environment has no IndexedDB at all. */
|
|
31
|
+
get available() {
|
|
32
|
+
try {
|
|
33
|
+
return typeof globalThis.indexedDB !== "undefined";
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async get(key) {
|
|
39
|
+
try {
|
|
40
|
+
const database = await this.open();
|
|
41
|
+
const record = await this.read(database, key);
|
|
42
|
+
if (record === void 0) return void 0;
|
|
43
|
+
if (record.expiresAt !== 0 && record.expiresAt <= Date.now()) {
|
|
44
|
+
await this.delete(key);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
return record.value;
|
|
48
|
+
} catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async set(key, value, ttlSeconds) {
|
|
53
|
+
if (!this.available) return;
|
|
54
|
+
const database = await this.open();
|
|
55
|
+
const record = {
|
|
56
|
+
value,
|
|
57
|
+
expiresAt: ttlSeconds > 0 ? Date.now() + ttlSeconds * 1e3 : 0
|
|
58
|
+
};
|
|
59
|
+
await this.transaction(database, "readwrite", (store) => {
|
|
60
|
+
store.put(record, key);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async delete(key) {
|
|
64
|
+
try {
|
|
65
|
+
const database = await this.open();
|
|
66
|
+
await this.transaction(database, "readwrite", (store) => {
|
|
67
|
+
store.delete(key);
|
|
68
|
+
});
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
async clear() {
|
|
72
|
+
try {
|
|
73
|
+
const database = await this.open();
|
|
74
|
+
await this.transaction(database, "readwrite", (store) => {
|
|
75
|
+
store.clear();
|
|
76
|
+
});
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
async keys() {
|
|
80
|
+
try {
|
|
81
|
+
return (await requestToPromise((await this.open()).transaction(this.storeName, "readonly").objectStore(this.storeName).getAllKeys())).map(String);
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Open (once) and memoise the database connection.
|
|
88
|
+
*
|
|
89
|
+
* The in-flight promise is stored before it settles, so two concurrent
|
|
90
|
+
* requests cannot open the same database twice and leak a connection.
|
|
91
|
+
*/
|
|
92
|
+
open() {
|
|
93
|
+
if (this.database) return Promise.resolve(this.database);
|
|
94
|
+
if (this.opening) return this.opening;
|
|
95
|
+
this.opening = new Promise((resolve, reject) => {
|
|
96
|
+
const factory = globalThis.indexedDB;
|
|
97
|
+
if (!factory) {
|
|
98
|
+
reject(/* @__PURE__ */ new Error("[snail] IndexedDB is not available in this environment"));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const request = factory.open(this.databaseName, this.version);
|
|
102
|
+
request.onupgradeneeded = () => {
|
|
103
|
+
const database = request.result;
|
|
104
|
+
if (!database.objectStoreNames.contains(this.storeName)) database.createObjectStore(this.storeName);
|
|
105
|
+
};
|
|
106
|
+
request.onsuccess = () => {
|
|
107
|
+
this.database = request.result;
|
|
108
|
+
resolve(request.result);
|
|
109
|
+
};
|
|
110
|
+
request.onerror = () => reject(request.error);
|
|
111
|
+
request.onblocked = () => reject(/* @__PURE__ */ new Error("[snail] IndexedDB upgrade is blocked"));
|
|
112
|
+
}).finally(() => {
|
|
113
|
+
this.opening = void 0;
|
|
114
|
+
});
|
|
115
|
+
return this.opening;
|
|
116
|
+
}
|
|
117
|
+
async read(database, key) {
|
|
118
|
+
const value = await requestToPromise(database.transaction(this.storeName, "readonly").objectStore(this.storeName).get(key));
|
|
119
|
+
if (value === void 0 || value === null || typeof value !== "object") return void 0;
|
|
120
|
+
if (!("value" in value) || !("expiresAt" in value)) return void 0;
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
transaction(database, mode, run) {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const transaction = database.transaction(this.storeName, mode);
|
|
126
|
+
run(transaction.objectStore(this.storeName));
|
|
127
|
+
transaction.oncomplete = () => resolve();
|
|
128
|
+
transaction.onerror = () => reject(transaction.error);
|
|
129
|
+
transaction.onabort = () => reject(transaction.error);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
/** Bridge a one-shot `IDBRequest` into a promise. */
|
|
134
|
+
function requestToPromise(request) {
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
request.onsuccess = () => resolve(request.result);
|
|
137
|
+
request.onerror = () => reject(request.error);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/plugins/cache/adapters/memory.ts
|
|
142
|
+
/** L1 capacity used whenever the configured one is missing or not positive. */
|
|
143
|
+
var DEFAULT_L1_MAX_SIZE = 100;
|
|
144
|
+
/**
|
|
145
|
+
* The L1 store: a `Map` with TTL and LRU capacity.
|
|
146
|
+
*
|
|
147
|
+
* ## Why there is no sweep timer
|
|
148
|
+
*
|
|
149
|
+
* The pre-rewrite memory adapter ran a `setInterval` that (a) kept a Node
|
|
150
|
+
* process alive forever and (b) made tests hang until vitest force-killed them.
|
|
151
|
+
* Expiry is therefore swept *lazily*, at the start of every operation: at most
|
|
152
|
+
* `maxSize` records are inspected, which is cheaper than the timer it replaces
|
|
153
|
+
* for any realistic capacity, and it cannot outlive the store.
|
|
154
|
+
*
|
|
155
|
+
* ## Why a `Map` and not a `WeakMap`
|
|
156
|
+
*
|
|
157
|
+
* LRU needs enumeration and ordering. `Map` preserves insertion order, so
|
|
158
|
+
* "least recently used" is simply "first key" and touching an entry is
|
|
159
|
+
* delete + set — no linked list to keep in sync with the data.
|
|
160
|
+
*/
|
|
161
|
+
var MemoryCacheAdapter = class {
|
|
162
|
+
constructor(options = {}) {
|
|
163
|
+
this.records = /* @__PURE__ */ new Map();
|
|
164
|
+
const maxSize = options.maxSize;
|
|
165
|
+
this.maxSize = typeof maxSize === "number" && Number.isFinite(maxSize) && maxSize > 0 ? Math.floor(maxSize) : 100;
|
|
166
|
+
this.onEvict = options.onEvict;
|
|
167
|
+
}
|
|
168
|
+
/** Number of live entries. */
|
|
169
|
+
get size() {
|
|
170
|
+
this.sweep();
|
|
171
|
+
return this.records.size;
|
|
172
|
+
}
|
|
173
|
+
/** `true` when the key is present and still fresh. */
|
|
174
|
+
async has(key) {
|
|
175
|
+
return await this.get(key) !== void 0;
|
|
176
|
+
}
|
|
177
|
+
async get(key) {
|
|
178
|
+
this.sweep();
|
|
179
|
+
const record = this.records.get(key);
|
|
180
|
+
if (!record) return void 0;
|
|
181
|
+
this.records.delete(key);
|
|
182
|
+
this.records.set(key, record);
|
|
183
|
+
return record.value;
|
|
184
|
+
}
|
|
185
|
+
async set(key, value, ttlSeconds) {
|
|
186
|
+
this.sweep();
|
|
187
|
+
if (!this.records.delete(key)) this.evictForCapacity(1);
|
|
188
|
+
this.records.set(key, {
|
|
189
|
+
value,
|
|
190
|
+
expiresAt: ttlSeconds > 0 ? Date.now() + ttlSeconds * 1e3 : 0
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
async delete(key) {
|
|
194
|
+
this.records.delete(key);
|
|
195
|
+
}
|
|
196
|
+
async clear() {
|
|
197
|
+
this.records.clear();
|
|
198
|
+
}
|
|
199
|
+
async keys() {
|
|
200
|
+
this.sweep();
|
|
201
|
+
return [...this.records.keys()];
|
|
202
|
+
}
|
|
203
|
+
/** Drop expired records. Called before every operation, never on a timer. */
|
|
204
|
+
sweep() {
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
for (const [key, record] of this.records) if (record.expiresAt !== 0 && record.expiresAt <= now) {
|
|
207
|
+
this.records.delete(key);
|
|
208
|
+
this.onEvict?.(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Make room for `incoming` new entries by dropping the oldest ones. */
|
|
212
|
+
evictForCapacity(incoming) {
|
|
213
|
+
while (this.records.size + incoming > this.maxSize) {
|
|
214
|
+
const oldest = this.records.keys().next();
|
|
215
|
+
if (oldest.done) return;
|
|
216
|
+
this.records.delete(oldest.value);
|
|
217
|
+
this.onEvict?.(oldest.value);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/plugins/cache/adapters/web-storage.ts
|
|
223
|
+
/**
|
|
224
|
+
* L2 adapter over any Web Storage area.
|
|
225
|
+
*
|
|
226
|
+
* One class drives both `localStorage` and `sessionStorage`: they share the
|
|
227
|
+
* `Storage` interface, and wrapping them twice would duplicate every guard below.
|
|
228
|
+
*
|
|
229
|
+
* ## Why the area is resolved through a getter
|
|
230
|
+
*
|
|
231
|
+
* `localStorage` does not exist in Node, and in a browser it *throws* on access
|
|
232
|
+
* when the user has blocked site data. Reading it in the constructor would make
|
|
233
|
+
* `import`-time behaviour environment-dependent — and a Node test could not even
|
|
234
|
+
* construct the adapter to assert that it degrades. The getter is therefore
|
|
235
|
+
* called per operation and its failure is swallowed; the store behaves as an
|
|
236
|
+
* always-empty cache, which the manager turns into "L1 only".
|
|
237
|
+
*/
|
|
238
|
+
var WebStorageCacheAdapter = class {
|
|
239
|
+
constructor(resolveStorage, options = {}) {
|
|
240
|
+
this.resolveStorage = resolveStorage;
|
|
241
|
+
this.prefix = options.prefix ?? "[snail-cache]";
|
|
242
|
+
this.label = options.label ?? "web storage";
|
|
243
|
+
}
|
|
244
|
+
/** `false` when the backing area is missing — the manager then drops L2. */
|
|
245
|
+
get available() {
|
|
246
|
+
return this.storage() !== void 0;
|
|
247
|
+
}
|
|
248
|
+
async get(key) {
|
|
249
|
+
const storage = this.storage();
|
|
250
|
+
if (!storage) return void 0;
|
|
251
|
+
const raw = storage.getItem(this.key(key));
|
|
252
|
+
if (raw === null) return void 0;
|
|
253
|
+
const record = this.parse(raw);
|
|
254
|
+
if (record === void 0) {
|
|
255
|
+
storage.removeItem(this.key(key));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (record.expiresAt !== 0 && record.expiresAt <= Date.now()) {
|
|
259
|
+
storage.removeItem(this.key(key));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
return record.value;
|
|
263
|
+
}
|
|
264
|
+
async set(key, value, ttlSeconds) {
|
|
265
|
+
const storage = this.storage();
|
|
266
|
+
if (!storage) return;
|
|
267
|
+
const record = {
|
|
268
|
+
value,
|
|
269
|
+
expiresAt: ttlSeconds > 0 ? Date.now() + ttlSeconds * 1e3 : 0
|
|
270
|
+
};
|
|
271
|
+
storage.setItem(this.key(key), JSON.stringify(record));
|
|
272
|
+
}
|
|
273
|
+
async delete(key) {
|
|
274
|
+
this.storage()?.removeItem(this.key(key));
|
|
275
|
+
}
|
|
276
|
+
async clear() {
|
|
277
|
+
const storage = this.storage();
|
|
278
|
+
if (!storage) return;
|
|
279
|
+
for (const key of this.keysOf(storage)) storage.removeItem(key);
|
|
280
|
+
}
|
|
281
|
+
async keys() {
|
|
282
|
+
const storage = this.storage();
|
|
283
|
+
if (!storage) return [];
|
|
284
|
+
return this.keysOf(storage).map((key) => key.slice(this.prefix.length));
|
|
285
|
+
}
|
|
286
|
+
storage() {
|
|
287
|
+
try {
|
|
288
|
+
return this.resolveStorage();
|
|
289
|
+
} catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
key(key) {
|
|
294
|
+
return `${this.prefix}${key}`;
|
|
295
|
+
}
|
|
296
|
+
/** Every storage key this adapter owns, prefix included. */
|
|
297
|
+
keysOf(storage) {
|
|
298
|
+
const owned = [];
|
|
299
|
+
for (let index = 0; index < storage.length; index++) {
|
|
300
|
+
const key = storage.key(index);
|
|
301
|
+
if (key !== null && key.startsWith(this.prefix)) owned.push(key);
|
|
302
|
+
}
|
|
303
|
+
return owned;
|
|
304
|
+
}
|
|
305
|
+
parse(raw) {
|
|
306
|
+
try {
|
|
307
|
+
const parsed = JSON.parse(raw);
|
|
308
|
+
if (parsed === null || typeof parsed !== "object") return void 0;
|
|
309
|
+
if (!("value" in parsed) || !("expiresAt" in parsed)) return void 0;
|
|
310
|
+
return parsed;
|
|
311
|
+
} catch {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/plugins/cache/decorators.ts
|
|
318
|
+
/**
|
|
319
|
+
* `@Cacheable()` / `@NoCache()` / `@Invalidates()` / `@HitSource()` — the
|
|
320
|
+
* declarative half of the cache plugin.
|
|
321
|
+
*
|
|
322
|
+
* All four work on a class and on a method. TypeScript distinguishes the two by
|
|
323
|
+
* arity (a class decorator is called with the constructor alone), which is the
|
|
324
|
+
* only signal available now that `emitDecoratorMetadata` emits nothing in TS 7.
|
|
325
|
+
*
|
|
326
|
+
* ## Why `@Invalidates` appends instead of replacing
|
|
327
|
+
*
|
|
328
|
+
* `@Invalidates("users", "orders")` is two decorator applications on one target.
|
|
329
|
+
* Using the merging factory means the reader gets `["users", "orders"]` without
|
|
330
|
+
* the plugin having to flatten a nested array — and two separate
|
|
331
|
+
* `@Invalidates()` lines compose the same way, which is how a method that
|
|
332
|
+
* touches several caches is naturally written.
|
|
333
|
+
*/
|
|
334
|
+
var CACHEABLE_KEY = "snail-cache/cacheable";
|
|
335
|
+
var NO_CACHE_KEY = "snail-cache/disabled";
|
|
336
|
+
var INVALIDATES_KEY = "snail-cache/invalidates";
|
|
337
|
+
var addCacheableOnClass = createClassDecorator(CACHEABLE_KEY, false);
|
|
338
|
+
var addCacheableOnMethod = createMethodDecorator(CACHEABLE_KEY, false);
|
|
339
|
+
var addNoCacheOnClass = createClassDecorator(NO_CACHE_KEY, false);
|
|
340
|
+
var addNoCacheOnMethod = createMethodDecorator(NO_CACHE_KEY, false);
|
|
341
|
+
var addInvalidatesOnClass = createClassDecorator(INVALIDATES_KEY);
|
|
342
|
+
var addInvalidatesOnMethod = createMethodDecorator(INVALIDATES_KEY);
|
|
343
|
+
/** Fold a class application and a method application into one decorator. */
|
|
344
|
+
function dualDecorator$1(applyToClass, applyToMethod) {
|
|
345
|
+
return ((target, propertyKey) => {
|
|
346
|
+
if (propertyKey === void 0) {
|
|
347
|
+
applyToClass(target);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
applyToMethod(target, propertyKey, void 0);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
/** Reject the misuse that would otherwise only surface at request time. */
|
|
354
|
+
function assertCacheableOptions(options) {
|
|
355
|
+
if (options.ttl !== void 0 && !(typeof options.ttl === "number" && options.ttl > 0)) throw new SnailDecoratorError("[snail] @Cacheable({ ttl }) must be a positive number of seconds");
|
|
356
|
+
if (options.key !== void 0 && (typeof options.key !== "string" || options.key.length === 0)) throw new SnailDecoratorError("[snail] @Cacheable({ key }) must be a non-empty string");
|
|
357
|
+
if (options.tags !== void 0) {
|
|
358
|
+
if (!Array.isArray(options.tags)) throw new SnailDecoratorError("[snail] @Cacheable({ tags }) must be an array of strings");
|
|
359
|
+
for (const tag of options.tags) if (typeof tag !== "string" || tag.length === 0) throw new SnailDecoratorError("[snail] @Cacheable({ tags }) must contain non-empty strings");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Mark this method — or every method of this class — as cacheable.
|
|
364
|
+
*
|
|
365
|
+
* Passing the decorator is an explicit opt-in: it caches even a verb that
|
|
366
|
+
* `CacheOptions.cacheFor` does not list. Without it, `cacheFor` decides, and the
|
|
367
|
+
* reason is that "cache every POST of this class" is far too easy to write by
|
|
368
|
+
* accident when the intent was only to attach tags.
|
|
369
|
+
*
|
|
370
|
+
* ```ts
|
|
371
|
+
* @Api("/user")
|
|
372
|
+
* @Cacheable({ tags: ["users"] })
|
|
373
|
+
* class UserApi {
|
|
374
|
+
* @Get("/") list(): Promise<User[]> { return null!; }
|
|
375
|
+
*
|
|
376
|
+
* @Get("/stats")
|
|
377
|
+
* @Cacheable({ ttl: 5 })
|
|
378
|
+
* stats(): Promise<Stats> { return null!; }
|
|
379
|
+
* }
|
|
380
|
+
* ```
|
|
381
|
+
*/
|
|
382
|
+
function Cacheable(options = {}) {
|
|
383
|
+
assertCacheableOptions(options);
|
|
384
|
+
const value = {
|
|
385
|
+
ttl: options.ttl,
|
|
386
|
+
key: options.key,
|
|
387
|
+
tags: options.tags === void 0 ? void 0 : [...options.tags]
|
|
388
|
+
};
|
|
389
|
+
return dualDecorator$1(addCacheableOnClass(value), addCacheableOnMethod(value));
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Opt this method — or this entire class — out of caching.
|
|
393
|
+
*
|
|
394
|
+
* A method-level `@NoCache()` beats a class-level `@Cacheable()`, and a
|
|
395
|
+
* class-level `@NoCache()` beats the `cacheFor` default. Opting out is the one
|
|
396
|
+
* decision that may never be overridden by a broader rule, because the cost of
|
|
397
|
+
* being wrong is serving stale data.
|
|
398
|
+
*/
|
|
399
|
+
function NoCache() {
|
|
400
|
+
return dualDecorator$1(addNoCacheOnClass(true), addNoCacheOnMethod(true));
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Purge every cached entry carrying any of `tags` once this request succeeds.
|
|
404
|
+
*
|
|
405
|
+
* "Succeeds" means the HTTP round-trip did: the hook lives on the response path,
|
|
406
|
+
* so a failed or cancelled request invalidates nothing. A method may invalidate a
|
|
407
|
+
* tag it also stores under — the plugin purges before it stores, so the fresh
|
|
408
|
+
* entry survives its own invalidation.
|
|
409
|
+
*/
|
|
410
|
+
function Invalidates(...tags) {
|
|
411
|
+
for (const tag of tags) if (typeof tag !== "string" || tag.length === 0) throw new SnailDecoratorError("[snail] @Invalidates(...tags) needs non-empty strings");
|
|
412
|
+
return ((target, propertyKey) => {
|
|
413
|
+
for (const tag of tags) if (propertyKey === void 0) addInvalidatesOnClass(tag)(target);
|
|
414
|
+
else addInvalidatesOnMethod(tag)(target, propertyKey, void 0);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Legacy-compatible alias of {@link Invalidates} for a single source name.
|
|
419
|
+
*
|
|
420
|
+
* The pre-rewrite decorator was named after the *source* of a change rather than
|
|
421
|
+
* the entries it purges; application code written against it keeps working, and
|
|
422
|
+
* new code should prefer `@Invalidates`.
|
|
423
|
+
*/
|
|
424
|
+
function HitSource(name) {
|
|
425
|
+
return Invalidates(name);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Read `@Cacheable(...)` from a method (when `methodName` is given) or a class.
|
|
429
|
+
*
|
|
430
|
+
* `undefined` means "not marked", which is different from `{}` ("marked, with no
|
|
431
|
+
* overrides") — the plugin treats the two differently.
|
|
432
|
+
*/
|
|
433
|
+
function readCacheable(target, methodName) {
|
|
434
|
+
return methodName === void 0 ? getClassMetadata(CACHEABLE_KEY, target) : getMethodMetadata(CACHEABLE_KEY, target, methodName);
|
|
435
|
+
}
|
|
436
|
+
/** `true` when `@NoCache()` was applied to this method or class. */
|
|
437
|
+
function readNoCache(target, methodName) {
|
|
438
|
+
return (methodName === void 0 ? getClassMetadata(NO_CACHE_KEY, target) : getMethodMetadata(NO_CACHE_KEY, target, methodName)) === true;
|
|
439
|
+
}
|
|
440
|
+
/** Tags listed by `@Invalidates(...)` / `@HitSource(...)`, in application order. */
|
|
441
|
+
function readInvalidates(target, methodName) {
|
|
442
|
+
return (methodName === void 0 ? getClassMetadata(INVALIDATES_KEY, target) : getMethodMetadata(INVALIDATES_KEY, target, methodName)) ?? [];
|
|
443
|
+
}
|
|
444
|
+
//#endregion
|
|
445
|
+
//#region src/plugins/cache/key.ts
|
|
446
|
+
/**
|
|
447
|
+
* Build the key used for one request.
|
|
448
|
+
*
|
|
449
|
+
* The readable part (`prefix:VERB`) is kept outside the hash so a misbehaving
|
|
450
|
+
* cache can be diagnosed from a key alone; only the volatile signature is hashed,
|
|
451
|
+
* which keeps keys short enough for `localStorage`.
|
|
452
|
+
*/
|
|
453
|
+
function buildCacheKey(input) {
|
|
454
|
+
const { prefix, request, methodType, explicitKey } = input;
|
|
455
|
+
if (explicitKey !== void 0 && explicitKey.length > 0) return `${prefix}:${explicitKey}`;
|
|
456
|
+
const method = (request.method ?? methodType).toUpperCase();
|
|
457
|
+
const signature = stableStringify({
|
|
458
|
+
method,
|
|
459
|
+
baseURL: request.baseURL ?? "",
|
|
460
|
+
url: request.url ?? "",
|
|
461
|
+
params: request.params ?? null,
|
|
462
|
+
data: request.data ?? null
|
|
463
|
+
});
|
|
464
|
+
return `${prefix}:${method}:${shortHash(signature)}`;
|
|
465
|
+
}
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/plugins/cache/manager.ts
|
|
468
|
+
/** Verbs cached when `cacheFor` is not configured. */
|
|
469
|
+
var DEFAULT_CACHE_FOR = ["GET"];
|
|
470
|
+
/**
|
|
471
|
+
* Storage engine behind the cache plugin.
|
|
472
|
+
*
|
|
473
|
+
* ## Layers
|
|
474
|
+
*
|
|
475
|
+
* - **L1** is a {@link MemoryCacheAdapter}: always present unless `l1: false`,
|
|
476
|
+
* bounded by `maxSize`, and the only place an L2 hit is promoted into.
|
|
477
|
+
* - **L2** is any {@link CacheAdapter}, usually a persistent store.
|
|
478
|
+
*
|
|
479
|
+
* ## What the manager owns that the adapters do not
|
|
480
|
+
*
|
|
481
|
+
* TTL, LRU and tags are policy, not storage. Keeping them here means a custom L2
|
|
482
|
+
* adapter only has to answer "get/set/delete" and never has to know what a tag
|
|
483
|
+
* is — which is what makes the adapter interface small enough to implement over
|
|
484
|
+
* anything (IndexedDB, `localStorage`, an HTTP cache).
|
|
485
|
+
*
|
|
486
|
+
* ## Freshness vs. `staleWhileRevalidate`
|
|
487
|
+
*
|
|
488
|
+
* Freshness is always decided by this class, using the `expiresAt` it records on
|
|
489
|
+
* `set`. In stale-while-revalidate mode L1 is told "never expire" so the stale
|
|
490
|
+
* copy survives to be served while the plugin refreshes it; otherwise L1 is
|
|
491
|
+
* given the real TTL and sweeps the entry itself. L2 always receives the real TTL
|
|
492
|
+
* so a second tab (which has no shared in-memory index) never reads a stale one.
|
|
493
|
+
*/
|
|
494
|
+
var CacheManager = class {
|
|
495
|
+
constructor(options = {}) {
|
|
496
|
+
this.expiry = /* @__PURE__ */ new Map();
|
|
497
|
+
this.tagIndex = /* @__PURE__ */ new Map();
|
|
498
|
+
this.keyTags = /* @__PURE__ */ new Map();
|
|
499
|
+
this.flight = /* @__PURE__ */ new Map();
|
|
500
|
+
this.logger = options.logger ?? createLogger("silent");
|
|
501
|
+
this.options = resolveOptions(options, this.logger);
|
|
502
|
+
this.l1 = this.options.l1 ? new MemoryCacheAdapter({
|
|
503
|
+
maxSize: this.options.maxSize,
|
|
504
|
+
onEvict: (key) => this.forgetKey(key)
|
|
505
|
+
}) : void 0;
|
|
506
|
+
}
|
|
507
|
+
/** Number of live **L1** entries. L2 is not enumerated, by design. */
|
|
508
|
+
get size() {
|
|
509
|
+
return this.l1?.size ?? 0;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Read a fresh value.
|
|
513
|
+
*
|
|
514
|
+
* A stale entry (kept alive only for stale-while-revalidate) is *not* returned:
|
|
515
|
+
* callers that must serve staleness ask {@link lookup} explicitly, so the
|
|
516
|
+
* common path cannot accidentally serve expired data.
|
|
517
|
+
*/
|
|
518
|
+
async get(key) {
|
|
519
|
+
return (await this.lookup(key))?.value;
|
|
520
|
+
}
|
|
521
|
+
/** `true` when a fresh value is stored under `key`. */
|
|
522
|
+
async has(key) {
|
|
523
|
+
return await this.get(key) !== void 0;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Read a value together with its freshness.
|
|
527
|
+
*
|
|
528
|
+
* `allowStale` is the plugin's `staleWhileRevalidate` switch: when the entry is
|
|
529
|
+
* past its TTL but still resident, it is returned with `stale: true` so the
|
|
530
|
+
* caller can serve it now and refresh afterwards.
|
|
531
|
+
*/
|
|
532
|
+
async lookup(key, allowStale = false) {
|
|
533
|
+
const fromL1 = await this.readL1(key);
|
|
534
|
+
if (fromL1.found) {
|
|
535
|
+
const stale = this.isStale(key);
|
|
536
|
+
if (!stale || allowStale) return {
|
|
537
|
+
value: fromL1.value,
|
|
538
|
+
stale
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
const fromL2 = await this.readL2(key);
|
|
542
|
+
if (!fromL2.found) return void 0;
|
|
543
|
+
this.markExpiry(key, this.options.ttl);
|
|
544
|
+
const l1Ttl = this.options.staleWhileRevalidate ? 0 : this.options.ttl;
|
|
545
|
+
await this.writeL1(key, fromL2.value, l1Ttl);
|
|
546
|
+
return {
|
|
547
|
+
value: fromL2.value,
|
|
548
|
+
stale: false
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Store a value in L1 and L2.
|
|
553
|
+
*
|
|
554
|
+
* `ttlSeconds` defaults to the configured TTL and `tags` may be empty; a tag
|
|
555
|
+
* written here is what `@Invalidates("tag")` later purges.
|
|
556
|
+
*/
|
|
557
|
+
async set(key, value, ttlSeconds = this.options.ttl, tags = []) {
|
|
558
|
+
this.markExpiry(key, ttlSeconds);
|
|
559
|
+
this.rememberTags(key, tags);
|
|
560
|
+
await this.writeL1(key, value, this.options.staleWhileRevalidate ? 0 : ttlSeconds);
|
|
561
|
+
await this.writeL2(key, value, ttlSeconds);
|
|
562
|
+
}
|
|
563
|
+
/** Remove one entry from every layer. */
|
|
564
|
+
async delete(key) {
|
|
565
|
+
this.forgetKey(key);
|
|
566
|
+
await this.l1?.delete(key);
|
|
567
|
+
if (!this.options.l2) return;
|
|
568
|
+
try {
|
|
569
|
+
await this.options.l2.delete(key);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
this.warnL2("delete", error);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/** Remove every entry this manager can see. */
|
|
575
|
+
async clear() {
|
|
576
|
+
await this.l1?.clear();
|
|
577
|
+
this.expiry.clear();
|
|
578
|
+
this.tagIndex.clear();
|
|
579
|
+
this.keyTags.clear();
|
|
580
|
+
if (!this.options.l2) return;
|
|
581
|
+
try {
|
|
582
|
+
await this.options.l2.clear();
|
|
583
|
+
} catch (error) {
|
|
584
|
+
this.warnL2("clear", error);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Purge every entry carrying any of `tags`.
|
|
589
|
+
*
|
|
590
|
+
* Used by `@Invalidates(...)` / `@HitSource(...)` after a successful request.
|
|
591
|
+
* Keys are collected first because deleting mutates the tags they came from.
|
|
592
|
+
*/
|
|
593
|
+
async invalidateTags(tags) {
|
|
594
|
+
const keys = /* @__PURE__ */ new Set();
|
|
595
|
+
for (const tag of tags) for (const key of this.tagIndex.get(tag) ?? []) keys.add(key);
|
|
596
|
+
for (const key of keys) await this.delete(key);
|
|
597
|
+
}
|
|
598
|
+
/** Alias of {@link clear}, spelled the way `@Invalidates("*")`-style callers expect. */
|
|
599
|
+
async invalidateAll() {
|
|
600
|
+
await this.clear();
|
|
601
|
+
}
|
|
602
|
+
/** The promise of the request currently being sent for `key`, if any. */
|
|
603
|
+
getInFlight(key) {
|
|
604
|
+
return this.flight.get(key);
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Publish the promise of the request being sent for `key`.
|
|
608
|
+
*
|
|
609
|
+
* The entry removes itself when the promise settles, so a rejected shared
|
|
610
|
+
* promise cannot be picked up by a later, unrelated request. The rejection is
|
|
611
|
+
* also marked as observed: a leader that fails with no follower waiting would
|
|
612
|
+
* otherwise surface as an unhandled rejection in Node.
|
|
613
|
+
*/
|
|
614
|
+
setInFlight(key, promise) {
|
|
615
|
+
const tracked = promise.then((value) => {
|
|
616
|
+
this.flight.delete(key);
|
|
617
|
+
return value;
|
|
618
|
+
}, (error) => {
|
|
619
|
+
this.flight.delete(key);
|
|
620
|
+
throw error;
|
|
621
|
+
});
|
|
622
|
+
tracked.catch(() => void 0);
|
|
623
|
+
this.flight.set(key, tracked);
|
|
624
|
+
}
|
|
625
|
+
async readL1(key) {
|
|
626
|
+
if (!this.l1) return { found: false };
|
|
627
|
+
const value = await this.l1.get(key);
|
|
628
|
+
return value === void 0 ? { found: false } : {
|
|
629
|
+
found: true,
|
|
630
|
+
value
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
async readL2(key) {
|
|
634
|
+
const l2 = this.options.l2;
|
|
635
|
+
if (!l2) return { found: false };
|
|
636
|
+
try {
|
|
637
|
+
const value = await l2.get(key);
|
|
638
|
+
return value === void 0 ? { found: false } : {
|
|
639
|
+
found: true,
|
|
640
|
+
value
|
|
641
|
+
};
|
|
642
|
+
} catch (error) {
|
|
643
|
+
this.warnL2("read", error);
|
|
644
|
+
return { found: false };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
async writeL1(key, value, ttlSeconds) {
|
|
648
|
+
if (!this.l1) return;
|
|
649
|
+
await this.l1.set(key, value, ttlSeconds);
|
|
650
|
+
}
|
|
651
|
+
async writeL2(key, value, ttlSeconds) {
|
|
652
|
+
const l2 = this.options.l2;
|
|
653
|
+
if (!l2) return;
|
|
654
|
+
try {
|
|
655
|
+
await l2.set(key, value, ttlSeconds);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
this.warnL2("write", error);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
markExpiry(key, ttlSeconds) {
|
|
661
|
+
this.expiry.set(key, ttlSeconds > 0 ? Date.now() + ttlSeconds * 1e3 : 0);
|
|
662
|
+
}
|
|
663
|
+
isStale(key) {
|
|
664
|
+
const expiresAt = this.expiry.get(key);
|
|
665
|
+
return expiresAt !== void 0 && expiresAt !== 0 && expiresAt <= Date.now();
|
|
666
|
+
}
|
|
667
|
+
rememberTags(key, tags) {
|
|
668
|
+
if (tags.length === 0) return;
|
|
669
|
+
const owned = this.keyTags.get(key) ?? /* @__PURE__ */ new Set();
|
|
670
|
+
for (const tag of tags) {
|
|
671
|
+
owned.add(tag);
|
|
672
|
+
const keys = this.tagIndex.get(tag) ?? /* @__PURE__ */ new Set();
|
|
673
|
+
keys.add(key);
|
|
674
|
+
this.tagIndex.set(tag, keys);
|
|
675
|
+
}
|
|
676
|
+
this.keyTags.set(key, owned);
|
|
677
|
+
}
|
|
678
|
+
forgetKey(key) {
|
|
679
|
+
this.expiry.delete(key);
|
|
680
|
+
const tags = this.keyTags.get(key);
|
|
681
|
+
if (!tags) return;
|
|
682
|
+
for (const tag of tags) {
|
|
683
|
+
const keys = this.tagIndex.get(tag);
|
|
684
|
+
if (!keys) continue;
|
|
685
|
+
keys.delete(key);
|
|
686
|
+
if (keys.size === 0) this.tagIndex.delete(tag);
|
|
687
|
+
}
|
|
688
|
+
this.keyTags.delete(key);
|
|
689
|
+
}
|
|
690
|
+
warnL2(operation, error) {
|
|
691
|
+
this.logger.warn(t("cache.warn.l2.failed", this.options.prefix, `${operation}: ${String(error)}`));
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
/** Apply every default and turn the L2 selector into a live adapter. */
|
|
695
|
+
function resolveOptions(options, logger) {
|
|
696
|
+
const prefix = options.prefix ?? "[snail-cache]";
|
|
697
|
+
return {
|
|
698
|
+
ttl: positive(options.ttl, 60),
|
|
699
|
+
maxSize: positive(options.maxSize, 100),
|
|
700
|
+
l1: options.l1 ?? true,
|
|
701
|
+
l2: createL2(options.l2, prefix, logger),
|
|
702
|
+
cacheFor: normalizeCacheFor(options.cacheFor),
|
|
703
|
+
prefix,
|
|
704
|
+
staleWhileRevalidate: options.staleWhileRevalidate ?? false,
|
|
705
|
+
dedupe: options.dedupe ?? true
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Turn the `l2` selector into an adapter.
|
|
710
|
+
*
|
|
711
|
+
* A missing environment global becomes "no L2 at all" plus one warning, rather
|
|
712
|
+
* than an adapter that silently misses on every read: the warning is the only
|
|
713
|
+
* signal an application gets that its persistence intent was not honoured.
|
|
714
|
+
*/
|
|
715
|
+
function createL2(selector, prefix, logger) {
|
|
716
|
+
if (selector === void 0) return void 0;
|
|
717
|
+
if (typeof selector !== "string") return selector;
|
|
718
|
+
const normalized = selector.toLowerCase();
|
|
719
|
+
if (normalized === "localstorage" || normalized === "sessionstorage") {
|
|
720
|
+
const area = normalized === "localstorage" ? "localStorage" : "sessionStorage";
|
|
721
|
+
const adapter = new WebStorageCacheAdapter(() => globalThis[area], {
|
|
722
|
+
prefix,
|
|
723
|
+
label: area
|
|
724
|
+
});
|
|
725
|
+
if (!adapter.available) {
|
|
726
|
+
logger.warn(t("cache.warn.l2.unavailable", prefix, area));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
return adapter;
|
|
730
|
+
}
|
|
731
|
+
if (normalized === "indexeddb") {
|
|
732
|
+
const adapter = new IndexedDBCacheAdapter({ storeName: prefix });
|
|
733
|
+
if (!adapter.available) {
|
|
734
|
+
logger.warn(t("cache.warn.l2.unavailable", prefix, "indexedDB"));
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
return adapter;
|
|
738
|
+
}
|
|
739
|
+
throw new SnailPluginError(t("error.plugin.cache.adapter", selector), { pluginName: "cache" });
|
|
740
|
+
}
|
|
741
|
+
/** Normalise `cacheFor` into `"all"` or an uppercase verb list. */
|
|
742
|
+
function normalizeCacheFor(input) {
|
|
743
|
+
if (input === void 0) return DEFAULT_CACHE_FOR;
|
|
744
|
+
if (typeof input === "string") return input.toLowerCase() === "all" ? "all" : [input.toUpperCase()];
|
|
745
|
+
return input.map((verb) => String(verb).toUpperCase());
|
|
746
|
+
}
|
|
747
|
+
/** Coerce an optional count to a positive finite number, else `fallback`. */
|
|
748
|
+
function positive(value, fallback) {
|
|
749
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
750
|
+
}
|
|
751
|
+
//#endregion
|
|
752
|
+
//#region src/plugins/cache/plugin.ts
|
|
753
|
+
/**
|
|
754
|
+
* The cache plugin.
|
|
755
|
+
*
|
|
756
|
+
* ## Where it sits in the pipeline
|
|
757
|
+
*
|
|
758
|
+
* `priority: -100` is the reserved cache band, which buys two things the contract
|
|
759
|
+
* spells out in `docs/guide/plugin-lifecycle.md` §2.2:
|
|
760
|
+
*
|
|
761
|
+
* - **last in forward order** — the interceptor (`100`) and every argument
|
|
762
|
+
* decorator have already run, so `ctx.request` holds the final url, params and
|
|
763
|
+
* body when the key is built. Hashing an earlier shape would key two different
|
|
764
|
+
* requests identically (or one request twice).
|
|
765
|
+
* - **first in unwind order** — the raw envelope is stored before the validation
|
|
766
|
+
* and transformation plugins touch it, so a hit replays exactly what the server
|
|
767
|
+
* sent rather than a processed derivative.
|
|
768
|
+
*
|
|
769
|
+
* ## Stores what the server sent
|
|
770
|
+
*
|
|
771
|
+
* The entry holds `ctx.response.data`, not the `SnailResult`. The result is a
|
|
772
|
+
* projection of the envelope onto the caller's envelope schema; caching it would
|
|
773
|
+
* freeze one server's key names into another's cache.
|
|
774
|
+
*/
|
|
775
|
+
/** Plugin name; also the identity used by `Service.use()` / `Service.remove()`. */
|
|
776
|
+
var CACHE_PLUGIN_NAME = "cache";
|
|
777
|
+
/** The reserved cache priority band (see `docs/guide/plugin-lifecycle.md` §2.1). */
|
|
778
|
+
var CACHE_PRIORITY = -100;
|
|
779
|
+
/** `ctx.state` slot holding the {@link CachePlan} of the current send. */
|
|
780
|
+
var CACHE_PLAN_STATE = "snail:cache:plan";
|
|
781
|
+
/**
|
|
782
|
+
* Create the cache plugin.
|
|
783
|
+
*
|
|
784
|
+
* ```ts
|
|
785
|
+
* const cache = Cache({ ttl: 30, l2: "localStorage" });
|
|
786
|
+
* Service.use(cache);
|
|
787
|
+
* await cache.manager?.invalidateAll();
|
|
788
|
+
* ```
|
|
789
|
+
*/
|
|
790
|
+
function Cache(options) {
|
|
791
|
+
let manager;
|
|
792
|
+
const base = createPlugin({
|
|
793
|
+
name: CACHE_PLUGIN_NAME,
|
|
794
|
+
priority: CACHE_PRIORITY,
|
|
795
|
+
setup(pluginOptions, api) {
|
|
796
|
+
const instance = new CacheManager({
|
|
797
|
+
...pluginOptions,
|
|
798
|
+
prefix: pluginOptions?.prefix ?? api.serverName,
|
|
799
|
+
logger: createLogger(api.serverOptions.logLevel)
|
|
800
|
+
});
|
|
801
|
+
manager = instance;
|
|
802
|
+
api.addMessages({
|
|
803
|
+
"cache.warn.l2.failed": "[%s] L2 cache %s failed; continuing without it",
|
|
804
|
+
"cache.warn.l2.unavailable": "[%s] %s is unavailable in this environment; the cache plugin uses L1 only",
|
|
805
|
+
"cache.warn.store.failed": "[%s] the response could not be cached: %s",
|
|
806
|
+
"cache.warn.revalidate.failed": "[%s] background revalidation failed: %s",
|
|
807
|
+
"info.cache.stale": "[%s] served a stale entry; revalidating in the background"
|
|
808
|
+
});
|
|
809
|
+
return {
|
|
810
|
+
beforeRequest: (ctx, next) => serveFromCache(instance, ctx, next),
|
|
811
|
+
afterResponse: (ctx, next) => storeResponse(instance, ctx, next)
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
})(options);
|
|
815
|
+
Object.defineProperty(base, "manager", {
|
|
816
|
+
enumerable: true,
|
|
817
|
+
configurable: true,
|
|
818
|
+
get: () => manager
|
|
819
|
+
});
|
|
820
|
+
return base;
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Serve a cache hit, or register this request as the one that will fill the gap.
|
|
824
|
+
*
|
|
825
|
+
* Three outcomes, in order of cost:
|
|
826
|
+
*
|
|
827
|
+
* 1. **fresh hit** — `ctx.interrupt(response)` and no `next()`, so axios is never
|
|
828
|
+
* reached;
|
|
829
|
+
* 2. **stale hit** (only with `staleWhileRevalidate`) — the stale body is served
|
|
830
|
+
* immediately and the refresh happens out of band;
|
|
831
|
+
* 3. **miss** — `next()` runs the request, and a follower of an identical
|
|
832
|
+
* in-flight request waits for its leader instead of sending a second one.
|
|
833
|
+
*/
|
|
834
|
+
async function serveFromCache(manager, ctx, next) {
|
|
835
|
+
const plan = resolveCachePlan(ctx, manager.options);
|
|
836
|
+
ctx.state.set(CACHE_PLAN_STATE, plan);
|
|
837
|
+
const key = plan.key;
|
|
838
|
+
if (key === void 0) return next();
|
|
839
|
+
const found = await manager.lookup(key, manager.options.staleWhileRevalidate);
|
|
840
|
+
if (found) {
|
|
841
|
+
ctx.markCacheHit();
|
|
842
|
+
ctx.interrupt(makeCachedResponse(found.value, ctx.request));
|
|
843
|
+
ctx.logger.debug(t("info.cache.hit", ctx.fullName));
|
|
844
|
+
if (found.stale) {
|
|
845
|
+
ctx.logger.debug(t("info.cache.stale", ctx.fullName));
|
|
846
|
+
revalidate(manager, ctx, plan, key);
|
|
847
|
+
}
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (!manager.options.dedupe) return next();
|
|
851
|
+
const pending = manager.getInFlight(key);
|
|
852
|
+
if (pending) {
|
|
853
|
+
const shared = await pending.catch(() => void 0);
|
|
854
|
+
if (shared !== void 0) {
|
|
855
|
+
ctx.markCacheHit();
|
|
856
|
+
ctx.interrupt(makeCachedResponse(shared, ctx.request));
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
return next();
|
|
860
|
+
}
|
|
861
|
+
const flight = deferred();
|
|
862
|
+
manager.setInFlight(key, flight.promise);
|
|
863
|
+
try {
|
|
864
|
+
await next();
|
|
865
|
+
flight.resolve(ctx.getResponse()?.data);
|
|
866
|
+
} catch (error) {
|
|
867
|
+
flight.reject(error);
|
|
868
|
+
throw error;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Invalidate first, then store, then continue the chain.
|
|
873
|
+
*
|
|
874
|
+
* The order of the first two is load-bearing: a method that is both cacheable
|
|
875
|
+
* and declares `@Invalidates` for one of its own tags stores *after* the purge,
|
|
876
|
+
* so it cannot delete the entry it just wrote. Storing before `next()` is what
|
|
877
|
+
* makes the cached value the raw envelope — later plugins see the response
|
|
878
|
+
* afterwards, and the cache keeps what the server actually sent.
|
|
879
|
+
*/
|
|
880
|
+
async function storeResponse(manager, ctx, next) {
|
|
881
|
+
const plan = ctx.state.get(CACHE_PLAN_STATE);
|
|
882
|
+
if (plan && !ctx.isCacheHit) {
|
|
883
|
+
if (plan.invalidate.length > 0) {
|
|
884
|
+
await manager.invalidateTags(plan.invalidate);
|
|
885
|
+
ctx.logger.debug(t("info.cache.invalidate", ctx.fullName, plan.invalidate.join(", ")));
|
|
886
|
+
}
|
|
887
|
+
if (plan.key !== void 0 && ctx.response) try {
|
|
888
|
+
await manager.set(plan.key, snapshot(ctx.response.data), plan.ttl, plan.tags);
|
|
889
|
+
ctx.logger.debug(t("info.cache.set", ctx.fullName));
|
|
890
|
+
} catch (error) {
|
|
891
|
+
ctx.logger.warn(t("cache.warn.store.failed", ctx.fullName, String(error)));
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
await next();
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Decide what the cache should do with this request, from the decorators and the
|
|
898
|
+
* configured verb list.
|
|
899
|
+
*
|
|
900
|
+
* Precedence, and why:
|
|
901
|
+
*
|
|
902
|
+
* 1. `@NoCache()` on the method — an explicit opt-out is never overridden;
|
|
903
|
+
* 2. `@Cacheable()` on the method — an explicit opt-in beats a class-wide
|
|
904
|
+
* decision, in both directions (this is what "method-level wins" means);
|
|
905
|
+
* 3. `@NoCache()` on the class;
|
|
906
|
+
* 4. `@Cacheable()` on the class — every method of the class;
|
|
907
|
+
* 5. otherwise, the verb must be listed in `cacheFor`, which defaults to `GET`.
|
|
908
|
+
*
|
|
909
|
+
* Tags from both levels are merged, class first, so a method can add its own
|
|
910
|
+
* without losing the class's. `ttl` and `key` are *not* merged: the most specific
|
|
911
|
+
* declaration wins outright.
|
|
912
|
+
*/
|
|
913
|
+
function resolveCachePlan(ctx, options) {
|
|
914
|
+
const classCacheable = readCacheable(ctx.apiClass);
|
|
915
|
+
const methodCacheable = readCacheable(ctx.apiClass, ctx.methodName);
|
|
916
|
+
const cacheable = readNoCache(ctx.apiClass, ctx.methodName) ? false : methodCacheable !== void 0 ? true : readNoCache(ctx.apiClass) ? false : classCacheable !== void 0 ? true : matchesCacheFor(ctx.methodType, options.cacheFor);
|
|
917
|
+
const explicitKey = methodCacheable?.key ?? classCacheable?.key;
|
|
918
|
+
return {
|
|
919
|
+
key: cacheable ? buildCacheKey({
|
|
920
|
+
prefix: options.prefix,
|
|
921
|
+
request: ctx.request,
|
|
922
|
+
methodType: ctx.methodType,
|
|
923
|
+
explicitKey
|
|
924
|
+
}) : void 0,
|
|
925
|
+
ttl: methodCacheable?.ttl ?? classCacheable?.ttl ?? options.ttl,
|
|
926
|
+
tags: [.../* @__PURE__ */ new Set([...classCacheable?.tags ?? [], ...methodCacheable?.tags ?? []])],
|
|
927
|
+
invalidate: [.../* @__PURE__ */ new Set([...readInvalidates(ctx.apiClass), ...readInvalidates(ctx.apiClass, ctx.methodName)])]
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
/** `true` when `cacheFor` covers this verb. */
|
|
931
|
+
function matchesCacheFor(method, cacheFor) {
|
|
932
|
+
return cacheFor === "all" || cacheFor.includes(method);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Wrap a cached body into the axios response a plugin must hand back.
|
|
936
|
+
*
|
|
937
|
+
* `headers` is empty on purpose: the stored entry is only the body, and inventing
|
|
938
|
+
* headers would make downstream plugins react to values the server never sent.
|
|
939
|
+
* `config` is the live request, so anything reading `response.config` sees the
|
|
940
|
+
* request that produced the hit.
|
|
941
|
+
*
|
|
942
|
+
* The body is **copied**. Without that, the object handed to the caller would be
|
|
943
|
+
* the very object the cache holds, so one `result.data.name = "x"` would silently
|
|
944
|
+
* rewrite the cache entry — and, worse, a response transformer that hydrates the
|
|
945
|
+
* payload in place would corrupt the stored value for every later hit.
|
|
946
|
+
*/
|
|
947
|
+
function makeCachedResponse(body, config) {
|
|
948
|
+
return {
|
|
949
|
+
data: snapshot(body),
|
|
950
|
+
status: 200,
|
|
951
|
+
statusText: "Cache Hit (snail)",
|
|
952
|
+
headers: {},
|
|
953
|
+
config
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Copy a value so the cache and the caller can never share a mutable reference.
|
|
958
|
+
*
|
|
959
|
+
* `structuredClone` is preferred because it preserves `Date`, `Map`, `Set` and
|
|
960
|
+
* typed arrays, which a JSON round-trip flattens. JSON is the fallback for older
|
|
961
|
+
* engines, and if both fail (a function-valued field, a class instance with
|
|
962
|
+
* private state) the original reference is returned — a shared reference is a
|
|
963
|
+
* far smaller problem than throwing inside a cache read.
|
|
964
|
+
*/
|
|
965
|
+
function snapshot(value) {
|
|
966
|
+
if (value === null || typeof value !== "object") return value;
|
|
967
|
+
const cloner = globalThis.structuredClone;
|
|
968
|
+
if (typeof cloner === "function") try {
|
|
969
|
+
return cloner(value);
|
|
970
|
+
} catch {}
|
|
971
|
+
try {
|
|
972
|
+
return JSON.parse(JSON.stringify(value));
|
|
973
|
+
} catch {
|
|
974
|
+
return value;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Refresh an entry after serving stale data.
|
|
979
|
+
*
|
|
980
|
+
* The refresh deliberately does **not** call this request's `next()`: the caller
|
|
981
|
+
* is still reading `ctx.response`, and a detached chain step would write the
|
|
982
|
+
* fresh response into the very same context — corrupting the result the caller is
|
|
983
|
+
* about to receive, or a later `send()` that reused the context. Instead the
|
|
984
|
+
* final config is replayed through the server's own axios instance and the
|
|
985
|
+
* response is normalised the same way `dispatch` normalises it, so the refreshed
|
|
986
|
+
* entry is byte-for-byte what a normal miss would have stored.
|
|
987
|
+
*/
|
|
988
|
+
function revalidate(manager, ctx, plan, key) {
|
|
989
|
+
const config = ctx.server.pluginManager.reduce("requestInterceptor", ctx.request, ctx);
|
|
990
|
+
(async () => {
|
|
991
|
+
try {
|
|
992
|
+
let response = await ctx.server.axios.request(config);
|
|
993
|
+
response = coerceJSONStringBody(response, ctx.serverOptions.coerceJSONString);
|
|
994
|
+
response = ctx.server.pluginManager.reduce("responseInterceptor", response, ctx);
|
|
995
|
+
await manager.set(key, response.data, plan.ttl, plan.tags);
|
|
996
|
+
} catch (error) {
|
|
997
|
+
ctx.logger.warn(t("cache.warn.revalidate.failed", ctx.fullName, String(error)));
|
|
998
|
+
}
|
|
999
|
+
})();
|
|
1000
|
+
}
|
|
1001
|
+
//#endregion
|
|
1002
|
+
//#region src/plugins/interceptor/decorators.ts
|
|
1003
|
+
/**
|
|
1004
|
+
* `@BeforeRequest()` / `@AfterResponse()` — the decorator half of the
|
|
1005
|
+
* interceptor plugin.
|
|
1006
|
+
*
|
|
1007
|
+
* ## Storage
|
|
1008
|
+
*
|
|
1009
|
+
* Both targets share one metadata key per phase: a class application lands in the
|
|
1010
|
+
* class slot, a method application in that method's slot
|
|
1011
|
+
* (`src/core/metadata.ts` §storage model). Reading them back is therefore one
|
|
1012
|
+
* `getClassMetadata` plus one `getMethodMetadata`, with no "is this a class or a
|
|
1013
|
+
* proto" branching at the call site.
|
|
1014
|
+
*
|
|
1015
|
+
* ## Why the key name is namespaced
|
|
1016
|
+
*
|
|
1017
|
+
* `createClassDecorator` turns `"snail-interceptor/before"` into
|
|
1018
|
+
* `Symbol.for("@snail-js/api:custom:snail-interceptor/before")`. A `Symbol.for`
|
|
1019
|
+
* key survives the two-copies-of-the-package situation a monorepo creates, so a
|
|
1020
|
+
* decorator applied from the hoisted copy is still visible to a plugin loaded
|
|
1021
|
+
* from the nested one.
|
|
1022
|
+
*/
|
|
1023
|
+
var BEFORE_KEY = "snail-interceptor/before";
|
|
1024
|
+
var AFTER_KEY = "snail-interceptor/after";
|
|
1025
|
+
var addBeforeOnClass = createClassDecorator(BEFORE_KEY);
|
|
1026
|
+
var addBeforeOnMethod = createMethodDecorator(BEFORE_KEY);
|
|
1027
|
+
var addAfterOnClass = createClassDecorator(AFTER_KEY);
|
|
1028
|
+
var addAfterOnMethod = createMethodDecorator(AFTER_KEY);
|
|
1029
|
+
/**
|
|
1030
|
+
* Fold a class application and a method application into one decorator.
|
|
1031
|
+
*
|
|
1032
|
+
* TypeScript tells the two apart by arity: a class decorator is called with the
|
|
1033
|
+
* constructor alone, a method decorator with `(prototype, key, descriptor)`.
|
|
1034
|
+
* That is the only reliable signal available without `reflect-metadata`.
|
|
1035
|
+
*/
|
|
1036
|
+
function dualDecorator(applyToClass, applyToMethod) {
|
|
1037
|
+
return ((target, propertyKey) => {
|
|
1038
|
+
if (propertyKey === void 0) {
|
|
1039
|
+
applyToClass(target);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
applyToMethod(target, propertyKey, void 0);
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
/** Reject the misuse that would otherwise fail silently at request time. */
|
|
1046
|
+
function assertCallback(value, decorator) {
|
|
1047
|
+
if (typeof value !== "function") throw new SnailDecoratorError(`[snail] @${decorator}() needs a function; got ${typeof value}`);
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Run `onFulfilled` on the request config of this class / this method, in series
|
|
1051
|
+
* with every other request interceptor.
|
|
1052
|
+
*
|
|
1053
|
+
* ```ts
|
|
1054
|
+
* @Api("/user")
|
|
1055
|
+
* @BeforeRequest<UserConfig>((config) => { config.headers.set("x-trace", "1"); })
|
|
1056
|
+
* class UserApi {
|
|
1057
|
+
* @Get("/")
|
|
1058
|
+
* @BeforeRequest((config) => { config.timeout = 5000; })
|
|
1059
|
+
* list(): Promise<User[]> { return null!; }
|
|
1060
|
+
* }
|
|
1061
|
+
* ```
|
|
1062
|
+
*
|
|
1063
|
+
* Class-level interceptors run before method-level ones, so a method may refine
|
|
1064
|
+
* what its class established. `onRejected` may recover a failure by returning a
|
|
1065
|
+
* replacement config; returning `undefined` (or throwing) lets the failure
|
|
1066
|
+
* through, and the request is then never sent.
|
|
1067
|
+
*/
|
|
1068
|
+
function BeforeRequest(onFulfilled, onRejected) {
|
|
1069
|
+
assertCallback(onFulfilled, "BeforeRequest");
|
|
1070
|
+
if (onRejected !== void 0) assertCallback(onRejected, "BeforeRequest");
|
|
1071
|
+
const entry = {
|
|
1072
|
+
onFulfilled,
|
|
1073
|
+
onRejected
|
|
1074
|
+
};
|
|
1075
|
+
return dualDecorator(addBeforeOnClass(entry), addBeforeOnMethod(entry));
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Run `onFulfilled` against the response of this class / this method.
|
|
1079
|
+
*
|
|
1080
|
+
* The callback receives the axios response, **not** a `next`: unlike
|
|
1081
|
+
* `beforeRequest`, this phase is an unwind hook, so the response already exists
|
|
1082
|
+
* and nothing here decides whether the request happens. Return a replacement
|
|
1083
|
+
* response, or `undefined` to keep the one that was passed in.
|
|
1084
|
+
*/
|
|
1085
|
+
function AfterResponse(onFulfilled, onRejected) {
|
|
1086
|
+
assertCallback(onFulfilled, "AfterResponse");
|
|
1087
|
+
if (onRejected !== void 0) assertCallback(onRejected, "AfterResponse");
|
|
1088
|
+
const entry = {
|
|
1089
|
+
onFulfilled,
|
|
1090
|
+
onRejected
|
|
1091
|
+
};
|
|
1092
|
+
return dualDecorator(addAfterOnClass(entry), addAfterOnMethod(entry));
|
|
1093
|
+
}
|
|
1094
|
+
/** Class-level `@BeforeRequest` entries, in application order. */
|
|
1095
|
+
function classBeforeEntries(target) {
|
|
1096
|
+
return getClassMetadata(BEFORE_KEY, target) ?? [];
|
|
1097
|
+
}
|
|
1098
|
+
/** Method-level `@BeforeRequest` entries, in application order. */
|
|
1099
|
+
function methodBeforeEntries(target, methodName) {
|
|
1100
|
+
return getMethodMetadata(BEFORE_KEY, target, methodName) ?? [];
|
|
1101
|
+
}
|
|
1102
|
+
/** Class-level `@AfterResponse` entries, in application order. */
|
|
1103
|
+
function classAfterEntries(target) {
|
|
1104
|
+
return getClassMetadata(AFTER_KEY, target) ?? [];
|
|
1105
|
+
}
|
|
1106
|
+
/** Method-level `@AfterResponse` entries, in application order. */
|
|
1107
|
+
function methodAfterEntries(target, methodName) {
|
|
1108
|
+
return getMethodMetadata(AFTER_KEY, target, methodName) ?? [];
|
|
1109
|
+
}
|
|
1110
|
+
//#endregion
|
|
1111
|
+
//#region src/plugins/interceptor/manager.ts
|
|
1112
|
+
/**
|
|
1113
|
+
* Runtime registry of programmatic interceptors.
|
|
1114
|
+
*
|
|
1115
|
+
* Decorators cover interceptors that are known at class-definition time. This
|
|
1116
|
+
* class covers the other half — an application that decides at runtime "from now
|
|
1117
|
+
* on, sign every request" — and it is also what the plugin uses internally to
|
|
1118
|
+
* hold the server-wide entries passed to `Interceptor({ request })`.
|
|
1119
|
+
*
|
|
1120
|
+
* Entries keep insertion order: that order *is* the execution order, since the
|
|
1121
|
+
* interceptor contract is deliberately sequential rather than priority based.
|
|
1122
|
+
*/
|
|
1123
|
+
var InterceptorManager = class {
|
|
1124
|
+
constructor() {
|
|
1125
|
+
this.registry = /* @__PURE__ */ new Map();
|
|
1126
|
+
this.sequence = 0;
|
|
1127
|
+
}
|
|
1128
|
+
/** Register an entry. Returns the id {@link eject} needs to remove it again. */
|
|
1129
|
+
use(entry) {
|
|
1130
|
+
if (!entry || typeof entry.onFulfilled !== "function" && typeof entry.onRejected !== "function") throw new SnailPluginError("[snail] InterceptorManager.use() expects an entry with onFulfilled and/or onRejected", { pluginName: "interceptor" });
|
|
1131
|
+
this.sequence += 1;
|
|
1132
|
+
this.registry.set(this.sequence, entry);
|
|
1133
|
+
return this.sequence;
|
|
1134
|
+
}
|
|
1135
|
+
/** Remove an entry by the id returned from {@link use}. Returns whether it existed. */
|
|
1136
|
+
eject(id) {
|
|
1137
|
+
return this.registry.delete(id);
|
|
1138
|
+
}
|
|
1139
|
+
/** Drop every entry. */
|
|
1140
|
+
clear() {
|
|
1141
|
+
this.registry.clear();
|
|
1142
|
+
}
|
|
1143
|
+
/** Registered entries, in execution order. */
|
|
1144
|
+
get entries() {
|
|
1145
|
+
return [...this.registry.values()];
|
|
1146
|
+
}
|
|
1147
|
+
/** Number of registered entries. */
|
|
1148
|
+
get size() {
|
|
1149
|
+
return this.registry.size;
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
//#endregion
|
|
1153
|
+
//#region src/plugins/interceptor/plugin.ts
|
|
1154
|
+
/**
|
|
1155
|
+
* The interceptor plugin.
|
|
1156
|
+
*
|
|
1157
|
+
* ## Where it sits in the pipeline
|
|
1158
|
+
*
|
|
1159
|
+
* `priority: 100` is the reserved interceptor band, so this plugin is the first
|
|
1160
|
+
* to see the request in forward order and the last to see the response in unwind
|
|
1161
|
+
* order. That is what lets `@BeforeRequest()` rewrite the config *before* the
|
|
1162
|
+
* cache plugin (`-100`) hashes the final url, params and body into a cache key.
|
|
1163
|
+
*
|
|
1164
|
+
* ## Request vs response
|
|
1165
|
+
*
|
|
1166
|
+
* `beforeRequest` is a chain hook: the interceptors run first, and only then
|
|
1167
|
+
* does the hook hand control on with `next()`. An interceptor that fails
|
|
1168
|
+
* unrecoverably therefore stops the request before anything reaches the network.
|
|
1169
|
+
*
|
|
1170
|
+
* `afterResponse` is an **unwind** hook. The response already exists when it
|
|
1171
|
+
* runs, so calling `next()` *first* would let later plugins observe a response
|
|
1172
|
+
* the interceptors had not rewritten yet. The interceptors run against
|
|
1173
|
+
* `ctx.response` first and the chain is advanced afterwards. The interceptor
|
|
1174
|
+
* callbacks themselves are not chain hooks — they receive the response rather
|
|
1175
|
+
* than a `next` — so nothing inside one of them can advance or stop the chain.
|
|
1176
|
+
*/
|
|
1177
|
+
/** Plugin name; also the identity used by `Service.use()` / `Service.remove()`. */
|
|
1178
|
+
var INTERCEPTOR_PLUGIN_NAME = "interceptor";
|
|
1179
|
+
/** The reserved interceptor priority band (see `docs/guide/plugin-lifecycle.md` §2.1). */
|
|
1180
|
+
var INTERCEPTOR_PRIORITY = 100;
|
|
1181
|
+
/**
|
|
1182
|
+
* Create the interceptor plugin.
|
|
1183
|
+
*
|
|
1184
|
+
* ```ts
|
|
1185
|
+
* const interceptors = Interceptor({
|
|
1186
|
+
* request: [{ onFulfilled: (config) => { config.headers.set("x-app", "web"); } }]
|
|
1187
|
+
* });
|
|
1188
|
+
* Service.use(interceptors);
|
|
1189
|
+
* interceptors.request.use({ onFulfilled: (config, ctx) => ctx.logger.debug(ctx.fullName) });
|
|
1190
|
+
* ```
|
|
1191
|
+
*/
|
|
1192
|
+
function Interceptor(options) {
|
|
1193
|
+
const request = new InterceptorManager();
|
|
1194
|
+
const response = new InterceptorManager();
|
|
1195
|
+
for (const entry of options?.request ?? []) request.use(entry);
|
|
1196
|
+
for (const entry of options?.response ?? []) response.use(entry);
|
|
1197
|
+
const base = createPlugin({
|
|
1198
|
+
name: INTERCEPTOR_PLUGIN_NAME,
|
|
1199
|
+
priority: 100,
|
|
1200
|
+
setup(_pluginOptions, api) {
|
|
1201
|
+
api.addMessages({
|
|
1202
|
+
"info.interceptor.request": "[%s] %s request interceptor(s) applied",
|
|
1203
|
+
"info.interceptor.response": "[%s] %s response interceptor(s) applied"
|
|
1204
|
+
});
|
|
1205
|
+
return {
|
|
1206
|
+
beforeRequest: (ctx, next) => runRequestInterceptors(request, ctx, next),
|
|
1207
|
+
afterResponse: (ctx, next) => runResponseInterceptors(response, ctx, next)
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
})(options);
|
|
1211
|
+
return Object.assign(base, {
|
|
1212
|
+
request,
|
|
1213
|
+
response
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Run every request interceptor, then continue the chain.
|
|
1218
|
+
*
|
|
1219
|
+
* Order is class → method → server-wide, each list in its own application order.
|
|
1220
|
+
* One interceptor's failure is offered to that same interceptor's `onRejected`
|
|
1221
|
+
* before it is allowed to abort the request, which is the only recovery point in
|
|
1222
|
+
* the whole pipeline — `onError` merely observes.
|
|
1223
|
+
*/
|
|
1224
|
+
async function runRequestInterceptors(manager, ctx, next) {
|
|
1225
|
+
const entries = [
|
|
1226
|
+
...classBeforeEntries(ctx.apiClass),
|
|
1227
|
+
...methodBeforeEntries(ctx.apiClass, ctx.methodName),
|
|
1228
|
+
...manager.entries
|
|
1229
|
+
];
|
|
1230
|
+
if (entries.length > 0) ctx.logger.debug(t("info.interceptor.request", ctx.fullName, String(entries.length)));
|
|
1231
|
+
for (const entry of entries) {
|
|
1232
|
+
if (typeof entry.onFulfilled !== "function") continue;
|
|
1233
|
+
try {
|
|
1234
|
+
const replaced = await entry.onFulfilled(ctx.request, ctx);
|
|
1235
|
+
if (replaced !== void 0) ctx.setRequest(replaced);
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
ctx.setRequest(await recover(entry, error, ctx));
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
await next();
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Run every response interceptor against the response that already exists.
|
|
1244
|
+
*
|
|
1245
|
+
* The callbacks are not chain hooks: they get the response, not a `next`, so a
|
|
1246
|
+
* response interceptor can rewrite but never interrupt. The plugin's own
|
|
1247
|
+
* `afterResponse` hook still advances the chain at the end, which is what lets
|
|
1248
|
+
* lower-priority plugins (validation, transformation) see the rewritten value.
|
|
1249
|
+
*/
|
|
1250
|
+
async function runResponseInterceptors(manager, ctx, next) {
|
|
1251
|
+
const entries = [
|
|
1252
|
+
...classAfterEntries(ctx.apiClass),
|
|
1253
|
+
...methodAfterEntries(ctx.apiClass, ctx.methodName),
|
|
1254
|
+
...manager.entries
|
|
1255
|
+
];
|
|
1256
|
+
if (entries.length > 0) ctx.logger.debug(t("info.interceptor.response", ctx.fullName, String(entries.length)));
|
|
1257
|
+
for (const entry of entries) {
|
|
1258
|
+
if (typeof entry.onFulfilled !== "function") continue;
|
|
1259
|
+
try {
|
|
1260
|
+
const replaced = await entry.onFulfilled(ctx.requireResponse(), ctx);
|
|
1261
|
+
if (replaced !== void 0) ctx.setResponse(replaced);
|
|
1262
|
+
} catch (error) {
|
|
1263
|
+
ctx.setResponse(await recover(entry, error, ctx));
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
await next();
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Give an interceptor's `onRejected` a chance to recover from `error`.
|
|
1270
|
+
*
|
|
1271
|
+
* Only a *value* recovers: `undefined` means "I handled the notification but
|
|
1272
|
+
* have nothing to continue with", which must not silently produce an undefined
|
|
1273
|
+
* config. The original error is rethrown in that case, so the caller sees the
|
|
1274
|
+
* real cause rather than a downstream symptom.
|
|
1275
|
+
*/
|
|
1276
|
+
async function recover(entry, error, ctx) {
|
|
1277
|
+
if (typeof entry.onRejected !== "function") throw error;
|
|
1278
|
+
const recovered = await entry.onRejected(error, ctx);
|
|
1279
|
+
if (recovered === void 0) throw error;
|
|
1280
|
+
return recovered;
|
|
1281
|
+
}
|
|
1282
|
+
//#endregion
|
|
1283
|
+
//#region src/plugins/pool/type.ts
|
|
1284
|
+
/**
|
|
1285
|
+
* The error codes a request-pool refusal can carry.
|
|
1286
|
+
*
|
|
1287
|
+
* `isPoolError()` is the supported way to test them, but the strings are exported
|
|
1288
|
+
* so an application can switch on one precisely — for example to distinguish "the
|
|
1289
|
+
* queue was full, try again shortly" from "the user navigated away".
|
|
1290
|
+
*/
|
|
1291
|
+
var POOL_ERROR_CODES = {
|
|
1292
|
+
/** `maxQueue` was reached; the request was never sent. */
|
|
1293
|
+
queueFull: "SNAIL_POOL_QUEUE_FULL",
|
|
1294
|
+
/** The request waited longer than `queueTimeout` and was dropped. */
|
|
1295
|
+
queueTimeout: "SNAIL_POOL_QUEUE_TIMEOUT",
|
|
1296
|
+
/** The request was cancelled while it was still waiting for a slot. */
|
|
1297
|
+
aborted: "SNAIL_POOL_ABORTED",
|
|
1298
|
+
/** The queue was cleared — usually because the plugin was uninstalled. */
|
|
1299
|
+
cleared: "SNAIL_POOL_CLEARED"
|
|
1300
|
+
};
|
|
1301
|
+
/**
|
|
1302
|
+
* Raised when the pool refuses or drops a request.
|
|
1303
|
+
*
|
|
1304
|
+
* A dedicated class rather than a reuse of `SnailRequestError`: a pool refusal
|
|
1305
|
+
* means the request **never reached the network**, which is a materially different
|
|
1306
|
+
* situation from a transport failure. Retrying is safe and usually correct, the
|
|
1307
|
+
* payload is untouched, and no server state changed — a caller that cannot tell the
|
|
1308
|
+
* two apart will either retry a request the server already processed, or give up on
|
|
1309
|
+
* one that was merely queued behind a burst.
|
|
1310
|
+
*/
|
|
1311
|
+
var SnailPoolError = class extends SnailError {
|
|
1312
|
+
constructor(message, code, options = {}) {
|
|
1313
|
+
super(message, {
|
|
1314
|
+
code,
|
|
1315
|
+
cause: options.cause
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
//#endregion
|
|
1320
|
+
//#region src/plugins/pool/scheduler.ts
|
|
1321
|
+
/**
|
|
1322
|
+
* The scheduling core of the request pool.
|
|
1323
|
+
*
|
|
1324
|
+
* Deterministic and free of any request or plugin concept: it hands out `release`
|
|
1325
|
+
* callbacks and knows nothing about what they guard. That makes the hard part —
|
|
1326
|
+
* ordering, fairness and the guarantee that a slot is always returned — testable
|
|
1327
|
+
* on its own, without a server or a network.
|
|
1328
|
+
*
|
|
1329
|
+
* ## Messages come from the plugin
|
|
1330
|
+
*
|
|
1331
|
+
* The refusal messages are contributed by `RequestPool`'s `setup`, following the
|
|
1332
|
+
* plugin-owned-strings convention. Using this class **standalone** therefore yields
|
|
1333
|
+
* the raw message keys rather than translated text — the `code` on each
|
|
1334
|
+
* {@link SnailPoolError} is always correct, and that is what callers should branch
|
|
1335
|
+
* on. Registering a catalogue yourself is enough if you want the text too.
|
|
1336
|
+
*
|
|
1337
|
+
* ## The invariant that matters
|
|
1338
|
+
*
|
|
1339
|
+
* `active` must never drift from the number of outstanding tickets. Every failure
|
|
1340
|
+
* path (a queue that is full, a wait that times out, a request that is aborted,
|
|
1341
|
+
* a waiter dropped by `clear`) either never increments `active` or has already
|
|
1342
|
+
* been counted, and `release()` is idempotent so a double release from a `finally`
|
|
1343
|
+
* plus an abort listener cannot leak a slot. A leaked slot is invisible until the
|
|
1344
|
+
* pool permanently deadlocks at its ceiling, at which point every later request
|
|
1345
|
+
* hangs — which is why it is enforced here rather than at the call site.
|
|
1346
|
+
*/
|
|
1347
|
+
var RequestPoolScheduler = class {
|
|
1348
|
+
constructor(options = {}) {
|
|
1349
|
+
this.active = 0;
|
|
1350
|
+
this.sequence = 0;
|
|
1351
|
+
this.waiters = [];
|
|
1352
|
+
const concurrency = Number.isFinite(options.concurrency) ? Math.floor(options.concurrency) : 6;
|
|
1353
|
+
this.concurrency = Math.max(1, concurrency);
|
|
1354
|
+
this.maxQueue = Number.isFinite(options.maxQueue) ? Math.max(0, Math.floor(options.maxQueue)) : Number.POSITIVE_INFINITY;
|
|
1355
|
+
this.queueTimeout = Math.max(0, options.queueTimeout ?? 0);
|
|
1356
|
+
this.priorityOf = options.priority;
|
|
1357
|
+
}
|
|
1358
|
+
/** Current counters. */
|
|
1359
|
+
get stats() {
|
|
1360
|
+
return {
|
|
1361
|
+
active: this.active,
|
|
1362
|
+
queued: this.waiters.length,
|
|
1363
|
+
concurrency: this.concurrency
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
/** Change the ceiling at runtime; raising it immediately admits queued work. */
|
|
1367
|
+
setConcurrency(value) {
|
|
1368
|
+
if (!Number.isFinite(value)) return;
|
|
1369
|
+
this.concurrency = Math.max(1, Math.floor(value));
|
|
1370
|
+
this.pump();
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Take a slot, or wait for one.
|
|
1374
|
+
*
|
|
1375
|
+
* Resolves with a ticket whose `release()` returns the slot. Rejects when the
|
|
1376
|
+
* queue is full or the wait exceeds `queueTimeout`. `signal` lets an abandoned
|
|
1377
|
+
* request leave the queue instead of holding a place it will never use.
|
|
1378
|
+
*/
|
|
1379
|
+
acquire(ctx, signal) {
|
|
1380
|
+
if (signal?.aborted) return Promise.reject(this.abortError());
|
|
1381
|
+
if (this.active < this.concurrency) return Promise.resolve(this.issue());
|
|
1382
|
+
if (this.waiters.length >= this.maxQueue) return Promise.reject(new SnailPoolError(t("error.pool.queueFull", this.waiters.length), POOL_ERROR_CODES.queueFull));
|
|
1383
|
+
return new Promise((resolve, reject) => {
|
|
1384
|
+
let timer;
|
|
1385
|
+
let onAbort;
|
|
1386
|
+
let done = false;
|
|
1387
|
+
const waiter = {
|
|
1388
|
+
priority: this.priorityOfFor(ctx),
|
|
1389
|
+
sequence: this.sequence++,
|
|
1390
|
+
settle: (ticket) => {
|
|
1391
|
+
if (done) return;
|
|
1392
|
+
done = true;
|
|
1393
|
+
waiter.dispose();
|
|
1394
|
+
resolve(ticket);
|
|
1395
|
+
},
|
|
1396
|
+
fail: (error) => {
|
|
1397
|
+
if (done) return;
|
|
1398
|
+
done = true;
|
|
1399
|
+
waiter.dispose();
|
|
1400
|
+
reject(error);
|
|
1401
|
+
},
|
|
1402
|
+
dispose: () => {
|
|
1403
|
+
if (timer) clearTimeout(timer);
|
|
1404
|
+
if (onAbort) signal?.removeEventListener?.("abort", onAbort);
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
if (this.queueTimeout > 0) {
|
|
1408
|
+
timer = setTimeout(() => {
|
|
1409
|
+
remove(this.waiters, waiter);
|
|
1410
|
+
waiter.fail(new SnailPoolError(t("error.pool.queueTimeout", this.queueTimeout), POOL_ERROR_CODES.queueTimeout));
|
|
1411
|
+
}, this.queueTimeout);
|
|
1412
|
+
timer.unref?.();
|
|
1413
|
+
}
|
|
1414
|
+
if (signal && typeof signal.addEventListener === "function") {
|
|
1415
|
+
onAbort = () => {
|
|
1416
|
+
remove(this.waiters, waiter);
|
|
1417
|
+
waiter.fail(this.abortError());
|
|
1418
|
+
};
|
|
1419
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1420
|
+
}
|
|
1421
|
+
this.waiters.push(waiter);
|
|
1422
|
+
this.sort();
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Drop every waiter, rejecting each with `reason`.
|
|
1427
|
+
*
|
|
1428
|
+
* Used on uninstall: a queued request must not sit forever waiting for a pool
|
|
1429
|
+
* that will never admit it.
|
|
1430
|
+
*/
|
|
1431
|
+
clear(reason) {
|
|
1432
|
+
const pending = this.waiters.splice(0, this.waiters.length);
|
|
1433
|
+
for (const waiter of pending) waiter.fail(reason ?? new SnailPoolError(t("error.pool.cleared"), POOL_ERROR_CODES.cleared));
|
|
1434
|
+
}
|
|
1435
|
+
/** Hand out one slot. */
|
|
1436
|
+
issue() {
|
|
1437
|
+
this.active += 1;
|
|
1438
|
+
let released = false;
|
|
1439
|
+
return { release: () => {
|
|
1440
|
+
if (released) return;
|
|
1441
|
+
released = true;
|
|
1442
|
+
this.active = Math.max(0, this.active - 1);
|
|
1443
|
+
this.pump();
|
|
1444
|
+
} };
|
|
1445
|
+
}
|
|
1446
|
+
/** Admit as many waiters as there are free slots. */
|
|
1447
|
+
pump() {
|
|
1448
|
+
while (this.active < this.concurrency && this.waiters.length > 0) this.waiters.shift().settle(this.issue());
|
|
1449
|
+
}
|
|
1450
|
+
/** Lowest priority number first, then arrival order. */
|
|
1451
|
+
sort() {
|
|
1452
|
+
this.waiters.sort((a, b) => a.priority === b.priority ? a.sequence - b.sequence : a.priority - b.priority);
|
|
1453
|
+
}
|
|
1454
|
+
priorityOfFor(ctx) {
|
|
1455
|
+
if (!this.priorityOf) return 0;
|
|
1456
|
+
try {
|
|
1457
|
+
const value = this.priorityOf(ctx);
|
|
1458
|
+
return Number.isFinite(value) ? value : 0;
|
|
1459
|
+
} catch {
|
|
1460
|
+
return 0;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
abortError() {
|
|
1464
|
+
return new SnailPoolError(t("error.pool.aborted"), POOL_ERROR_CODES.aborted);
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
/** Remove one waiter by identity, wherever it sits in the queue. */
|
|
1468
|
+
function remove(waiters, waiter) {
|
|
1469
|
+
const index = waiters.indexOf(waiter);
|
|
1470
|
+
if (index !== -1) waiters.splice(index, 1);
|
|
1471
|
+
}
|
|
1472
|
+
//#endregion
|
|
1473
|
+
//#region src/plugins/pool/plugin.ts
|
|
1474
|
+
/** Plugin name; also the identity used by `Service.use()` / `Service.remove()`. */
|
|
1475
|
+
var POOL_PLUGIN_NAME = "pool";
|
|
1476
|
+
/**
|
|
1477
|
+
* Priority of the pool band.
|
|
1478
|
+
*
|
|
1479
|
+
* **Below** the cache (`-100`), which is the whole point: forward hooks run
|
|
1480
|
+
* highest-priority first, so the pool is the very last thing to run before the
|
|
1481
|
+
* transport. A request that the cache can answer never reaches the pool and never
|
|
1482
|
+
* consumes a slot. Putting the pool above the cache would let a handful of cached
|
|
1483
|
+
* reads occupy the entire pool and starve the real requests behind them.
|
|
1484
|
+
*/
|
|
1485
|
+
var POOL_PRIORITY = -150;
|
|
1486
|
+
/**
|
|
1487
|
+
* Bound how many requests are in flight at once.
|
|
1488
|
+
*
|
|
1489
|
+
* ```ts
|
|
1490
|
+
* Service.use(RequestPool({ concurrency: 4, maxQueue: 50, queueTimeout: 10_000 }));
|
|
1491
|
+
*
|
|
1492
|
+
* // `ctx.state` is readable from the priority callback, so an interactive request
|
|
1493
|
+
* // can jump ahead of a background prefetch.
|
|
1494
|
+
* Service.use(RequestPool({ concurrency: 4, priority: (ctx) => (ctx.state.get("prefetch") ? 100 : 0) }));
|
|
1495
|
+
* ```
|
|
1496
|
+
*
|
|
1497
|
+
* ## What problem this solves
|
|
1498
|
+
*
|
|
1499
|
+
* See {@link RequestPoolOptions} for why the browser's own queue is not enough.
|
|
1500
|
+
* In short: the built-in one is FIFO, invisible and unprioritised, so a burst from
|
|
1501
|
+
* one screen can starve the request the user is actually waiting for, and nothing
|
|
1502
|
+
* fails fast when the backlog grows without bound.
|
|
1503
|
+
*
|
|
1504
|
+
* ## Where it sits
|
|
1505
|
+
*
|
|
1506
|
+
* Last in forward order, immediately before the network call, and it holds its slot
|
|
1507
|
+
* only for the transport — `next()` resolves once the response has been received,
|
|
1508
|
+
* so validation, transformation and the caller's reactive updates happen after the
|
|
1509
|
+
* slot is already back in the pool.
|
|
1510
|
+
*
|
|
1511
|
+
* A cache hit short-circuits `beforeRequest` upstream of this plugin, so cached
|
|
1512
|
+
* reads cost no concurrency.
|
|
1513
|
+
*
|
|
1514
|
+
* ## One path it does not cover
|
|
1515
|
+
*
|
|
1516
|
+
* `useTokenAuth` replays a request that came back `401` by re-running the transport
|
|
1517
|
+
* directly, deliberately without re-entering `beforeRequest` (re-entering would
|
|
1518
|
+
* loop). Those replays therefore **do not pass through this pool** and are not
|
|
1519
|
+
* counted against `concurrency`.
|
|
1520
|
+
*
|
|
1521
|
+
* The practical impact is small — replays are bounded by the number of requests
|
|
1522
|
+
* that were in flight when the token expired, which were already uncapped — but it
|
|
1523
|
+
* is worth knowing before reaching for the pool to protect a backend from an auth
|
|
1524
|
+
* storm. If that matters, cap it at the source: use `useRequest`'s own concurrency
|
|
1525
|
+
* control, or let `useTokenAuth` refresh proactively rather than on the first 401.
|
|
1526
|
+
*/
|
|
1527
|
+
function RequestPool(options) {
|
|
1528
|
+
let scheduler;
|
|
1529
|
+
const base = createPlugin({
|
|
1530
|
+
name: POOL_PLUGIN_NAME,
|
|
1531
|
+
priority: POOL_PRIORITY,
|
|
1532
|
+
setup(_pluginOptions, api) {
|
|
1533
|
+
const instance = new RequestPoolScheduler(options);
|
|
1534
|
+
scheduler = instance;
|
|
1535
|
+
api.addMessages({
|
|
1536
|
+
"error.pool.queueFull": "请求池队列已满(等待中 %s 个),请稍后重试",
|
|
1537
|
+
"error.pool.queueTimeout": "请求池排队超时(已等待 %sms)",
|
|
1538
|
+
"error.pool.cleared": "请求池已清空,排队的请求被取消",
|
|
1539
|
+
"error.pool.aborted": "请求在排队期间被取消"
|
|
1540
|
+
});
|
|
1541
|
+
api.onDispose(() => instance.clear());
|
|
1542
|
+
return { async beforeRequest(ctx, next) {
|
|
1543
|
+
const signal = ctx.request.signal ?? void 0;
|
|
1544
|
+
const ticket = await instance.acquire(ctx, signal);
|
|
1545
|
+
try {
|
|
1546
|
+
await next();
|
|
1547
|
+
} finally {
|
|
1548
|
+
ticket.release();
|
|
1549
|
+
}
|
|
1550
|
+
} };
|
|
1551
|
+
}
|
|
1552
|
+
})(options);
|
|
1553
|
+
return Object.defineProperty(base, "scheduler", {
|
|
1554
|
+
enumerable: true,
|
|
1555
|
+
configurable: true,
|
|
1556
|
+
get: () => scheduler
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
/** Read the live counters of an installed pool plugin, or `undefined`. */
|
|
1560
|
+
function poolStats(plugin) {
|
|
1561
|
+
return plugin.scheduler?.stats;
|
|
1562
|
+
}
|
|
1563
|
+
/** Drop every queued request of an installed pool plugin. */
|
|
1564
|
+
function clearPool(plugin, reason) {
|
|
1565
|
+
plugin.scheduler?.clear(reason);
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* `true` when `error` came from the pool rather than from the transport.
|
|
1569
|
+
*
|
|
1570
|
+
* Lets a caller tell "refused before it was ever sent" — safe to retry later —
|
|
1571
|
+
* apart from a real network failure. Covers all four refusal paths: a full queue,
|
|
1572
|
+
* a queue wait that timed out, a request abandoned while waiting, and a queue
|
|
1573
|
+
* cleared by an uninstall.
|
|
1574
|
+
*/
|
|
1575
|
+
function isPoolError(error) {
|
|
1576
|
+
return error instanceof SnailPoolError;
|
|
1577
|
+
}
|
|
1578
|
+
//#endregion
|
|
1579
|
+
//#region src/plugins/transform/decorators.ts
|
|
1580
|
+
/**
|
|
1581
|
+
* Transform decorators.
|
|
1582
|
+
*
|
|
1583
|
+
* `@Transform(DtoClass)` chooses the class a response is hydrated into;
|
|
1584
|
+
* `@PropertyType()` and `@ExposeName()` describe the shape of that class. All
|
|
1585
|
+
* three are metadata-only — they never touch a request or a server, so a DTO
|
|
1586
|
+
* shared between two api classes cannot leak state from one to the other.
|
|
1587
|
+
*/
|
|
1588
|
+
/** Metadata slot written by `@Transform`. */
|
|
1589
|
+
var TRANSFORM_KEY = customMetadataKey("transform/dto");
|
|
1590
|
+
/** Metadata slot written by `@PropertyType`. */
|
|
1591
|
+
var PROPERTY_TYPE_KEY = customMetadataKey("transform/property-type");
|
|
1592
|
+
/** Metadata slot written by `@ExposeName`. */
|
|
1593
|
+
var EXPOSE_NAME_KEY = customMetadataKey("transform/expose-name");
|
|
1594
|
+
/**
|
|
1595
|
+
* Choose the DTO a response payload is hydrated into.
|
|
1596
|
+
*
|
|
1597
|
+
* Applies to an api class and to a single method; the method wins, so one
|
|
1598
|
+
* endpoint can answer with a different shape than its siblings.
|
|
1599
|
+
*
|
|
1600
|
+
* ```ts
|
|
1601
|
+
* @Api("/user")
|
|
1602
|
+
* @Transform(UserDto)
|
|
1603
|
+
* class UserApi {
|
|
1604
|
+
* @Get("/:id")
|
|
1605
|
+
* getUser(@Params("id") id: string): Promise<UserDto> { return null!; }
|
|
1606
|
+
* }
|
|
1607
|
+
* ```
|
|
1608
|
+
*/
|
|
1609
|
+
function Transform$1(dto) {
|
|
1610
|
+
if (typeof dto !== "function") throw new SnailDecoratorError("[snail] @Transform() expects a DTO class, not an instance or a plain object");
|
|
1611
|
+
return ((target, propertyKey) => {
|
|
1612
|
+
defineMetadata(TRANSFORM_KEY, dto, target, propertyKey);
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
var definePropertyType = createPropertyDecorator("transform/property-type");
|
|
1616
|
+
/**
|
|
1617
|
+
* Declare the runtime type of one DTO property.
|
|
1618
|
+
*
|
|
1619
|
+
* ```ts
|
|
1620
|
+
* class OrderDto {
|
|
1621
|
+
* @PropertyType(() => UserDto) user!: UserDto;
|
|
1622
|
+
* @PropertyType(() => ItemDto, { array: true }) items!: ItemDto[];
|
|
1623
|
+
* @PropertyType(() => Date) createdAt!: Date;
|
|
1624
|
+
* }
|
|
1625
|
+
* ```
|
|
1626
|
+
*
|
|
1627
|
+
* A property without `@PropertyType` is assigned as-is, so primitives, plain
|
|
1628
|
+
* objects and arrays of primitives need no declaration — but they *do* need
|
|
1629
|
+
* something that makes the property known, otherwise the unknown-key rule drops
|
|
1630
|
+
* them.
|
|
1631
|
+
*/
|
|
1632
|
+
function PropertyType(type, options) {
|
|
1633
|
+
if (typeof type !== "function") throw new SnailDecoratorError("[snail] @PropertyType() expects a lazy type resolver, e.g. @PropertyType(() => ChildDto)");
|
|
1634
|
+
return definePropertyType(options ? {
|
|
1635
|
+
type,
|
|
1636
|
+
options
|
|
1637
|
+
} : { type });
|
|
1638
|
+
}
|
|
1639
|
+
var defineExposeName = createPropertyDecorator("transform/expose-name");
|
|
1640
|
+
/**
|
|
1641
|
+
* Read a property from a differently-named JSON key.
|
|
1642
|
+
*
|
|
1643
|
+
* ```ts
|
|
1644
|
+
* class UserDto {
|
|
1645
|
+
* @ExposeName("user_name") userName!: string;
|
|
1646
|
+
* }
|
|
1647
|
+
* // { "user_name": "ada" } → new UserDto().userName === "ada"
|
|
1648
|
+
* ```
|
|
1649
|
+
*
|
|
1650
|
+
* Excluding a key needs no decorator: undeclared JSON keys are already dropped.
|
|
1651
|
+
*/
|
|
1652
|
+
function ExposeName(jsonKey) {
|
|
1653
|
+
if (typeof jsonKey !== "string" || jsonKey.length === 0) throw new SnailDecoratorError("[snail] @ExposeName() requires a non-empty JSON key");
|
|
1654
|
+
return defineExposeName(jsonKey);
|
|
1655
|
+
}
|
|
1656
|
+
/** Read the DTO of one method: method first, then the api class. */
|
|
1657
|
+
function resolveDto(apiClass, methodName) {
|
|
1658
|
+
return getMetadata(TRANSFORM_KEY, apiClass, methodName) ?? getMetadata(TRANSFORM_KEY, apiClass);
|
|
1659
|
+
}
|
|
1660
|
+
/** `true` for an own (not inherited) property. */
|
|
1661
|
+
function hasOwn(target, key) {
|
|
1662
|
+
return Object.prototype.hasOwnProperty.call(target, key);
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Revive a JSON date.
|
|
1666
|
+
*
|
|
1667
|
+
* A string that `new Date()` rejects is returned unchanged: an `Invalid Date` is
|
|
1668
|
+
* truthy, serialises to `null` and fails every later check somewhere far from the
|
|
1669
|
+
* cause, so keeping the original string at least leaves the bug where it is.
|
|
1670
|
+
*/
|
|
1671
|
+
function reviveDate(value) {
|
|
1672
|
+
if (value instanceof Date) return value;
|
|
1673
|
+
if (typeof value !== "string" && typeof value !== "number") return value;
|
|
1674
|
+
const date = new Date(value);
|
|
1675
|
+
return Number.isNaN(date.getTime()) ? value : date;
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* The property names a DTO declares.
|
|
1679
|
+
*
|
|
1680
|
+
* Two sources, and both are needed:
|
|
1681
|
+
* - the property decorators, which are the only way to see a `field!: T`
|
|
1682
|
+
* declaration (TypeScript erases those at compile time);
|
|
1683
|
+
* - the instance's own keys, which cover initialised fields and constructor
|
|
1684
|
+
* assignments and let a partially decorated DTO keep working.
|
|
1685
|
+
*
|
|
1686
|
+
* An empty result means the class declares nothing at all — see
|
|
1687
|
+
* {@link buildInstance}.
|
|
1688
|
+
*/
|
|
1689
|
+
function knownPropertyNames(DtoClass, instance) {
|
|
1690
|
+
const names = /* @__PURE__ */ new Set([...collectMethodKeys(PROPERTY_TYPE_KEY, DtoClass), ...collectMethodKeys(EXPOSE_NAME_KEY, DtoClass)]);
|
|
1691
|
+
for (const key of Object.keys(instance)) names.add(key);
|
|
1692
|
+
return names;
|
|
1693
|
+
}
|
|
1694
|
+
/** Shallow-copy every own key of the JSON onto the instance. */
|
|
1695
|
+
function copyOwnKeys(target, source) {
|
|
1696
|
+
for (const key of Object.keys(source)) target[key] = source[key];
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Hydrate one property value using the type declared for it.
|
|
1700
|
+
*
|
|
1701
|
+
* `childDepth` is the depth of `value` itself, so the depth guard in
|
|
1702
|
+
* {@link hydrateInto} can stop the descent before the stack does.
|
|
1703
|
+
*/
|
|
1704
|
+
function hydrateProperty(value, spec, options, childDepth, seen) {
|
|
1705
|
+
if (!spec || value === null || value === void 0) return value;
|
|
1706
|
+
const resolved = spec.type();
|
|
1707
|
+
if (spec.options?.array === true) {
|
|
1708
|
+
if (!Array.isArray(value)) return value;
|
|
1709
|
+
return value.map((item) => hydrateValue(item, resolved, options, childDepth, seen));
|
|
1710
|
+
}
|
|
1711
|
+
return hydrateValue(value, resolved, options, childDepth, seen);
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* Hydrate a single value against a resolved constructor.
|
|
1715
|
+
*
|
|
1716
|
+
* Primitives need no work — `JSON.parse` already produced the right JavaScript
|
|
1717
|
+
* type — and a non-function resolver is treated as "no type declared" rather than
|
|
1718
|
+
* a crash, because a typo in a decorator must not break every response.
|
|
1719
|
+
*/
|
|
1720
|
+
function hydrateValue(value, resolved, options, depth, seen) {
|
|
1721
|
+
if (value === null || value === void 0) return value;
|
|
1722
|
+
if (resolved === Date) return reviveDate(value);
|
|
1723
|
+
if (resolved === String || resolved === Number || resolved === Boolean) return value;
|
|
1724
|
+
if (typeof resolved !== "function") return value;
|
|
1725
|
+
return hydrateInto(value, resolved, options, depth, seen);
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Build one instance, honouring `fromJSON`, the declared properties and
|
|
1729
|
+
* `keepUnknown`.
|
|
1730
|
+
*/
|
|
1731
|
+
function buildInstance(raw, DtoClass, options, depth, seen) {
|
|
1732
|
+
if (typeof DtoClass.fromJSON === "function") return DtoClass.fromJSON(raw, options.ctx);
|
|
1733
|
+
const instance = new DtoClass();
|
|
1734
|
+
const known = knownPropertyNames(DtoClass, instance);
|
|
1735
|
+
if (known.size === 0) {
|
|
1736
|
+
copyOwnKeys(instance, raw);
|
|
1737
|
+
return instance;
|
|
1738
|
+
}
|
|
1739
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
1740
|
+
for (const property of known) {
|
|
1741
|
+
const jsonKey = getMetadata(EXPOSE_NAME_KEY, DtoClass, property) ?? property;
|
|
1742
|
+
if (!hasOwn(raw, jsonKey)) continue;
|
|
1743
|
+
consumed.add(jsonKey);
|
|
1744
|
+
const spec = getMetadata(PROPERTY_TYPE_KEY, DtoClass, property);
|
|
1745
|
+
instance[property] = hydrateProperty(raw[jsonKey], spec, options, depth + 1, seen);
|
|
1746
|
+
}
|
|
1747
|
+
if (options.keepUnknown) for (const key of Object.keys(raw)) {
|
|
1748
|
+
if (consumed.has(key)) continue;
|
|
1749
|
+
instance[key] = raw[key];
|
|
1750
|
+
}
|
|
1751
|
+
return instance;
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Recursive worker behind {@link hydrate}.
|
|
1755
|
+
*
|
|
1756
|
+
* Three guards, each fixing a way this could hang or lie:
|
|
1757
|
+
* - a non-object is returned unchanged, so a primitive payload passes through;
|
|
1758
|
+
* - `depth > maxDepth` stops a self-referencing `@PropertyType` chain;
|
|
1759
|
+
* - `seen` stops a *cyclic* JSON graph, which depth alone would only delay
|
|
1760
|
+
* (branching recursion is exponential, not linear).
|
|
1761
|
+
*/
|
|
1762
|
+
function hydrateInto(raw, DtoClass, options, depth, seen) {
|
|
1763
|
+
if (raw === null || typeof raw !== "object") return raw;
|
|
1764
|
+
if (depth > options.maxDepth) return raw;
|
|
1765
|
+
if (Array.isArray(raw)) return raw.map((item) => hydrateInto(item, DtoClass, options, depth + 1, seen));
|
|
1766
|
+
if (seen.has(raw)) return raw;
|
|
1767
|
+
seen.add(raw);
|
|
1768
|
+
try {
|
|
1769
|
+
return buildInstance(raw, DtoClass, options, depth, seen);
|
|
1770
|
+
} finally {
|
|
1771
|
+
seen.delete(raw);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Turn a plain JSON payload into an instance of `DtoClass`.
|
|
1776
|
+
*
|
|
1777
|
+
* A primitive, `null`, an unknown class or an over-deep value is returned
|
|
1778
|
+
* unchanged rather than wrapped: the caller asked for a class, but a response that
|
|
1779
|
+
* does not look like one is more useful as-is than as an empty instance.
|
|
1780
|
+
*
|
|
1781
|
+
* ```ts
|
|
1782
|
+
* const user = hydrate(raw, UserDto);
|
|
1783
|
+
* user instanceof UserDto; // true
|
|
1784
|
+
* ```
|
|
1785
|
+
*/
|
|
1786
|
+
function hydrate(raw, DtoClass, options = {}) {
|
|
1787
|
+
return hydrateInto(raw, DtoClass, {
|
|
1788
|
+
keepUnknown: options.keepUnknown ?? false,
|
|
1789
|
+
maxDepth: options.maxDepth ?? 32,
|
|
1790
|
+
ctx: options.ctx
|
|
1791
|
+
}, 0, /* @__PURE__ */ new WeakSet());
|
|
1792
|
+
}
|
|
1793
|
+
//#endregion
|
|
1794
|
+
//#region src/plugins/transform/plugin.ts
|
|
1795
|
+
/**
|
|
1796
|
+
* Hydrate the live response payload in place (by replacing the response body).
|
|
1797
|
+
*
|
|
1798
|
+
* A hydration failure must not corrupt the response: a throwing DTO constructor or
|
|
1799
|
+
* a broken `fromJSON` is reported and the raw JSON is left exactly as it arrived,
|
|
1800
|
+
* because a partially hydrated body is worse than an unhydrated one.
|
|
1801
|
+
*/
|
|
1802
|
+
function transformResponse(ctx, Dto, options) {
|
|
1803
|
+
const response = ctx.getResponse();
|
|
1804
|
+
if (!response) return;
|
|
1805
|
+
const { dataKey } = ctx.serverOptions;
|
|
1806
|
+
try {
|
|
1807
|
+
const envelope = response.data;
|
|
1808
|
+
const hydrated = hydrate(unwrapEnvelope(envelope, dataKey), Dto, {
|
|
1809
|
+
keepUnknown: options.keepUnknown,
|
|
1810
|
+
maxDepth: options.maxDepth,
|
|
1811
|
+
ctx
|
|
1812
|
+
});
|
|
1813
|
+
ctx.setResponse({
|
|
1814
|
+
...response,
|
|
1815
|
+
data: looksLikeEnvelope(envelope, dataKey) ? {
|
|
1816
|
+
...envelope,
|
|
1817
|
+
[dataKey]: hydrated
|
|
1818
|
+
} : hydrated
|
|
1819
|
+
});
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
ctx.logger.warn(t("error.plugin.transform", ctx.fullName, String(error)));
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Create the transform plugin.
|
|
1826
|
+
*
|
|
1827
|
+
* ```ts
|
|
1828
|
+
* Service.use(Transform());
|
|
1829
|
+
*
|
|
1830
|
+
* @Api("/user")
|
|
1831
|
+
* @Transform(UserDto)
|
|
1832
|
+
* class UserApi {
|
|
1833
|
+
* @Get("/:id")
|
|
1834
|
+
* getUser(@Params("id") id: string): Promise<UserDto> { return null!; }
|
|
1835
|
+
* }
|
|
1836
|
+
* ```
|
|
1837
|
+
*
|
|
1838
|
+
* Without a DTO — no decorator and no `dto` option — the plugin is a no-op and the
|
|
1839
|
+
* payload stays the plain object JSON.parse produced.
|
|
1840
|
+
*/
|
|
1841
|
+
var transformPlugin = createPlugin({
|
|
1842
|
+
name: "transform",
|
|
1843
|
+
priority: 0,
|
|
1844
|
+
setup(options) {
|
|
1845
|
+
const fallbackDto = options?.dto;
|
|
1846
|
+
const resolved = {
|
|
1847
|
+
keepUnknown: options?.keepUnknown ?? false,
|
|
1848
|
+
maxDepth: options?.maxDepth ?? 32
|
|
1849
|
+
};
|
|
1850
|
+
return { afterResponse(ctx, next) {
|
|
1851
|
+
const Dto = resolveDto(ctx.apiClass, ctx.methodName) ?? fallbackDto;
|
|
1852
|
+
if (Dto) transformResponse(ctx, Dto, resolved);
|
|
1853
|
+
return next();
|
|
1854
|
+
} };
|
|
1855
|
+
}
|
|
1856
|
+
});
|
|
1857
|
+
//#endregion
|
|
1858
|
+
//#region src/plugins/transform/index.ts
|
|
1859
|
+
function Transform(input) {
|
|
1860
|
+
return typeof input === "function" ? Transform$1(input) : transformPlugin(input);
|
|
1861
|
+
}
|
|
1862
|
+
//#endregion
|
|
1863
|
+
//#region src/plugins/validate/decorators.ts
|
|
1864
|
+
/**
|
|
1865
|
+
* `@Validate(schema)` / `@ValidateResponse(schema)` — attach a zod schema to a
|
|
1866
|
+
* request or to a response body.
|
|
1867
|
+
*
|
|
1868
|
+
* Both work on an api class and on a single method. A class-level schema applies
|
|
1869
|
+
* to every method of the class; a method-level one overrides it, which is the only
|
|
1870
|
+
* ordering that lets one endpoint deviate without redeclaring the class default on
|
|
1871
|
+
* every sibling.
|
|
1872
|
+
*/
|
|
1873
|
+
/** Metadata slot written by `@Validate`. */
|
|
1874
|
+
var REQUEST_SCHEMA_KEY = customMetadataKey("validate/request");
|
|
1875
|
+
/** Metadata slot written by `@ValidateResponse`. */
|
|
1876
|
+
var RESPONSE_SCHEMA_KEY = customMetadataKey("validate/response");
|
|
1877
|
+
/**
|
|
1878
|
+
* Build a class/method decorator that stores one schema.
|
|
1879
|
+
*
|
|
1880
|
+
* The value is checked at decoration time, not at request time: a typo like
|
|
1881
|
+
* `@Validate({})` would otherwise stay silent until a request ran, and then read
|
|
1882
|
+
* as "the payload is malformed" instead of "the decorator is wrong".
|
|
1883
|
+
*/
|
|
1884
|
+
function schemaDecorator(key, label) {
|
|
1885
|
+
return (schema) => {
|
|
1886
|
+
if (!schema || typeof schema.safeParse !== "function") throw new SnailDecoratorError(`[snail] @${label}() expects a zod schema with a safeParse() method`);
|
|
1887
|
+
return ((target, propertyKey) => {
|
|
1888
|
+
defineMetadata(key, schema, target, propertyKey);
|
|
1889
|
+
});
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
var defineRequestSchema = schemaDecorator(REQUEST_SCHEMA_KEY, "Validate");
|
|
1893
|
+
var defineResponseSchema = schemaDecorator(RESPONSE_SCHEMA_KEY, "ValidateResponse");
|
|
1894
|
+
/**
|
|
1895
|
+
* Validate the outgoing request body (or query) against a zod schema.
|
|
1896
|
+
*
|
|
1897
|
+
* ```ts
|
|
1898
|
+
* @Post("/")
|
|
1899
|
+
* @Validate(z.object({ name: z.string().min(1) }))
|
|
1900
|
+
* create(@Data() body: CreateUser): Promise<User> { return null!; }
|
|
1901
|
+
* ```
|
|
1902
|
+
*/
|
|
1903
|
+
function Validate$1(schema) {
|
|
1904
|
+
return defineRequestSchema(schema);
|
|
1905
|
+
}
|
|
1906
|
+
/**
|
|
1907
|
+
* Validate the response payload against a zod schema.
|
|
1908
|
+
*
|
|
1909
|
+
* Response validation only ever warns: the backend, not the caller, decides what
|
|
1910
|
+
* it sends, and throwing away a usable payload because one field is unexpected
|
|
1911
|
+
* turns a cosmetic backend drift into a broken page.
|
|
1912
|
+
*/
|
|
1913
|
+
function ValidateResponse(schema) {
|
|
1914
|
+
return defineResponseSchema(schema);
|
|
1915
|
+
}
|
|
1916
|
+
/** Read the request schema of one method: method first, then the api class. */
|
|
1917
|
+
function resolveRequestSchema(apiClass, methodName) {
|
|
1918
|
+
return getMetadata(REQUEST_SCHEMA_KEY, apiClass, methodName) ?? getMetadata(REQUEST_SCHEMA_KEY, apiClass);
|
|
1919
|
+
}
|
|
1920
|
+
/** Read the response schema of one method: method first, then the api class. */
|
|
1921
|
+
function resolveResponseSchema(apiClass, methodName) {
|
|
1922
|
+
return getMetadata(RESPONSE_SCHEMA_KEY, apiClass, methodName) ?? getMetadata(RESPONSE_SCHEMA_KEY, apiClass);
|
|
1923
|
+
}
|
|
1924
|
+
//#endregion
|
|
1925
|
+
//#region src/plugins/validate/type.ts
|
|
1926
|
+
/**
|
|
1927
|
+
* Thrown when the request payload does not satisfy its schema.
|
|
1928
|
+
*
|
|
1929
|
+
* The request is abandoned **before** anything reaches the network, which is the
|
|
1930
|
+
* whole point of validating a request: an invalid body that is still sent produces
|
|
1931
|
+
* a backend error that looks like a server bug, and a `4xx` that pollutes the
|
|
1932
|
+
* application's error handling for what is really a programmer mistake.
|
|
1933
|
+
*
|
|
1934
|
+
* `issues` is zod's own list, kept verbatim so the application can render field
|
|
1935
|
+
* paths and messages instead of parsing a string.
|
|
1936
|
+
*/
|
|
1937
|
+
var SnailValidationError = class extends SnailError {
|
|
1938
|
+
constructor(message, options) {
|
|
1939
|
+
super(message, {
|
|
1940
|
+
code: "SNAIL_VALIDATION_ERROR",
|
|
1941
|
+
cause: options.cause
|
|
1942
|
+
});
|
|
1943
|
+
this.issues = options.issues;
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
//#endregion
|
|
1947
|
+
//#region src/plugins/validate/plugin.ts
|
|
1948
|
+
/**
|
|
1949
|
+
* Zod validation plugin.
|
|
1950
|
+
*
|
|
1951
|
+
* ## The asymmetry, and why it is deliberate
|
|
1952
|
+
*
|
|
1953
|
+
* **An invalid request aborts the call.** The request never reaches the network:
|
|
1954
|
+
* a body that fails its own schema is a programmer mistake, and the backend error
|
|
1955
|
+
* it would produce points at the wrong layer.
|
|
1956
|
+
*
|
|
1957
|
+
* **An invalid response only warns.** The response is already here and the caller
|
|
1958
|
+
* asked for it; throwing it away because the backend added, renamed or retyped a
|
|
1959
|
+
* field turns a cosmetic drift into a broken page. The warning carries zod's
|
|
1960
|
+
* issues so the drift is still visible in the console.
|
|
1961
|
+
*
|
|
1962
|
+
* ## Priority
|
|
1963
|
+
*
|
|
1964
|
+
* `-50` — the reserved validate band. In the forward phase it runs after the
|
|
1965
|
+
* version (`50`) and the adapters (`0`) have finished shaping the request, and
|
|
1966
|
+
* before the cache (`-100`) is asked for a key. On the unwind side it runs after
|
|
1967
|
+
* the cache and before the adapters, so the response is validated *before* the
|
|
1968
|
+
* transform plugin replaces the payload with class instances.
|
|
1969
|
+
*/
|
|
1970
|
+
/**
|
|
1971
|
+
* The value a request schema validates.
|
|
1972
|
+
*
|
|
1973
|
+
* Write verbs carry a body, read verbs carry query params. Validating the body
|
|
1974
|
+
* when there is one and the query otherwise means one decorator covers both, and
|
|
1975
|
+
* a request that carries neither (`undefined`) is left to the backend rather than
|
|
1976
|
+
* failing against an empty object.
|
|
1977
|
+
*/
|
|
1978
|
+
function requestTarget(ctx) {
|
|
1979
|
+
return ctx.request.data !== void 0 ? ctx.request.data : ctx.request.params;
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Validate the outgoing request, throwing when `strict`.
|
|
1983
|
+
*
|
|
1984
|
+
* Never calls `next()` on failure: the chain ends, `send()` rejects with the
|
|
1985
|
+
* {@link SnailValidationError}, and no axios adapter is ever reached.
|
|
1986
|
+
*/
|
|
1987
|
+
function validateRequest(ctx, schema, strict, next) {
|
|
1988
|
+
const target = requestTarget(ctx);
|
|
1989
|
+
if (target === void 0) return next();
|
|
1990
|
+
const parsed = schema.safeParse(target);
|
|
1991
|
+
if (parsed.success) return next();
|
|
1992
|
+
const message = t("error.plugin.validate.request", ctx.fullName);
|
|
1993
|
+
if (!strict) {
|
|
1994
|
+
ctx.logger.warn(message, parsed.error.issues);
|
|
1995
|
+
return next();
|
|
1996
|
+
}
|
|
1997
|
+
throw new SnailValidationError(message, { issues: parsed.error.issues });
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Validate the response payload and warn about it.
|
|
2001
|
+
*
|
|
2002
|
+
* A throwing logger or a malformed schema must not fail the request, so the whole
|
|
2003
|
+
* check is defensive: the caller still receives the payload no matter what.
|
|
2004
|
+
*/
|
|
2005
|
+
function warnOnInvalidResponse(ctx, schema) {
|
|
2006
|
+
try {
|
|
2007
|
+
const response = ctx.getResponse();
|
|
2008
|
+
if (!response) return;
|
|
2009
|
+
const payload = unwrapEnvelope(response.data, ctx.serverOptions.dataKey);
|
|
2010
|
+
const parsed = schema.safeParse(payload);
|
|
2011
|
+
if (parsed.success) return;
|
|
2012
|
+
ctx.logger.warn(t("error.plugin.validate.response", ctx.fullName), parsed.error.issues);
|
|
2013
|
+
} catch (error) {
|
|
2014
|
+
ctx.logger.warn(t("error.plugin.validate.response", ctx.fullName), error);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Create the zod validation plugin.
|
|
2019
|
+
*
|
|
2020
|
+
* ```ts
|
|
2021
|
+
* Service.use(Validate({ strict: false }));
|
|
2022
|
+
* ```
|
|
2023
|
+
*
|
|
2024
|
+
* It is normally used without options, together with the decorators:
|
|
2025
|
+
* `Service.use(Validate())`.
|
|
2026
|
+
*/
|
|
2027
|
+
var validatePlugin = createPlugin({
|
|
2028
|
+
name: "validate",
|
|
2029
|
+
priority: -50,
|
|
2030
|
+
setup(options) {
|
|
2031
|
+
const fallbackRequest = options?.request;
|
|
2032
|
+
const fallbackResponse = options?.response;
|
|
2033
|
+
const strict = options?.strict ?? true;
|
|
2034
|
+
return {
|
|
2035
|
+
beforeRequest(ctx, next) {
|
|
2036
|
+
const schema = resolveRequestSchema(ctx.apiClass, ctx.methodName) ?? fallbackRequest;
|
|
2037
|
+
if (!schema) return next();
|
|
2038
|
+
return validateRequest(ctx, schema, strict, next);
|
|
2039
|
+
},
|
|
2040
|
+
afterResponse(ctx, next) {
|
|
2041
|
+
const schema = resolveResponseSchema(ctx.apiClass, ctx.methodName) ?? fallbackResponse;
|
|
2042
|
+
if (schema) warnOnInvalidResponse(ctx, schema);
|
|
2043
|
+
return next();
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
});
|
|
2048
|
+
//#endregion
|
|
2049
|
+
//#region src/plugins/validate/index.ts
|
|
2050
|
+
function Validate(input) {
|
|
2051
|
+
return isZodSchema(input) ? Validate$1(input) : validatePlugin(input);
|
|
2052
|
+
}
|
|
2053
|
+
/** `true` for a zod schema — the only shape the decorator overload accepts. */
|
|
2054
|
+
function isZodSchema(value) {
|
|
2055
|
+
return typeof value?.safeParse === "function";
|
|
2056
|
+
}
|
|
2057
|
+
//#endregion
|
|
2058
|
+
//#region src/plugins/version/decorators.ts
|
|
2059
|
+
/**
|
|
2060
|
+
* `@Version(...)` — declare the api version of a class or of one method.
|
|
2061
|
+
*
|
|
2062
|
+
* Method wins over class, class wins over the plugin's `defaultVersion`, which is
|
|
2063
|
+
* the only ordering that lets one endpoint of a class deviate without forcing
|
|
2064
|
+
* every sibling to restate the class version.
|
|
2065
|
+
*
|
|
2066
|
+
* The decorator is intentionally metadata-only: it never touches the request or
|
|
2067
|
+
* the server, so decorating a class from a shared module cannot leak a version
|
|
2068
|
+
* into another server instance.
|
|
2069
|
+
*/
|
|
2070
|
+
/**
|
|
2071
|
+
* Metadata slot written by `@Version`.
|
|
2072
|
+
*
|
|
2073
|
+
* One key covers both levels: a class decorator stores under the class slot and a
|
|
2074
|
+
* method decorator under the method name, so a reader never has to know which
|
|
2075
|
+
* decorator wrote the value.
|
|
2076
|
+
*/
|
|
2077
|
+
var VERSION_KEY = customMetadataKey("versioning/version");
|
|
2078
|
+
/**
|
|
2079
|
+
* Declare the version of an api class or of a single request method.
|
|
2080
|
+
*
|
|
2081
|
+
* ```ts
|
|
2082
|
+
* @Api("/user")
|
|
2083
|
+
* @Version("1.2.0")
|
|
2084
|
+
* class UserApi {
|
|
2085
|
+
* @Get("/legacy")
|
|
2086
|
+
* @Version("0.9.0")
|
|
2087
|
+
* legacy(): Promise<void> { return null!; }
|
|
2088
|
+
* }
|
|
2089
|
+
* ```
|
|
2090
|
+
*/
|
|
2091
|
+
function Version(version) {
|
|
2092
|
+
if (typeof version !== "string" || version.length === 0) throw new SnailDecoratorError("[snail] @Version() requires a non-empty version string");
|
|
2093
|
+
return ((target, propertyKey) => {
|
|
2094
|
+
defineMetadata(VERSION_KEY, version, target, propertyKey);
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
/**
|
|
2098
|
+
* Read the version declared for one method.
|
|
2099
|
+
*
|
|
2100
|
+
* Reads the method slot first, then the class slot, and both walk the prototype
|
|
2101
|
+
* chain so a subclass inherits the version of its base api class.
|
|
2102
|
+
*/
|
|
2103
|
+
function resolveDeclaredVersion(apiClass, methodName) {
|
|
2104
|
+
return getMetadata(VERSION_KEY, apiClass, methodName) ?? getMetadata(VERSION_KEY, apiClass);
|
|
2105
|
+
}
|
|
2106
|
+
//#endregion
|
|
2107
|
+
//#region src/plugins/version/plugin.ts
|
|
2108
|
+
/**
|
|
2109
|
+
* Version management plugin.
|
|
2110
|
+
*
|
|
2111
|
+
* ## What it rewrites
|
|
2112
|
+
*
|
|
2113
|
+
* Only the live request on the context — never `server.defaults.baseURL`, never
|
|
2114
|
+
* the shared axios instance.
|
|
2115
|
+
*
|
|
2116
|
+
* The pre-rewrite implementation baked the version into the server's `baseURL`
|
|
2117
|
+
* the first time any request ran. That leaked: the first method to run decided
|
|
2118
|
+
* the version of every later request on that server, so calling a `v2` endpoint
|
|
2119
|
+
* once silently moved the whole application to `v2`. Resolving the version per
|
|
2120
|
+
* request from the method/class metadata makes that unrepresentable.
|
|
2121
|
+
*
|
|
2122
|
+
* ## Priority
|
|
2123
|
+
*
|
|
2124
|
+
* `50` — inside the reserved version band. It runs after an interceptor (`100`)
|
|
2125
|
+
* so it sees the url the interceptor produced, and before the cache (`-100`) so
|
|
2126
|
+
* the cache hashes the versioned url instead of a url that changes under it.
|
|
2127
|
+
*/
|
|
2128
|
+
/**
|
|
2129
|
+
* Default key or url segment name per transport.
|
|
2130
|
+
*
|
|
2131
|
+
* `url` keeps the conventional `v` marker, so a bare
|
|
2132
|
+
* `Versioning({ type: "url", defaultVersion: "1.0.0" })` turns `/user/1` into
|
|
2133
|
+
* `/v1.0.0/user/1`. Pass `key: ""` for a bare `/1.0.0/user/1` segment.
|
|
2134
|
+
*/
|
|
2135
|
+
var DEFAULT_KEYS = {
|
|
2136
|
+
url: "v",
|
|
2137
|
+
header: "x-api-version",
|
|
2138
|
+
query: "v"
|
|
2139
|
+
};
|
|
2140
|
+
/**
|
|
2141
|
+
* Validate and normalise the factory options.
|
|
2142
|
+
*
|
|
2143
|
+
* Runs once per server, inside `setup`. Throwing here rolls the registration back,
|
|
2144
|
+
* so a misconfigured plugin is never half-installed and silently doing nothing.
|
|
2145
|
+
*/
|
|
2146
|
+
function resolveVersioningOptions(options) {
|
|
2147
|
+
const type = options?.type ?? "url";
|
|
2148
|
+
if (type !== "url" && type !== "header" && type !== "query" && type !== "custom") throw new SnailPluginError(`[snail] Versioning() received an unknown type "${String(type)}"`, { pluginName: "versioning" });
|
|
2149
|
+
const defaultVersion = options?.defaultVersion;
|
|
2150
|
+
if (typeof defaultVersion !== "string" || defaultVersion.length === 0) throw new SnailPluginError("[snail] Versioning() requires a non-empty `defaultVersion`", { pluginName: "versioning" });
|
|
2151
|
+
if (type === "custom" && typeof options?.extractor !== "function") throw new SnailPluginError("[snail] Versioning({ type: \"custom\" }) requires an `extractor` function", { pluginName: "versioning" });
|
|
2152
|
+
return {
|
|
2153
|
+
type,
|
|
2154
|
+
defaultVersion,
|
|
2155
|
+
key: type === "custom" ? options?.key ?? "" : options?.key ?? DEFAULT_KEYS[type],
|
|
2156
|
+
extractor: options?.extractor
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
/**
|
|
2160
|
+
* The mutable header bag of the request, created when a plugin or a decorator
|
|
2161
|
+
* has not produced an `AxiosHeaders` yet.
|
|
2162
|
+
*/
|
|
2163
|
+
function headerBag(ctx) {
|
|
2164
|
+
if (!(ctx.request.headers instanceof AxiosHeaders)) ctx.request.headers = AxiosHeaders.from(ctx.request.headers ?? {});
|
|
2165
|
+
return ctx.request.headers;
|
|
2166
|
+
}
|
|
2167
|
+
/** `true` when `segment` already is one of the url's path segments. */
|
|
2168
|
+
function hasSegment(url, segment) {
|
|
2169
|
+
return (url.split(/[?#]/, 1)[0] ?? "").split("/").includes(segment);
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* Prepend `<key><version>` as the first path segment.
|
|
2173
|
+
*
|
|
2174
|
+
* Absolute urls are left untouched — prefixing them would corrupt the host — and
|
|
2175
|
+
* a url that already carries the segment is returned as-is, so a method whose path
|
|
2176
|
+
* hard-codes the version is not rewritten twice.
|
|
2177
|
+
*/
|
|
2178
|
+
function applyURLVersion(url, version, key) {
|
|
2179
|
+
const segment = `${key}${version}`;
|
|
2180
|
+
if (isAbsoluteURL(url) || hasSegment(url, segment)) return url;
|
|
2181
|
+
if (url.length === 0) return `/${segment}`;
|
|
2182
|
+
return `/${segment}${url.startsWith("/") ? "" : "/"}${url}`;
|
|
2183
|
+
}
|
|
2184
|
+
/** Write the version header, replacing any value the caller already set. */
|
|
2185
|
+
function applyHeaderVersion(ctx, version, key) {
|
|
2186
|
+
headerBag(ctx).set(key, version);
|
|
2187
|
+
}
|
|
2188
|
+
/** Merge the version into the query params without dropping the existing ones. */
|
|
2189
|
+
function applyQueryVersion(ctx, version, key) {
|
|
2190
|
+
const current = ctx.request.params;
|
|
2191
|
+
ctx.request.params = {
|
|
2192
|
+
...isPlainObject(current) ? current : {},
|
|
2193
|
+
[key]: version
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
/** Delegate to the caller's extractor and merge whatever patch it returns. */
|
|
2197
|
+
function applyCustomVersion(ctx, version, extractor) {
|
|
2198
|
+
const patch = extractor(version, ctx);
|
|
2199
|
+
if (!patch) return;
|
|
2200
|
+
if (typeof patch.url === "string") ctx.request.url = patch.url;
|
|
2201
|
+
if (patch.headers) {
|
|
2202
|
+
const headers = headerBag(ctx);
|
|
2203
|
+
for (const [key, value] of Object.entries(patch.headers)) headers.set(key, value);
|
|
2204
|
+
}
|
|
2205
|
+
if (patch.params) {
|
|
2206
|
+
const current = ctx.request.params;
|
|
2207
|
+
ctx.request.params = {
|
|
2208
|
+
...isPlainObject(current) ? current : {},
|
|
2209
|
+
...patch.params
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Resolve the effective version and rewrite the live request.
|
|
2215
|
+
*
|
|
2216
|
+
* The default version is applied too, not skipped: the backend wants `/v1.0.0`
|
|
2217
|
+
* for an undeclared method, and nothing else in the pipeline knows that. Only the
|
|
2218
|
+
* *logging* distinguishes an explicit version from the default.
|
|
2219
|
+
*/
|
|
2220
|
+
function applyVersion(ctx, options) {
|
|
2221
|
+
const version = resolveDeclaredVersion(ctx.apiClass, ctx.methodName) ?? options.defaultVersion;
|
|
2222
|
+
if (version !== options.defaultVersion) {
|
|
2223
|
+
ctx.logger.info(t("info.version.change", ctx.fullName, version));
|
|
2224
|
+
ctx.logger.warn(t("warn.version.change", ctx.fullName, options.defaultVersion, version));
|
|
2225
|
+
}
|
|
2226
|
+
switch (options.type) {
|
|
2227
|
+
case "url":
|
|
2228
|
+
ctx.request.url = applyURLVersion(ctx.request.url ?? ctx.route, version, options.key);
|
|
2229
|
+
return;
|
|
2230
|
+
case "header":
|
|
2231
|
+
applyHeaderVersion(ctx, version, options.key);
|
|
2232
|
+
return;
|
|
2233
|
+
case "query":
|
|
2234
|
+
applyQueryVersion(ctx, version, options.key);
|
|
2235
|
+
return;
|
|
2236
|
+
case "custom":
|
|
2237
|
+
applyCustomVersion(ctx, version, options.extractor);
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
/**
|
|
2242
|
+
* Create the version management plugin.
|
|
2243
|
+
*
|
|
2244
|
+
* ```ts
|
|
2245
|
+
* Service.use(Versioning({ type: "url", defaultVersion: "1.0.0" }));
|
|
2246
|
+
* ```
|
|
2247
|
+
*
|
|
2248
|
+
* The rewrite happens in `beforeRequest`, so it is the last thing that can change
|
|
2249
|
+
* the url before the request is hashed, cached and sent.
|
|
2250
|
+
*/
|
|
2251
|
+
var Versioning = createPlugin({
|
|
2252
|
+
name: "versioning",
|
|
2253
|
+
priority: 50,
|
|
2254
|
+
setup(options) {
|
|
2255
|
+
const resolved = resolveVersioningOptions(options);
|
|
2256
|
+
return { beforeRequest(ctx, next) {
|
|
2257
|
+
applyVersion(ctx, resolved);
|
|
2258
|
+
return next();
|
|
2259
|
+
} };
|
|
2260
|
+
}
|
|
2261
|
+
});
|
|
2262
|
+
//#endregion
|
|
2263
|
+
export { AfterResponse, BeforeRequest, CACHE_PLUGIN_NAME, CACHE_PRIORITY, Cache, CacheManager, Cacheable, DEFAULT_L1_MAX_SIZE, ExposeName, HitSource, INTERCEPTOR_PLUGIN_NAME, INTERCEPTOR_PRIORITY, IndexedDBCacheAdapter, Interceptor, InterceptorManager, Invalidates, MemoryCacheAdapter, NoCache, POOL_ERROR_CODES, POOL_PLUGIN_NAME, POOL_PRIORITY, PropertyType, RequestPool, RequestPoolScheduler, SnailPoolError, SnailValidationError, Transform, Validate, ValidateResponse, Version, Versioning, WebStorageCacheAdapter, buildCacheKey, classAfterEntries, classBeforeEntries, clearPool, hydrate, isPoolError, makeCachedResponse, methodAfterEntries, methodBeforeEntries, poolStats, readCacheable, readInvalidates, readNoCache };
|
|
2264
|
+
|
|
2265
|
+
//# sourceMappingURL=index.js.map
|