@snail-js/api 0.1.28 → 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 -4871
- package/dist/snail-api.umd.cjs +0 -4875
- 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
package/dist/index.js
ADDED
|
@@ -0,0 +1,2034 @@
|
|
|
1
|
+
import { A as stableStringify, B as SnailError, C as buildRequestURL, D as pathParamNames, E as joinURL, F as isFunction, I as isObject, L as isPlainObject, M as isBinaryBody, N as isBrowser, O as replacePathParams, P as isDefined, R as isPromise, S as en, T as isAbsoluteURL, _ as localization, a as SnailHttpError, b as t, c as finalizeRequestURL, d as paramSources, f as registerParamResolver, g as languages, h as getLocale, i as SnailHookError, j as stripQuery, k as shortHash, l as hasParamResolver, m as Localization, n as createPlugin, o as SnailResponseError, r as definePlugin, s as applyParamDescriptors, t as composeChain, u as paramResolvers, v as registerMessages, w as capitalize, x as zh, y as setLocale, z as SnailDecoratorError } from "./chunks/plugin-D3RDffDG.js";
|
|
2
|
+
import { n as SnailRequestError, r as SnailTimeoutError, t as SnailCancelledError } from "./chunks/request-qeRhihEF.js";
|
|
3
|
+
import { a as omitUndefined, i as omit, n as deferred, o as pick, s as tryCatch, t as deepMerge } from "./chunks/object-Ct0w-h4i.js";
|
|
4
|
+
import { a as readKey, c as DEFAULT_API_OPTIONS, d as LOG_LEVEL_WEIGHT, l as DEFAULT_RESPONSE_KEYS, n as buildResult, r as coerceJSONStringBody, s as DEFAULT_ACCEPTED_CODES, t as assertBusinessCode, u as DEFAULT_SERVER_OPTIONS } from "./chunks/response-D-ubkAFl.js";
|
|
5
|
+
import { A as SNAIL_DOWNLOAD_PROGRESS, B as SNAIL_WS_OPTIONS, C as getMetadata, D as resolveOwner, E as mergeMetadata, F as SNAIL_SERVER_OPTIONS, I as SNAIL_SSE_HANDLERS, L as SNAIL_SSE_OPTIONS, M as SNAIL_HTTP_STREAM, N as SNAIL_PARAMS, O as SNAIL_API_OPTIONS, P as SNAIL_REQUEST_METHOD, R as SNAIL_UPLOAD_PROGRESS, S as deleteMetadata, T as hasMetadata, V as createLogger, _ as SnailPluginError, a as customMetadataKey, b as collectMethodKeys, c as getOwnMethodMetadata, d as HeaderValue, f as Params, g as normalizeParamInput, h as defineParamDescriptor, i as createPropertyDecorator, j as SNAIL_HEADERS, k as SNAIL_CUSTOM_KEY_PREFIX, l as Data, m as createParamDecoratorFor, n as createMethodDecorator, o as getClassMetadata, p as Query, r as createParamDecorator, s as getMethodMetadata, t as createClassDecorator, u as HeaderParam, v as appendMetadata, w as getOwnMetadata, x as defineMetadata, y as clearMetadataRegistry, z as SNAIL_WS_HANDLERS } from "./chunks/custom-UchOSg_s.js";
|
|
6
|
+
import { i as Emitter, n as triggerBlobDownload, r as triggerDownload, t as filenameFromDisposition } from "./chunks/download-lBC7oOTT.js";
|
|
7
|
+
import axios, { AxiosHeaders, isCancel } from "axios";
|
|
8
|
+
//#region src/core/state-bag.ts
|
|
9
|
+
/**
|
|
10
|
+
* Typed key/value bag.
|
|
11
|
+
*
|
|
12
|
+
* Two independent bags ride along every request:
|
|
13
|
+
*
|
|
14
|
+
* - `ctx.state` — **plugin scratch space**. Timers, cache keys, in-flight
|
|
15
|
+
* promises, tracing ids. Never exposed to the caller.
|
|
16
|
+
* - `ctx.meta` — **caller-visible reactive values** (`data`, `loading`, `error`)
|
|
17
|
+
* created by the framework adapter's `initMeta` hook.
|
|
18
|
+
*
|
|
19
|
+
* Keeping them separate matters: a plugin writing bookkeeping into `meta` would
|
|
20
|
+
* leak internal state into the object the UI renders.
|
|
21
|
+
*/
|
|
22
|
+
var StateBag = class {
|
|
23
|
+
constructor() {
|
|
24
|
+
this.values = /* @__PURE__ */ new Map();
|
|
25
|
+
}
|
|
26
|
+
get(key, fallback) {
|
|
27
|
+
return this.values.has(key) ? this.values.get(key) : fallback;
|
|
28
|
+
}
|
|
29
|
+
/** Read a value, throwing when the key is absent. */
|
|
30
|
+
require(key) {
|
|
31
|
+
if (!this.values.has(key)) throw new ReferenceError(`[snail] context state "${key}" has not been set`);
|
|
32
|
+
return this.values.get(key);
|
|
33
|
+
}
|
|
34
|
+
/** Write a value. */
|
|
35
|
+
set(key, value) {
|
|
36
|
+
this.values.set(key, value);
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
/** Write a value only when the key is currently absent. */
|
|
40
|
+
setDefault(key, value) {
|
|
41
|
+
if (!this.values.has(key)) this.values.set(key, value);
|
|
42
|
+
return this.values.get(key);
|
|
43
|
+
}
|
|
44
|
+
/** `true` when the key was ever set (even to `undefined`). */
|
|
45
|
+
has(key) {
|
|
46
|
+
return this.values.has(key);
|
|
47
|
+
}
|
|
48
|
+
/** Remove a key. Returns whether it existed. */
|
|
49
|
+
delete(key) {
|
|
50
|
+
return this.values.delete(key);
|
|
51
|
+
}
|
|
52
|
+
/** Drop everything. */
|
|
53
|
+
clear() {
|
|
54
|
+
this.values.clear();
|
|
55
|
+
}
|
|
56
|
+
/** Current keys. */
|
|
57
|
+
keys() {
|
|
58
|
+
return [...this.values.keys()];
|
|
59
|
+
}
|
|
60
|
+
/** Shallow snapshot, handy for logging and assertions. */
|
|
61
|
+
snapshot() {
|
|
62
|
+
return Object.fromEntries(this.values);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/core/context.ts
|
|
67
|
+
/**
|
|
68
|
+
* Per-request context — the single object every plugin hook receives.
|
|
69
|
+
*
|
|
70
|
+
* One context exists per `send()` call, so plugins may store freely in
|
|
71
|
+
* `ctx.state` without worrying about concurrent requests colliding. That is a
|
|
72
|
+
* deliberate fix over the pre-rewrite design, where the "event map" and the
|
|
73
|
+
* request config lived on the long-lived `SnailMethod` instance and two
|
|
74
|
+
* overlapping `send()` calls shared mutable state.
|
|
75
|
+
*/
|
|
76
|
+
var SnailContext = class {
|
|
77
|
+
constructor(init) {
|
|
78
|
+
this.state = new StateBag();
|
|
79
|
+
this.meta = {};
|
|
80
|
+
this.pathParams = {};
|
|
81
|
+
this.startedAt = Date.now();
|
|
82
|
+
this.interrupted = false;
|
|
83
|
+
this.cacheHit = false;
|
|
84
|
+
this.server = init.server;
|
|
85
|
+
this.serverOptions = init.serverOptions;
|
|
86
|
+
this.apiClass = init.apiClass;
|
|
87
|
+
this.api = init.api;
|
|
88
|
+
this.apiName = init.apiName;
|
|
89
|
+
this.apiOptions = init.apiOptions;
|
|
90
|
+
this.methodName = init.methodName;
|
|
91
|
+
this.methodType = init.methodType;
|
|
92
|
+
this.route = init.route;
|
|
93
|
+
this.fullName = `${init.serverOptions.name}.${init.apiName}.${init.methodName}`;
|
|
94
|
+
this.request = init.request;
|
|
95
|
+
this.descriptors = init.descriptors;
|
|
96
|
+
this.logger = init.logger;
|
|
97
|
+
}
|
|
98
|
+
/** Milliseconds elapsed since `send()` started. */
|
|
99
|
+
get elapsed() {
|
|
100
|
+
return (this.finishedAt ?? Date.now()) - this.startedAt;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Stop the request.
|
|
104
|
+
*
|
|
105
|
+
* With a `response` argument the network call is skipped entirely and that
|
|
106
|
+
* response is used instead — this is exactly how a cache hit works. Without
|
|
107
|
+
* one, the request is abandoned and `send()` rejects with a cancellation error.
|
|
108
|
+
*/
|
|
109
|
+
interrupt(response) {
|
|
110
|
+
this.interrupted = true;
|
|
111
|
+
if (response) this.response = response;
|
|
112
|
+
}
|
|
113
|
+
/** `true` when a plugin short-circuited the request. */
|
|
114
|
+
get isInterrupted() {
|
|
115
|
+
return this.interrupted;
|
|
116
|
+
}
|
|
117
|
+
/** Record that the current response came from a cache. */
|
|
118
|
+
markCacheHit() {
|
|
119
|
+
this.cacheHit = true;
|
|
120
|
+
}
|
|
121
|
+
/** `true` when the response was served from a cache. */
|
|
122
|
+
get isCacheHit() {
|
|
123
|
+
return this.cacheHit;
|
|
124
|
+
}
|
|
125
|
+
/** Replace the current response. */
|
|
126
|
+
setResponse(response) {
|
|
127
|
+
this.response = response;
|
|
128
|
+
}
|
|
129
|
+
/** Read the current response. */
|
|
130
|
+
getResponse() {
|
|
131
|
+
return this.response;
|
|
132
|
+
}
|
|
133
|
+
/** Read the current response, throwing when there is none. */
|
|
134
|
+
requireResponse() {
|
|
135
|
+
if (!this.response) throw new ReferenceError(`[snail] ${this.fullName} has no response at this point`);
|
|
136
|
+
return this.response;
|
|
137
|
+
}
|
|
138
|
+
/** Replace the request config. */
|
|
139
|
+
setRequest(request) {
|
|
140
|
+
this.request = request;
|
|
141
|
+
this.request.url = this.request.url ?? this.route;
|
|
142
|
+
}
|
|
143
|
+
/** Read the request config. */
|
|
144
|
+
getRequest() {
|
|
145
|
+
return this.request;
|
|
146
|
+
}
|
|
147
|
+
/** Replace the parsed result. */
|
|
148
|
+
setResult(result) {
|
|
149
|
+
this.result = result;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Clear everything that belongs to one `send()` while keeping the context
|
|
153
|
+
* identity, so `meta` — and therefore the caller's reactive handles — survive
|
|
154
|
+
* a re-send.
|
|
155
|
+
*/
|
|
156
|
+
reset(request) {
|
|
157
|
+
this.state.clear();
|
|
158
|
+
this.pathParams = {};
|
|
159
|
+
this.response = void 0;
|
|
160
|
+
this.error = void 0;
|
|
161
|
+
this.result = void 0;
|
|
162
|
+
this.finishedAt = void 0;
|
|
163
|
+
this.interrupted = false;
|
|
164
|
+
this.cacheHit = false;
|
|
165
|
+
this.startedAt = Date.now();
|
|
166
|
+
this.request = request;
|
|
167
|
+
}
|
|
168
|
+
/** Shallow copy of the fields worth logging. */
|
|
169
|
+
describe() {
|
|
170
|
+
return {
|
|
171
|
+
name: this.fullName,
|
|
172
|
+
method: this.methodType,
|
|
173
|
+
url: this.request.url,
|
|
174
|
+
route: this.route,
|
|
175
|
+
baseURL: this.request.baseURL,
|
|
176
|
+
params: this.request.params,
|
|
177
|
+
fromCache: this.cacheHit,
|
|
178
|
+
elapsed: this.elapsed
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/core/connection.ts
|
|
184
|
+
/**
|
|
185
|
+
* Attach `Symbol.dispose` / `Symbol.asyncDispose` to a connection.
|
|
186
|
+
*
|
|
187
|
+
* Defined as a helper rather than as two methods on each transport because the
|
|
188
|
+
* symbols are *optional*: `esnext.disposable` is a recent addition, and an engine
|
|
189
|
+
* that lacks them would otherwise end up with a property literally named
|
|
190
|
+
* `"undefined"` if the keys were written inline.
|
|
191
|
+
*
|
|
192
|
+
* The result is that a connection can be released three equivalent ways —
|
|
193
|
+
* `close()`, `using`, or `await using` — with `close()` staying the only one that
|
|
194
|
+
* works everywhere.
|
|
195
|
+
*/
|
|
196
|
+
function withDispose(connection) {
|
|
197
|
+
const symbols = Symbol;
|
|
198
|
+
const target = connection;
|
|
199
|
+
if (typeof symbols.dispose === "symbol") target[symbols.dispose] = () => {
|
|
200
|
+
connection.close();
|
|
201
|
+
};
|
|
202
|
+
if (typeof symbols.asyncDispose === "symbol") target[symbols.asyncDispose] = async () => {
|
|
203
|
+
connection.close();
|
|
204
|
+
};
|
|
205
|
+
return connection;
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/core/http-stream.ts
|
|
209
|
+
/**
|
|
210
|
+
* Streaming HTTP response.
|
|
211
|
+
*
|
|
212
|
+
* Uses `fetch` so the response body arrives as a `ReadableStream`, which works
|
|
213
|
+
* in browsers, Node 18+ and workers alike. The connection object is itself
|
|
214
|
+
* async-iterable, so callers write `for await (const chunk of stream)`.
|
|
215
|
+
*/
|
|
216
|
+
function createHttpStream(init) {
|
|
217
|
+
const { url, options, body, name, logger } = init;
|
|
218
|
+
const controller = new AbortController();
|
|
219
|
+
const opened = deferred();
|
|
220
|
+
const closed = deferred();
|
|
221
|
+
opened.promise.catch(() => void 0);
|
|
222
|
+
const decodeText = options.decodeText ?? true;
|
|
223
|
+
const lineDelimited = options.lineDelimited ?? false;
|
|
224
|
+
let connected = false;
|
|
225
|
+
let stopped = false;
|
|
226
|
+
let iterator;
|
|
227
|
+
const start = async () => {
|
|
228
|
+
const method = (options.method ?? "POST").toUpperCase();
|
|
229
|
+
const hasBody = body !== void 0 && method !== "GET";
|
|
230
|
+
const response = await fetch(url, {
|
|
231
|
+
method,
|
|
232
|
+
headers: {
|
|
233
|
+
accept: "text/event-stream, application/x-ndjson, text/plain",
|
|
234
|
+
...hasBody ? { "content-type": "application/json" } : {},
|
|
235
|
+
...options.headers ?? {},
|
|
236
|
+
...init.headers ?? {}
|
|
237
|
+
},
|
|
238
|
+
body: hasBody ? JSON.stringify(body) : void 0,
|
|
239
|
+
signal: controller.signal
|
|
240
|
+
});
|
|
241
|
+
if (!response.ok) throw new SnailRequestError(t("error.request.failed", name, `stream responded ${response.status} ${response.statusText}`));
|
|
242
|
+
if (!response.body) throw new SnailRequestError(t("error.request.failed", name, "stream response has no readable body"));
|
|
243
|
+
connected = true;
|
|
244
|
+
opened.resolve();
|
|
245
|
+
return response.body.getReader();
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* Start the request immediately rather than on first iteration.
|
|
249
|
+
*
|
|
250
|
+
* `opened` and `closed` are only useful if the request is already in flight — a
|
|
251
|
+
* lazily started stream leaves `await connection.opened` pending forever, which
|
|
252
|
+
* is a trap. Creating the connection *is* the "send" action for a stream.
|
|
253
|
+
*/
|
|
254
|
+
const readerPromise = start();
|
|
255
|
+
readerPromise.catch(() => void 0);
|
|
256
|
+
const createIterator = () => {
|
|
257
|
+
const decoder = new TextDecoder("utf-8");
|
|
258
|
+
let pending = "";
|
|
259
|
+
let reader;
|
|
260
|
+
const next = async () => {
|
|
261
|
+
try {
|
|
262
|
+
reader ??= await readerPromise;
|
|
263
|
+
while (true) {
|
|
264
|
+
if (lineDelimited) {
|
|
265
|
+
const newlineIndex = pending.indexOf("\n");
|
|
266
|
+
if (newlineIndex !== -1) {
|
|
267
|
+
const line = pending.slice(0, newlineIndex);
|
|
268
|
+
pending = pending.slice(newlineIndex + 1);
|
|
269
|
+
if (line.trim().length === 0) continue;
|
|
270
|
+
return {
|
|
271
|
+
value: line,
|
|
272
|
+
done: false
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
} else if (pending.length > 0) {
|
|
276
|
+
const chunk = pending;
|
|
277
|
+
pending = "";
|
|
278
|
+
return {
|
|
279
|
+
value: chunk,
|
|
280
|
+
done: false
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const { done, value } = await reader.read();
|
|
284
|
+
if (done) {
|
|
285
|
+
connected = false;
|
|
286
|
+
stopped = true;
|
|
287
|
+
closed.resolve();
|
|
288
|
+
if (pending.length > 0) {
|
|
289
|
+
const tail = pending;
|
|
290
|
+
pending = "";
|
|
291
|
+
return {
|
|
292
|
+
value: tail,
|
|
293
|
+
done: false
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
value: void 0,
|
|
298
|
+
done: true
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
pending += decodeText ? decoder.decode(value, { stream: true }) : String(value);
|
|
302
|
+
}
|
|
303
|
+
} catch (error) {
|
|
304
|
+
connected = false;
|
|
305
|
+
stopped = true;
|
|
306
|
+
opened.reject(error);
|
|
307
|
+
closed.resolve();
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
return {
|
|
312
|
+
next,
|
|
313
|
+
return: async () => {
|
|
314
|
+
stopped = true;
|
|
315
|
+
connected = false;
|
|
316
|
+
controller.abort();
|
|
317
|
+
closed.resolve();
|
|
318
|
+
return {
|
|
319
|
+
value: void 0,
|
|
320
|
+
done: true
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
const connection = withDispose({
|
|
326
|
+
get connected() {
|
|
327
|
+
return connected;
|
|
328
|
+
},
|
|
329
|
+
opened: opened.promise,
|
|
330
|
+
closed: closed.promise,
|
|
331
|
+
close() {
|
|
332
|
+
if (stopped) return;
|
|
333
|
+
stopped = true;
|
|
334
|
+
connected = false;
|
|
335
|
+
controller.abort();
|
|
336
|
+
logger.debug(t("info.sse.close", name));
|
|
337
|
+
closed.resolve();
|
|
338
|
+
},
|
|
339
|
+
[Symbol.asyncIterator]() {
|
|
340
|
+
iterator ??= createIterator();
|
|
341
|
+
return iterator;
|
|
342
|
+
},
|
|
343
|
+
async text() {
|
|
344
|
+
let out = "";
|
|
345
|
+
for await (const chunk of connection) out += chunk;
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
return connection;
|
|
350
|
+
}
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region src/core/method.ts
|
|
353
|
+
/**
|
|
354
|
+
* One pending request.
|
|
355
|
+
*
|
|
356
|
+
* Created by calling a proxied api method — `userApi.getUser("1")` — and sent by
|
|
357
|
+
* calling {@link SnailMethod.send}. Nothing hits the network until `send()`.
|
|
358
|
+
*
|
|
359
|
+
* ## Why the context is built once and reset
|
|
360
|
+
*
|
|
361
|
+
* Framework adapters create the caller's reactive handles in their `initMeta`
|
|
362
|
+
* hook. Those handles must survive every re-send, so the context is constructed
|
|
363
|
+
* once and {@link SnailContext.reset} clears only the per-request fields. Calling
|
|
364
|
+
* `userApi.getUser()` twice would produce two independent sets of refs, which is
|
|
365
|
+
* exactly the bug the old `request()`-returns-fresh-state design had.
|
|
366
|
+
*/
|
|
367
|
+
var SnailMethod = class {
|
|
368
|
+
/**
|
|
369
|
+
* Caller-visible reactive values created by framework adapters.
|
|
370
|
+
*
|
|
371
|
+
* A live view of `context.meta`, so the handles an adapter created in
|
|
372
|
+
* `initMeta` stay stable across every re-send. Empty when no adapter plugin is
|
|
373
|
+
* installed.
|
|
374
|
+
*
|
|
375
|
+
* `loading` and `error` are typed by the {@link SnailMeta} interface; the
|
|
376
|
+
* envelope handles are named after the server's configured keys, so augment
|
|
377
|
+
* `SnailMeta` to type them.
|
|
378
|
+
*/
|
|
379
|
+
get meta() {
|
|
380
|
+
return this.context.meta;
|
|
381
|
+
}
|
|
382
|
+
constructor(init, args = []) {
|
|
383
|
+
this.emitter = new Emitter();
|
|
384
|
+
this.inFlight = false;
|
|
385
|
+
this.init = init;
|
|
386
|
+
this.args = args;
|
|
387
|
+
this.name = `${init.serverOptions.name}.${init.apiName}.${init.methodName}`;
|
|
388
|
+
this.methodName = init.methodName;
|
|
389
|
+
this.methodType = init.methodType;
|
|
390
|
+
this.route = init.route;
|
|
391
|
+
this.context = new SnailContext({
|
|
392
|
+
server: init.server,
|
|
393
|
+
serverOptions: init.serverOptions,
|
|
394
|
+
apiClass: init.apiClass,
|
|
395
|
+
api: init.api,
|
|
396
|
+
apiName: init.apiName,
|
|
397
|
+
apiOptions: init.apiOptions,
|
|
398
|
+
methodName: init.methodName,
|
|
399
|
+
methodType: init.methodType,
|
|
400
|
+
route: init.route,
|
|
401
|
+
request: init.requestConfig(),
|
|
402
|
+
descriptors: init.descriptors,
|
|
403
|
+
logger: init.logger
|
|
404
|
+
});
|
|
405
|
+
init.pluginManager.runEffectsSync("initMeta", this.context);
|
|
406
|
+
}
|
|
407
|
+
/** `true` while a request is in flight. */
|
|
408
|
+
get pending() {
|
|
409
|
+
return this.inFlight;
|
|
410
|
+
}
|
|
411
|
+
/** Result of the most recent successful request. */
|
|
412
|
+
get result() {
|
|
413
|
+
return this.context.result;
|
|
414
|
+
}
|
|
415
|
+
/** Error from the most recent failed request. */
|
|
416
|
+
get error() {
|
|
417
|
+
return this.context.error;
|
|
418
|
+
}
|
|
419
|
+
/** The final axios config of the most recent request. */
|
|
420
|
+
get request() {
|
|
421
|
+
return this.context.request;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Send the request.
|
|
425
|
+
*
|
|
426
|
+
* Any arguments given here replace the ones captured when the method was
|
|
427
|
+
* proxied, which is what lets a strategy own one instance and vary the
|
|
428
|
+
* arguments per call.
|
|
429
|
+
*
|
|
430
|
+
* ## One request in flight at a time
|
|
431
|
+
*
|
|
432
|
+
* A `SnailMethod` owns exactly one context, and that context is what holds the
|
|
433
|
+
* caller's reactive handles. Two overlapping `send()` calls would therefore
|
|
434
|
+
* race over the same `ctx.response`, and the slower response could land in the
|
|
435
|
+
* state the faster one already reported.
|
|
436
|
+
*
|
|
437
|
+
* Starting a second send therefore **aborts the first**, so the previous call
|
|
438
|
+
* rejects with a `SnailCancelledError` and "latest wins" holds. Callers that
|
|
439
|
+
* genuinely want two parallel requests should create two instances —
|
|
440
|
+
* `userApi.getUser("1")` and `userApi.getUser("2")` — which is also what makes
|
|
441
|
+
* the argument types line up.
|
|
442
|
+
*/
|
|
443
|
+
async send(...args) {
|
|
444
|
+
const callArgs = args.length > 0 ? args : this.args;
|
|
445
|
+
const ctx = this.begin();
|
|
446
|
+
this.inFlight = true;
|
|
447
|
+
ctx.logger.info(t("info.request.start", this.methodType, `${ctx.request.baseURL ?? ""}${this.route}`, this.name));
|
|
448
|
+
try {
|
|
449
|
+
await this.init.pluginManager.ready;
|
|
450
|
+
if (Object.keys(ctx.meta).length === 0) this.init.pluginManager.runEffectsSync("initMeta", ctx);
|
|
451
|
+
this.init.pluginManager.runEffectsSync("beforeCreate", ctx);
|
|
452
|
+
applyParamDescriptors(ctx, callArgs);
|
|
453
|
+
finalizeRequestURL(ctx);
|
|
454
|
+
await this.init.pluginManager.runChain("beforeRequest", ctx, () => this.dispatch(ctx));
|
|
455
|
+
if (!ctx.response) throw new SnailCancelledError(t("error.request.cancelled", this.name));
|
|
456
|
+
await this.init.pluginManager.runChain("afterResponse", ctx);
|
|
457
|
+
const result = this.finalize(ctx);
|
|
458
|
+
this.emitter.emit("success", result);
|
|
459
|
+
return result;
|
|
460
|
+
} catch (error) {
|
|
461
|
+
throw await this.fail(ctx, error);
|
|
462
|
+
} finally {
|
|
463
|
+
this.inFlight = false;
|
|
464
|
+
ctx.finishedAt = Date.now();
|
|
465
|
+
try {
|
|
466
|
+
await this.init.pluginManager.runEffects("afterRequest", ctx);
|
|
467
|
+
} catch (cleanupError) {
|
|
468
|
+
ctx.logger.error(t("error.request.failed", this.name, `afterRequest hook: ${String(cleanupError)}`));
|
|
469
|
+
}
|
|
470
|
+
this.emitter.emit("finish", void 0);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Cancel the in-flight request.
|
|
475
|
+
*
|
|
476
|
+
* `send()` rejects with a {@link SnailCancelledError}, which strategies treat
|
|
477
|
+
* as expected control flow rather than a failure.
|
|
478
|
+
*/
|
|
479
|
+
abort(reason) {
|
|
480
|
+
this.controller?.abort(reason);
|
|
481
|
+
}
|
|
482
|
+
/** Subscribe to a successful request. Returns an unsubscribe function. */
|
|
483
|
+
onSuccess(listener) {
|
|
484
|
+
return this.emitter.on("success", listener);
|
|
485
|
+
}
|
|
486
|
+
/** Subscribe to a failed request. */
|
|
487
|
+
onError(listener) {
|
|
488
|
+
return this.emitter.on("error", listener);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Subscribe to a rejected business code.
|
|
492
|
+
*
|
|
493
|
+
* Observation only: the request still rejects with a `SnailResponseError`, so
|
|
494
|
+
* this is the right place to raise a toast, not to recover.
|
|
495
|
+
*/
|
|
496
|
+
onCodeError(listener) {
|
|
497
|
+
return this.emitter.on("codeError", listener);
|
|
498
|
+
}
|
|
499
|
+
/** Subscribe to settlement, successful or not. */
|
|
500
|
+
onFinish(listener) {
|
|
501
|
+
return this.emitter.on("finish", listener);
|
|
502
|
+
}
|
|
503
|
+
/** Subscribe to a response served from a cache. */
|
|
504
|
+
onHitCache(listener) {
|
|
505
|
+
return this.emitter.on("cache", listener);
|
|
506
|
+
}
|
|
507
|
+
/** Reset the context for a fresh send, superseding any request already in flight. */
|
|
508
|
+
begin() {
|
|
509
|
+
const ctx = this.context;
|
|
510
|
+
this.controller?.abort();
|
|
511
|
+
this.controller = new AbortController();
|
|
512
|
+
const config = this.init.requestConfig();
|
|
513
|
+
config.signal = this.controller.signal;
|
|
514
|
+
ctx.reset(config);
|
|
515
|
+
return ctx;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* The transport step.
|
|
519
|
+
*
|
|
520
|
+
* Runs only when every `beforeRequest` hook called `next()`, i.e. only when the
|
|
521
|
+
* response has to come from the network. The `afterResponse` chain is *not* here
|
|
522
|
+
* — see {@link SnailMethod.send} for why it has to run on a cache hit too.
|
|
523
|
+
*/
|
|
524
|
+
async dispatch(ctx) {
|
|
525
|
+
let config = this.init.pluginManager.reduce("requestInterceptor", ctx.request, ctx);
|
|
526
|
+
ctx.request = config;
|
|
527
|
+
let response;
|
|
528
|
+
try {
|
|
529
|
+
response = await this.init.axios.request(config);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
throw this.normalizeTransportError(error);
|
|
532
|
+
}
|
|
533
|
+
response = coerceJSONStringBody(response, ctx.serverOptions.coerceJSONString);
|
|
534
|
+
ctx.setResponse(response);
|
|
535
|
+
const intercepted = this.init.pluginManager.reduce("responseInterceptor", response, ctx);
|
|
536
|
+
ctx.setResponse(intercepted);
|
|
537
|
+
}
|
|
538
|
+
/** Validate the envelope and assemble the caller-facing result. */
|
|
539
|
+
finalize(ctx) {
|
|
540
|
+
const response = ctx.requireResponse();
|
|
541
|
+
const envelope = response.data;
|
|
542
|
+
const { codeKey, messageKey, dataKey, validateCode } = ctx.serverOptions;
|
|
543
|
+
const code = readKey(envelope, codeKey);
|
|
544
|
+
assertBusinessCode({
|
|
545
|
+
body: envelope,
|
|
546
|
+
code,
|
|
547
|
+
dataKey,
|
|
548
|
+
validate: validateCode,
|
|
549
|
+
fullName: this.name,
|
|
550
|
+
message: t("error.response.code", this.name, String(code))
|
|
551
|
+
});
|
|
552
|
+
const result = buildResult({
|
|
553
|
+
response,
|
|
554
|
+
envelope,
|
|
555
|
+
codeKey,
|
|
556
|
+
messageKey,
|
|
557
|
+
dataKey,
|
|
558
|
+
fromCache: ctx.isCacheHit,
|
|
559
|
+
config: ctx.request
|
|
560
|
+
});
|
|
561
|
+
ctx.setResult(result);
|
|
562
|
+
if (ctx.isCacheHit) this.emitter.emit("cache", void 0);
|
|
563
|
+
ctx.logger.info(t("info.request.success", this.methodType, `${response.config.baseURL ?? ""}${response.config.url ?? ""}`, this.name, `${Math.round(ctx.elapsed)}ms`));
|
|
564
|
+
return result;
|
|
565
|
+
}
|
|
566
|
+
/** Report a failure through the plugin hooks and the events, then rethrow it. */
|
|
567
|
+
async fail(ctx, error) {
|
|
568
|
+
ctx.error = error;
|
|
569
|
+
try {
|
|
570
|
+
await this.init.pluginManager.runEffects("onError", ctx, error);
|
|
571
|
+
} catch (hookError) {
|
|
572
|
+
ctx.logger.error(t("error.request.failed", this.name, `onError hook: ${String(hookError)}`));
|
|
573
|
+
}
|
|
574
|
+
if (error instanceof SnailResponseError) {
|
|
575
|
+
this.emitter.emit("codeError", {
|
|
576
|
+
code: error.businessCode,
|
|
577
|
+
payload: error.payload,
|
|
578
|
+
error
|
|
579
|
+
});
|
|
580
|
+
ctx.logger.warn(t("info.request.codeError", this.methodType, this.route, this.name, String(error.businessCode)));
|
|
581
|
+
} else {
|
|
582
|
+
this.emitter.emit("error", error);
|
|
583
|
+
if (!(error instanceof SnailCancelledError)) ctx.logger.error(t("info.request.error", this.methodType, this.route, this.name, String(error)));
|
|
584
|
+
}
|
|
585
|
+
return error;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Turn axios' error vocabulary into ours.
|
|
589
|
+
*
|
|
590
|
+
* The rule is: **if the server answered, hand back axios' error unchanged** —
|
|
591
|
+
* it carries `response.status` and `response.data`, which applications and the
|
|
592
|
+
* auth strategies both branch on. Only when no response exists at all (DNS
|
|
593
|
+
* failure, offline, CORS rejection) is there nothing useful to preserve, so
|
|
594
|
+
* that case becomes a typed {@link SnailHttpError} with the axios error as its
|
|
595
|
+
* `cause`.
|
|
596
|
+
*/
|
|
597
|
+
normalizeTransportError(error) {
|
|
598
|
+
if (error instanceof SnailCancelledError) return error;
|
|
599
|
+
const axiosError = error;
|
|
600
|
+
if (isCancel(error) || axiosError?.code === "ERR_CANCELED") return new SnailCancelledError(t("error.request.cancelled", this.name), { cause: error });
|
|
601
|
+
if (axiosError?.code === "ECONNABORTED" || axiosError?.code === "ETIMEDOUT") return new SnailTimeoutError(t("error.request.timeout", this.name, String(axiosError.config?.timeout ?? "")), {
|
|
602
|
+
timeout: axiosError.config?.timeout,
|
|
603
|
+
cause: error
|
|
604
|
+
});
|
|
605
|
+
if (axiosError?.isAxiosError === true && !axiosError.response) return new SnailHttpError(t("error.request.failed", this.name, axiosError.message ?? "network error"), {
|
|
606
|
+
cause: error,
|
|
607
|
+
code: "SNAIL_NETWORK_ERROR"
|
|
608
|
+
});
|
|
609
|
+
return error;
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
//#endregion
|
|
613
|
+
//#region src/core/plugin-manager.ts
|
|
614
|
+
/** Hook names that traverse the plugin list in the forward direction. */
|
|
615
|
+
var FORWARD_HOOKS = /* @__PURE__ */ new Set([
|
|
616
|
+
"configureServer",
|
|
617
|
+
"configureApi",
|
|
618
|
+
"configureMethod",
|
|
619
|
+
"initMeta",
|
|
620
|
+
"beforeCreate",
|
|
621
|
+
"beforeRequest",
|
|
622
|
+
"requestInterceptor"
|
|
623
|
+
]);
|
|
624
|
+
/** Hook names that traverse the plugin list in the unwind direction. */
|
|
625
|
+
var UNWIND_HOOKS = /* @__PURE__ */ new Set([
|
|
626
|
+
"afterResponse",
|
|
627
|
+
"responseInterceptor",
|
|
628
|
+
"onError",
|
|
629
|
+
"afterRequest"
|
|
630
|
+
]);
|
|
631
|
+
/**
|
|
632
|
+
* Per-server plugin registry.
|
|
633
|
+
*
|
|
634
|
+
* ## Why this is not a singleton
|
|
635
|
+
*
|
|
636
|
+
* The pre-rewrite implementation kept one global manager plus a mutable
|
|
637
|
+
* `_server` pointer that every caller had to set with `switchServer()` before
|
|
638
|
+
* `getHooks()`. Any two servers in flight — or simply two interleaved `await`s —
|
|
639
|
+
* silently read the *wrong* server's plugins. Each `SnailServer` now owns its
|
|
640
|
+
* own manager and passes its name in the constructor, so that class of bug is
|
|
641
|
+
* unrepresentable.
|
|
642
|
+
*
|
|
643
|
+
* ## Ordering
|
|
644
|
+
*
|
|
645
|
+
* Plugins sort by `priority` descending, ties broken by registration order.
|
|
646
|
+
*
|
|
647
|
+
* - **Forward hooks** run highest-priority first, so an interceptor (`100`)
|
|
648
|
+
* installs before the cache (`-100`).
|
|
649
|
+
* - **Unwind hooks** run in the opposite direction, closing the onion: the
|
|
650
|
+
* plugin closest to the network reacts to the response first.
|
|
651
|
+
*
|
|
652
|
+
* ## Sync registration, async install
|
|
653
|
+
*
|
|
654
|
+
* `register()` is synchronous and returns immediately, so `Service.use(A).use(B)`
|
|
655
|
+
* chains naturally while a `dependsOn` mistake still throws on the spot. The
|
|
656
|
+
* `install` hooks themselves may be async; {@link PluginManager.ready} is awaited
|
|
657
|
+
* once per request, before any plugin hook runs.
|
|
658
|
+
*/
|
|
659
|
+
var PluginManager = class {
|
|
660
|
+
constructor(serverName, serverOptions) {
|
|
661
|
+
this.serverName = serverName;
|
|
662
|
+
this.serverOptions = serverOptions;
|
|
663
|
+
this.registry = /* @__PURE__ */ new Map();
|
|
664
|
+
this.order = [];
|
|
665
|
+
this.installTasks = [];
|
|
666
|
+
this.sequence = 0;
|
|
667
|
+
}
|
|
668
|
+
/** Number of registered plugins. */
|
|
669
|
+
get size() {
|
|
670
|
+
return this.registry.size;
|
|
671
|
+
}
|
|
672
|
+
/** Resolves once every `install` hook has settled. */
|
|
673
|
+
get ready() {
|
|
674
|
+
return Promise.all(this.installTasks).then(() => void 0);
|
|
675
|
+
}
|
|
676
|
+
/** `true` when a plugin with this name is registered. */
|
|
677
|
+
has(name) {
|
|
678
|
+
return this.registry.has(name);
|
|
679
|
+
}
|
|
680
|
+
/** Read one registered plugin. */
|
|
681
|
+
get(name) {
|
|
682
|
+
return this.registry.get(name);
|
|
683
|
+
}
|
|
684
|
+
/** Registered plugin names in chain order (forward phase). */
|
|
685
|
+
names() {
|
|
686
|
+
return this.sorted("forward").map((entry) => entry.name);
|
|
687
|
+
}
|
|
688
|
+
/** All registered plugins in chain order (forward phase). */
|
|
689
|
+
list() {
|
|
690
|
+
return this.sorted("forward");
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Validate and register a plugin. Synchronous by design.
|
|
694
|
+
*
|
|
695
|
+
* Throws {@link SnailPluginError} for a nameless plugin, a duplicate name or
|
|
696
|
+
* an unsatisfied `dependsOn` — all before anything is mutated.
|
|
697
|
+
*/
|
|
698
|
+
register(plugin) {
|
|
699
|
+
if (!plugin || typeof plugin !== "object") throw new SnailPluginError("[snail] use() expects a plugin object");
|
|
700
|
+
const { name } = plugin;
|
|
701
|
+
if (typeof name !== "string" || name.length === 0) throw new SnailPluginError("[snail] a plugin must declare a non-empty `name`");
|
|
702
|
+
if (this.registry.has(name)) throw new SnailPluginError(t("error.options.plugin.exists", name, this.serverName), { pluginName: name });
|
|
703
|
+
for (const dependency of plugin.dependsOn ?? []) if (!this.registry.has(dependency)) throw new SnailPluginError(t("error.options.plugin.missing", name, dependency), { pluginName: name });
|
|
704
|
+
this.registry.set(name, {
|
|
705
|
+
name,
|
|
706
|
+
priority: Number.isFinite(plugin.priority) ? plugin.priority : 0,
|
|
707
|
+
index: this.sequence++,
|
|
708
|
+
instance: plugin
|
|
709
|
+
});
|
|
710
|
+
this.order.push(name);
|
|
711
|
+
if (typeof plugin.install === "function") {
|
|
712
|
+
const context = {
|
|
713
|
+
serverName: this.serverName,
|
|
714
|
+
serverOptions: this.serverOptions,
|
|
715
|
+
pluginNames: this.names()
|
|
716
|
+
};
|
|
717
|
+
let outcome;
|
|
718
|
+
try {
|
|
719
|
+
outcome = plugin.install(context, plugin.options);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
this.registry.delete(name);
|
|
722
|
+
this.order.pop();
|
|
723
|
+
throw new SnailPluginError(`[snail] plugin "${name}" failed to install: ${String(error)}`, {
|
|
724
|
+
pluginName: name,
|
|
725
|
+
cause: error
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
if (outcome && typeof outcome.then === "function") this.installTasks.push(Promise.resolve(outcome).then(() => void 0, (error) => {
|
|
729
|
+
throw new SnailPluginError(`[snail] plugin "${name}" failed to install: ${String(error)}`, {
|
|
730
|
+
pluginName: name,
|
|
731
|
+
cause: error
|
|
732
|
+
});
|
|
733
|
+
}));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/** Unregister a plugin, running its `uninstall` hook first. */
|
|
737
|
+
async remove(name) {
|
|
738
|
+
const entry = this.registry.get(name);
|
|
739
|
+
if (!entry) throw new SnailPluginError(t("error.options.plugin.notFound", name, this.serverName), { pluginName: name });
|
|
740
|
+
await entry.instance.uninstall?.({
|
|
741
|
+
serverName: this.serverName,
|
|
742
|
+
serverOptions: this.serverOptions,
|
|
743
|
+
pluginNames: this.names()
|
|
744
|
+
}, entry.instance.options);
|
|
745
|
+
this.registry.delete(name);
|
|
746
|
+
const at = this.order.indexOf(name);
|
|
747
|
+
if (at !== -1) this.order.splice(at, 1);
|
|
748
|
+
}
|
|
749
|
+
/** Remove every plugin, unwinding in reverse registration order. */
|
|
750
|
+
async clear() {
|
|
751
|
+
for (const name of [...this.order].reverse()) await this.remove(name);
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Sorted plugin list.
|
|
755
|
+
*
|
|
756
|
+
* `"forward"` → priority descending (highest first).
|
|
757
|
+
* `"unwind"` → priority ascending (highest last).
|
|
758
|
+
*/
|
|
759
|
+
sorted(direction) {
|
|
760
|
+
const entries = this.order.map((name) => this.registry.get(name)).filter((entry) => entry !== void 0);
|
|
761
|
+
entries.sort((a, b) => a.priority === b.priority ? a.index - b.index : b.priority - a.priority);
|
|
762
|
+
return direction === "forward" ? entries : entries.reverse();
|
|
763
|
+
}
|
|
764
|
+
/** Hooks of one name, in the direction that hook traverses. */
|
|
765
|
+
hooks(hookName) {
|
|
766
|
+
if (!FORWARD_HOOKS.has(hookName) && !UNWIND_HOOKS.has(hookName)) throw new SnailPluginError(t("error.hook.unknown", hookName));
|
|
767
|
+
const direction = FORWARD_HOOKS.has(hookName) ? "forward" : "unwind";
|
|
768
|
+
const bound = [];
|
|
769
|
+
for (const entry of this.sorted(direction)) {
|
|
770
|
+
const hook = entry.instance[hookName];
|
|
771
|
+
if (typeof hook === "function") bound.push({
|
|
772
|
+
pluginName: entry.name,
|
|
773
|
+
hook
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
return bound;
|
|
777
|
+
}
|
|
778
|
+
/** `true` when at least one plugin implements `hookName`. */
|
|
779
|
+
hasHook(hookName) {
|
|
780
|
+
for (const name of this.order) if (typeof this.registry.get(name).instance[hookName] === "function") return true;
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Run a chain hook over every plugin, then `downstream`.
|
|
785
|
+
*
|
|
786
|
+
* A plugin that never calls `next()` stops the chain: `downstream` (for
|
|
787
|
+
* `beforeRequest`, the HTTP request itself) never runs. That is the mechanism
|
|
788
|
+
* behind a cache hit.
|
|
789
|
+
*/
|
|
790
|
+
async runChain(hookName, ctx, downstream) {
|
|
791
|
+
const hooks = this.hooks(hookName);
|
|
792
|
+
if (hooks.length === 0) {
|
|
793
|
+
await downstream?.();
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
await composeChain(hookName, hooks)(ctx, downstream);
|
|
797
|
+
}
|
|
798
|
+
/** Run a non-chain hook over every plugin, sequentially and awaited. */
|
|
799
|
+
async runEffects(hookName, ...args) {
|
|
800
|
+
for (const { hook } of this.hooks(hookName)) await hook(...args);
|
|
801
|
+
}
|
|
802
|
+
/** Run a non-chain hook synchronously — for constructor-time hooks. */
|
|
803
|
+
runEffectsSync(hookName, ...args) {
|
|
804
|
+
for (const { hook } of this.hooks(hookName)) hook(...args);
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Fold a value through every plugin's hook.
|
|
808
|
+
*
|
|
809
|
+
* Used by `requestInterceptor` / `responseInterceptor`, where each plugin may
|
|
810
|
+
* return a replacement for the value it received. Returning `undefined` keeps
|
|
811
|
+
* the previous value.
|
|
812
|
+
*/
|
|
813
|
+
reduce(hookName, initial, ...args) {
|
|
814
|
+
let current = initial;
|
|
815
|
+
for (const { hook } of this.hooks(hookName)) {
|
|
816
|
+
const next = hook(current, ...args);
|
|
817
|
+
if (next !== void 0 && next !== null) current = next;
|
|
818
|
+
}
|
|
819
|
+
return current;
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
//#endregion
|
|
823
|
+
//#region src/error/options.ts
|
|
824
|
+
/**
|
|
825
|
+
* Thrown when a required configuration block is missing — a server class
|
|
826
|
+
* without `@Server()`, an api class without `@Api()`, or a value that failed
|
|
827
|
+
* one of the option guards.
|
|
828
|
+
*/
|
|
829
|
+
var SnailOptionsError = class extends SnailError {
|
|
830
|
+
constructor(message, options = {}) {
|
|
831
|
+
super(message, {
|
|
832
|
+
code: "SNAIL_OPTIONS_ERROR",
|
|
833
|
+
cause: options.cause
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
//#endregion
|
|
838
|
+
//#region src/core/resolve.ts
|
|
839
|
+
/**
|
|
840
|
+
* Turns raw decorator metadata into concrete, fully-defaulted options.
|
|
841
|
+
*
|
|
842
|
+
* Everything a request needs is resolved *here* rather than spread across the
|
|
843
|
+
* server, the proxy and the method. One place to read, one place to test.
|
|
844
|
+
*/
|
|
845
|
+
/** Read and default the options written by `@Server(...)`. */
|
|
846
|
+
function resolveServerOptions(serverClass, fallbackName) {
|
|
847
|
+
const declared = getMetadata(SNAIL_SERVER_OPTIONS, serverClass);
|
|
848
|
+
if (!declared) throw new SnailOptionsError(t("error.options.server.missing", fallbackName));
|
|
849
|
+
const baseURL = declared.baseURL ?? DEFAULT_SERVER_OPTIONS.baseURL;
|
|
850
|
+
if (typeof baseURL !== "string" || baseURL.length === 0) throw new SnailOptionsError(t("error.options.server.baseURL"));
|
|
851
|
+
return {
|
|
852
|
+
...declared,
|
|
853
|
+
name: declared.name ?? fallbackName,
|
|
854
|
+
baseURL,
|
|
855
|
+
timeout: declared.timeout ?? DEFAULT_SERVER_OPTIONS.timeout,
|
|
856
|
+
codeKey: declared.codeKey ?? DEFAULT_SERVER_OPTIONS.codeKey,
|
|
857
|
+
messageKey: declared.messageKey ?? DEFAULT_SERVER_OPTIONS.messageKey,
|
|
858
|
+
dataKey: declared.dataKey ?? DEFAULT_SERVER_OPTIONS.dataKey,
|
|
859
|
+
logLevel: declared.logLevel ?? DEFAULT_SERVER_OPTIONS.logLevel,
|
|
860
|
+
coerceJSONString: declared.coerceJSONString ?? DEFAULT_SERVER_OPTIONS.coerceJSONString
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
/** Read and default the options written by `@Api(...)`. */
|
|
864
|
+
function resolveApiOptions(apiClass, fallbackName) {
|
|
865
|
+
const declared = getMetadata(SNAIL_API_OPTIONS, apiClass) ?? {};
|
|
866
|
+
if (declared.url !== void 0 && typeof declared.url !== "string") throw new SnailOptionsError(t("error.options.api.url"));
|
|
867
|
+
return {
|
|
868
|
+
url: declared.url ?? "",
|
|
869
|
+
name: declared.name ?? fallbackName,
|
|
870
|
+
timeout: declared.timeout,
|
|
871
|
+
adapter: declared.adapter,
|
|
872
|
+
responseType: declared.responseType,
|
|
873
|
+
withCredentials: declared.withCredentials
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
/** Read the full request-method options written by `@Get(path, options)`. */
|
|
877
|
+
function resolveMethodDecoratorOptions(apiClass, methodName) {
|
|
878
|
+
return getMetadata(SNAIL_REQUEST_METHOD, apiClass, methodName);
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Merge the three levels of `@Header(...)` — api class, then method.
|
|
882
|
+
*
|
|
883
|
+
* Method-level headers win, which is the only ordering that lets a single
|
|
884
|
+
* endpoint override a class-wide default.
|
|
885
|
+
*/
|
|
886
|
+
function resolveHeaders(apiClass, methodName) {
|
|
887
|
+
const apiHeaders = getMetadata(SNAIL_HEADERS, apiClass) ?? {};
|
|
888
|
+
const methodHeaders = getMetadata(SNAIL_HEADERS, apiClass, methodName) ?? {};
|
|
889
|
+
return AxiosHeaders.from({
|
|
890
|
+
...apiHeaders,
|
|
891
|
+
...methodHeaders
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
/** Progress callbacks written by `@UploadProgress()` / `@DownloadProgress()`. */
|
|
895
|
+
function resolveProgress(apiClass, methodName) {
|
|
896
|
+
return {
|
|
897
|
+
onUploadProgress: getMetadata(SNAIL_UPLOAD_PROGRESS, apiClass, methodName),
|
|
898
|
+
onDownloadProgress: getMetadata(SNAIL_DOWNLOAD_PROGRESS, apiClass, methodName)
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
/** Join the api prefix with a method path. */
|
|
902
|
+
function resolveRoute(apiURL, methodPath) {
|
|
903
|
+
return buildRequestURL(apiURL, methodPath || "");
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Build the axios config a request starts from.
|
|
907
|
+
*
|
|
908
|
+
* Values cascade method → api → server, and only the winner survives. The
|
|
909
|
+
* argument decorators and the plugins refine this further during the pipeline.
|
|
910
|
+
*/ function buildBaseRequestConfig(input) {
|
|
911
|
+
const { serverOptions, apiOptions, methodOptions, methodType, headers } = input;
|
|
912
|
+
const merged = AxiosHeaders.concat(headers);
|
|
913
|
+
for (const [key, value] of Object.entries(serverOptions.headers ?? {})) if (!merged.has(key)) merged.set(key, value);
|
|
914
|
+
return {
|
|
915
|
+
url: methodOptions.url,
|
|
916
|
+
method: methodType.toLowerCase(),
|
|
917
|
+
baseURL: serverOptions.baseURL,
|
|
918
|
+
timeout: methodOptions.timeout ?? apiOptions.timeout ?? serverOptions.timeout,
|
|
919
|
+
responseType: methodOptions.responseType ?? apiOptions.responseType ?? serverOptions.responseType ?? "json",
|
|
920
|
+
withCredentials: methodOptions.withCredentials ?? apiOptions.withCredentials ?? serverOptions.withCredentials,
|
|
921
|
+
adapter: methodOptions.adapter ?? apiOptions.adapter ?? serverOptions.adapter,
|
|
922
|
+
headers: merged,
|
|
923
|
+
params: {
|
|
924
|
+
...serverOptions.params ?? {},
|
|
925
|
+
...methodOptions.params ?? {}
|
|
926
|
+
},
|
|
927
|
+
data: methodOptions.data,
|
|
928
|
+
onUploadProgress: methodOptions.onUploadProgress,
|
|
929
|
+
onDownloadProgress: methodOptions.onDownloadProgress
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
//#endregion
|
|
933
|
+
//#region src/core/resolve-stream.ts
|
|
934
|
+
var EMPTY_SSE_HANDLERS = {
|
|
935
|
+
open: [],
|
|
936
|
+
error: [],
|
|
937
|
+
events: []
|
|
938
|
+
};
|
|
939
|
+
var EMPTY_WS_HANDLERS = {
|
|
940
|
+
open: [],
|
|
941
|
+
message: [],
|
|
942
|
+
close: [],
|
|
943
|
+
error: []
|
|
944
|
+
};
|
|
945
|
+
/** Read the `@Sse(...)` options and its registered handlers. */
|
|
946
|
+
function resolveSseEndpoint(streamClass) {
|
|
947
|
+
const declared = getMetadata(SNAIL_SSE_OPTIONS, streamClass);
|
|
948
|
+
if (!declared) return void 0;
|
|
949
|
+
return {
|
|
950
|
+
url: declared.url ?? "",
|
|
951
|
+
options: declared,
|
|
952
|
+
handlers: getMetadata(SNAIL_SSE_HANDLERS, streamClass) ?? EMPTY_SSE_HANDLERS
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
/** Read the `@WebSocket(...)` options and its registered handlers. */
|
|
956
|
+
function resolveWsEndpoint(streamClass) {
|
|
957
|
+
const declared = getMetadata(SNAIL_WS_OPTIONS, streamClass);
|
|
958
|
+
if (!declared) return void 0;
|
|
959
|
+
return {
|
|
960
|
+
url: declared.url ?? "",
|
|
961
|
+
options: declared,
|
|
962
|
+
handlers: getMetadata(SNAIL_WS_HANDLERS, streamClass) ?? EMPTY_WS_HANDLERS
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/** Read the `@HttpStream(...)` options of one method. */
|
|
966
|
+
function resolveHttpStreamEndpoint(apiClass, methodName) {
|
|
967
|
+
const declared = getMetadata(SNAIL_HTTP_STREAM, apiClass, methodName);
|
|
968
|
+
if (!declared) return void 0;
|
|
969
|
+
return {
|
|
970
|
+
url: declared.url ?? "",
|
|
971
|
+
options: declared
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Turn an http(s) `baseURL` into the matching WebSocket origin.
|
|
976
|
+
*
|
|
977
|
+
* `https://api.example.com` → `wss://api.example.com`. A relative `baseURL`
|
|
978
|
+
* (the common browser case) keeps working because `new WebSocket` resolves a
|
|
979
|
+
* relative url against the document base — but the scheme still has to be
|
|
980
|
+
* upgraded explicitly, which is what this does.
|
|
981
|
+
*/
|
|
982
|
+
function toWebSocketURL(url) {
|
|
983
|
+
if (url.startsWith("https://")) return `wss://${url.slice(8)}`;
|
|
984
|
+
if (url.startsWith("http://")) return `ws://${url.slice(7)}`;
|
|
985
|
+
return url;
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Bind every SSE handler to the instance that will receive the events.
|
|
989
|
+
*
|
|
990
|
+
* Decorators only ever see the prototype, so the raw functions are stored and
|
|
991
|
+
* bound here — one binding per `open()`, against the instance actually created by
|
|
992
|
+
* `createSse`.
|
|
993
|
+
*/
|
|
994
|
+
function rebindSseHandlers(handlers, instance) {
|
|
995
|
+
return {
|
|
996
|
+
open: handlers.open.map((fn) => fn.bind(instance)),
|
|
997
|
+
error: handlers.error.map((fn) => fn.bind(instance)),
|
|
998
|
+
events: handlers.events.map((entry) => ({
|
|
999
|
+
event: entry.event,
|
|
1000
|
+
handler: entry.handler.bind(instance)
|
|
1001
|
+
}))
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
/** Bind every WebSocket handler to the instance. @see rebindSseHandlers */
|
|
1005
|
+
function rebindWsHandlers(handlers, instance) {
|
|
1006
|
+
return {
|
|
1007
|
+
open: handlers.open.map((fn) => fn.bind(instance)),
|
|
1008
|
+
message: handlers.message.map((fn) => fn.bind(instance)),
|
|
1009
|
+
close: handlers.close.map((fn) => fn.bind(instance)),
|
|
1010
|
+
error: handlers.error.map((fn) => fn.bind(instance))
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
//#endregion
|
|
1014
|
+
//#region src/core/reconnect.ts
|
|
1015
|
+
/** Defaults used when a transport enables reconnecting without a policy. */
|
|
1016
|
+
var DEFAULT_RECONNECT_POLICY = {
|
|
1017
|
+
retries: 3,
|
|
1018
|
+
delayMs: 1e3,
|
|
1019
|
+
maxDelayMs: 3e4,
|
|
1020
|
+
factor: 2,
|
|
1021
|
+
jitter: true
|
|
1022
|
+
};
|
|
1023
|
+
/**
|
|
1024
|
+
* Normalise the `reconnect` option.
|
|
1025
|
+
*
|
|
1026
|
+
* Returns `undefined` when reconnecting is switched off, so callers can branch on
|
|
1027
|
+
* a single falsy check instead of handling `false | undefined | policy`.
|
|
1028
|
+
*/
|
|
1029
|
+
function resolveReconnectPolicy(policy) {
|
|
1030
|
+
if (policy === false) return void 0;
|
|
1031
|
+
if (policy === void 0) return { ...DEFAULT_RECONNECT_POLICY };
|
|
1032
|
+
return {
|
|
1033
|
+
...DEFAULT_RECONNECT_POLICY,
|
|
1034
|
+
...policy
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Exponential backoff with optional full jitter.
|
|
1039
|
+
*
|
|
1040
|
+
* `attempt` is 1-based: `attempt: 1` returns the first delay. The result is
|
|
1041
|
+
* always capped by `maxDelayMs`, so a long outage cannot push a retry hours out.
|
|
1042
|
+
*/
|
|
1043
|
+
function backoffDelay(attempt, policy) {
|
|
1044
|
+
const base = policy.delayMs * Math.pow(policy.factor, Math.max(0, attempt - 1));
|
|
1045
|
+
const capped = Math.min(base, policy.maxDelayMs);
|
|
1046
|
+
if (!policy.jitter) return capped;
|
|
1047
|
+
return Math.max(50, Math.round(Math.random() * capped));
|
|
1048
|
+
}
|
|
1049
|
+
/** `true` when another attempt is allowed. */
|
|
1050
|
+
function canRetry(attempt, policy) {
|
|
1051
|
+
return attempt <= policy.retries;
|
|
1052
|
+
}
|
|
1053
|
+
//#endregion
|
|
1054
|
+
//#region src/core/sse.ts
|
|
1055
|
+
/**
|
|
1056
|
+
* Server-Sent Events over `fetch`.
|
|
1057
|
+
*
|
|
1058
|
+
* `EventSource` is deliberately not used: it cannot send request headers, cannot
|
|
1059
|
+
* `POST`, cannot be aborted cleanly and reconnects on its own terms. A `fetch`
|
|
1060
|
+
* stream reader supports all four, and the parser below is ~50 lines.
|
|
1061
|
+
*/
|
|
1062
|
+
function createSseConnection(init) {
|
|
1063
|
+
const { url, options, handlers, name, logger } = init;
|
|
1064
|
+
const policy = resolveReconnectPolicy(options.reconnect ?? { retries: 3 });
|
|
1065
|
+
const opened = deferred();
|
|
1066
|
+
const closed = deferred();
|
|
1067
|
+
const controller = new AbortController();
|
|
1068
|
+
opened.promise.catch(() => void 0);
|
|
1069
|
+
let connected = false;
|
|
1070
|
+
let stopped = false;
|
|
1071
|
+
let attempt = 0;
|
|
1072
|
+
let retryHint;
|
|
1073
|
+
let timer;
|
|
1074
|
+
let lastError;
|
|
1075
|
+
/**
|
|
1076
|
+
* Whether the current attempt delivered at least one event.
|
|
1077
|
+
*
|
|
1078
|
+
* This is what separates a healthy connection from a doomed one. A server that
|
|
1079
|
+
* accepts the request, sends events and then closes the stream is behaving
|
|
1080
|
+
* correctly and should reconnect with a fresh retry budget; a server that
|
|
1081
|
+
* accepts and immediately closes without ever sending anything is looping, and
|
|
1082
|
+
* its retry budget must actually run out.
|
|
1083
|
+
*
|
|
1084
|
+
* Resetting the counter as soon as headers arrive — the obvious implementation
|
|
1085
|
+
* — makes the budget meaningless and produces an unbounded hot reconnect loop.
|
|
1086
|
+
*/
|
|
1087
|
+
let sawEvent = false;
|
|
1088
|
+
const messageListeners = /* @__PURE__ */ new Set();
|
|
1089
|
+
const eventListeners = /* @__PURE__ */ new Map();
|
|
1090
|
+
/** Invoke one listener set, keeping a broken listener from killing the stream. */
|
|
1091
|
+
const notify = (listeners, message) => {
|
|
1092
|
+
for (const listener of [...listeners]) try {
|
|
1093
|
+
listener(message);
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
logger.error(`[snail] ${name} SSE message listener threw`, error);
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
const dispatch = (message) => {
|
|
1099
|
+
sawEvent = true;
|
|
1100
|
+
for (const entry of handlers.events) {
|
|
1101
|
+
if (entry.event !== message.event) continue;
|
|
1102
|
+
try {
|
|
1103
|
+
entry.handler(message);
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
logger.error(`[snail] ${name} SSE handler for "${message.event}" threw`, error);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
notify(messageListeners, message);
|
|
1109
|
+
notify(eventListeners.get(message.event) ?? [], message);
|
|
1110
|
+
};
|
|
1111
|
+
const scheduleReconnect = () => {
|
|
1112
|
+
if (stopped) return;
|
|
1113
|
+
if (!policy || !canRetry(attempt + 1, policy)) {
|
|
1114
|
+
if (!connected) opened.reject(lastError ?? new SnailRequestError(t("error.request.failed", name, `SSE connection failed after ${attempt} attempt(s)`)));
|
|
1115
|
+
stopped = true;
|
|
1116
|
+
closed.resolve();
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
attempt += 1;
|
|
1120
|
+
const delay = retryHint ?? backoffDelay(attempt, policy);
|
|
1121
|
+
retryHint = void 0;
|
|
1122
|
+
timer = setTimeout(() => {
|
|
1123
|
+
run();
|
|
1124
|
+
}, delay);
|
|
1125
|
+
};
|
|
1126
|
+
const run = async () => {
|
|
1127
|
+
if (stopped) return;
|
|
1128
|
+
sawEvent = false;
|
|
1129
|
+
try {
|
|
1130
|
+
const response = await fetch(url, {
|
|
1131
|
+
method: options.method ?? "GET",
|
|
1132
|
+
headers: {
|
|
1133
|
+
accept: "text/event-stream",
|
|
1134
|
+
...options.headers ?? {},
|
|
1135
|
+
...init.headers ?? {}
|
|
1136
|
+
},
|
|
1137
|
+
body: options.method === "POST" && options.data !== void 0 ? JSON.stringify(options.data) : void 0,
|
|
1138
|
+
credentials: options.withCredentials ? "include" : "same-origin",
|
|
1139
|
+
signal: controller.signal
|
|
1140
|
+
});
|
|
1141
|
+
if (!response.ok) throw new SnailRequestError(t("error.request.failed", name, `SSE responded ${response.status} ${response.statusText}`));
|
|
1142
|
+
if (!response.body) throw new SnailRequestError(t("error.request.failed", name, "SSE response has no readable body"));
|
|
1143
|
+
if (!connected) {
|
|
1144
|
+
connected = true;
|
|
1145
|
+
opened.resolve();
|
|
1146
|
+
for (const handler of handlers.open) try {
|
|
1147
|
+
handler(new Event("open"));
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
logger.error(`[snail] ${name} SSE open handler threw`, error);
|
|
1150
|
+
}
|
|
1151
|
+
logger.info(t("info.sse.open", name));
|
|
1152
|
+
}
|
|
1153
|
+
await readStream(response.body, dispatch, (hint) => {
|
|
1154
|
+
retryHint = hint;
|
|
1155
|
+
});
|
|
1156
|
+
connected = false;
|
|
1157
|
+
lastError = void 0;
|
|
1158
|
+
if (sawEvent) attempt = 0;
|
|
1159
|
+
scheduleReconnect();
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
if (controller.signal.aborted || stopped) {
|
|
1162
|
+
stopped = true;
|
|
1163
|
+
closed.resolve();
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
lastError = error;
|
|
1167
|
+
for (const handler of handlers.error) try {
|
|
1168
|
+
handler(new Event("error"));
|
|
1169
|
+
} catch (handlerError) {
|
|
1170
|
+
logger.error(`[snail] ${name} SSE error handler threw`, handlerError);
|
|
1171
|
+
}
|
|
1172
|
+
logger.warn(t("error.request.failed", name, `SSE ${String(error)}`));
|
|
1173
|
+
connected = false;
|
|
1174
|
+
scheduleReconnect();
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
run();
|
|
1178
|
+
return withDispose({
|
|
1179
|
+
get connected() {
|
|
1180
|
+
return connected;
|
|
1181
|
+
},
|
|
1182
|
+
opened: opened.promise,
|
|
1183
|
+
closed: closed.promise,
|
|
1184
|
+
onMessage(listener) {
|
|
1185
|
+
messageListeners.add(listener);
|
|
1186
|
+
return () => {
|
|
1187
|
+
messageListeners.delete(listener);
|
|
1188
|
+
};
|
|
1189
|
+
},
|
|
1190
|
+
on(event, listener) {
|
|
1191
|
+
let listeners = eventListeners.get(event);
|
|
1192
|
+
if (!listeners) {
|
|
1193
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1194
|
+
eventListeners.set(event, listeners);
|
|
1195
|
+
}
|
|
1196
|
+
listeners.add(listener);
|
|
1197
|
+
return () => {
|
|
1198
|
+
listeners.delete(listener);
|
|
1199
|
+
};
|
|
1200
|
+
},
|
|
1201
|
+
close() {
|
|
1202
|
+
if (stopped) return;
|
|
1203
|
+
stopped = true;
|
|
1204
|
+
if (timer) clearTimeout(timer);
|
|
1205
|
+
controller.abort();
|
|
1206
|
+
connected = false;
|
|
1207
|
+
messageListeners.clear();
|
|
1208
|
+
eventListeners.clear();
|
|
1209
|
+
logger.info(t("info.sse.close", name));
|
|
1210
|
+
closed.resolve();
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Parse an event-stream body.
|
|
1216
|
+
*
|
|
1217
|
+
* Follows the WHATWG rules that matter in practice: lines are terminated by
|
|
1218
|
+
* `\n`, `\r\n` or `\r`; a blank line dispatches the buffered event; `:` starts a
|
|
1219
|
+
* comment; a field value keeps everything after the first colon, minus one
|
|
1220
|
+
* leading space.
|
|
1221
|
+
*/
|
|
1222
|
+
async function readStream(body, dispatch, onRetry) {
|
|
1223
|
+
const reader = body.getReader();
|
|
1224
|
+
const decoder = new TextDecoder("utf-8");
|
|
1225
|
+
let buffer = "";
|
|
1226
|
+
let eventName = "message";
|
|
1227
|
+
let dataLines = [];
|
|
1228
|
+
let lastId = "";
|
|
1229
|
+
let sawData = false;
|
|
1230
|
+
const flush = () => {
|
|
1231
|
+
if (!sawData) {
|
|
1232
|
+
eventName = "message";
|
|
1233
|
+
dataLines = [];
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
dispatch({
|
|
1237
|
+
event: eventName || "message",
|
|
1238
|
+
data: dataLines.join("\n"),
|
|
1239
|
+
id: lastId,
|
|
1240
|
+
retry: void 0
|
|
1241
|
+
});
|
|
1242
|
+
eventName = "message";
|
|
1243
|
+
dataLines = [];
|
|
1244
|
+
sawData = false;
|
|
1245
|
+
};
|
|
1246
|
+
while (true) {
|
|
1247
|
+
const { done, value } = await reader.read();
|
|
1248
|
+
if (done) break;
|
|
1249
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1250
|
+
let newlineIndex = buffer.search(/\r\n|\r|\n/);
|
|
1251
|
+
while (newlineIndex !== -1) {
|
|
1252
|
+
const line = buffer.slice(0, newlineIndex);
|
|
1253
|
+
const terminatorLength = buffer.startsWith("\r\n", newlineIndex) ? 2 : 1;
|
|
1254
|
+
buffer = buffer.slice(newlineIndex + terminatorLength);
|
|
1255
|
+
newlineIndex = buffer.search(/\r\n|\r|\n/);
|
|
1256
|
+
if (line.length === 0) {
|
|
1257
|
+
flush();
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
if (line.startsWith(":")) continue;
|
|
1261
|
+
const colon = line.indexOf(":");
|
|
1262
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
1263
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
1264
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
1265
|
+
switch (field) {
|
|
1266
|
+
case "event":
|
|
1267
|
+
eventName = value;
|
|
1268
|
+
break;
|
|
1269
|
+
case "data":
|
|
1270
|
+
dataLines.push(value);
|
|
1271
|
+
sawData = true;
|
|
1272
|
+
break;
|
|
1273
|
+
case "id":
|
|
1274
|
+
lastId = value;
|
|
1275
|
+
break;
|
|
1276
|
+
case "retry": {
|
|
1277
|
+
const parsed = Number.parseInt(value, 10);
|
|
1278
|
+
if (Number.isFinite(parsed)) onRetry(parsed);
|
|
1279
|
+
break;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
flush();
|
|
1285
|
+
}
|
|
1286
|
+
//#endregion
|
|
1287
|
+
//#region src/core/websocket.ts
|
|
1288
|
+
/**
|
|
1289
|
+
* WebSocket with reconnecting and an outbound queue.
|
|
1290
|
+
*
|
|
1291
|
+
* Two behaviours the platform socket does not give you, and which every real
|
|
1292
|
+
* application ends up writing by hand:
|
|
1293
|
+
*
|
|
1294
|
+
* - **Reconnect with backoff** — `close` is normal; without a policy the socket
|
|
1295
|
+
* simply stays dead after a blip.
|
|
1296
|
+
* - **Send-before-open queueing** — `open()` returns immediately, so a caller
|
|
1297
|
+
* that sends on the next line would otherwise throw `InvalidStateError`.
|
|
1298
|
+
*/
|
|
1299
|
+
function createWsConnection(init) {
|
|
1300
|
+
const { url, options, handlers, name, logger } = init;
|
|
1301
|
+
const policy = resolveReconnectPolicy(options.reconnect ?? { retries: 3 });
|
|
1302
|
+
const serializer = normalizeSerializer(options.serializer);
|
|
1303
|
+
const queueWhileConnecting = options.queueWhileConnecting ?? true;
|
|
1304
|
+
const opened = deferred();
|
|
1305
|
+
const closed = deferred();
|
|
1306
|
+
opened.promise.catch(() => void 0);
|
|
1307
|
+
let socket;
|
|
1308
|
+
let connected = false;
|
|
1309
|
+
let stopped = false;
|
|
1310
|
+
let attempt = 0;
|
|
1311
|
+
let timer;
|
|
1312
|
+
let queue = [];
|
|
1313
|
+
const emit = (kind, event) => {
|
|
1314
|
+
for (const handler of handlers[kind]) try {
|
|
1315
|
+
handler(event);
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
logger.error(`[snail] ${name} WS ${kind} handler threw`, error);
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
const flush = () => {
|
|
1321
|
+
if (!socket || !connected || queue.length === 0) return;
|
|
1322
|
+
const pending = queue;
|
|
1323
|
+
queue = [];
|
|
1324
|
+
for (const message of pending) socket.send(serializer.serialize(message));
|
|
1325
|
+
};
|
|
1326
|
+
const scheduleReconnect = () => {
|
|
1327
|
+
if (stopped) return;
|
|
1328
|
+
if (!policy || !canRetry(attempt + 1, policy)) {
|
|
1329
|
+
if (!connected) opened.reject(new SnailRequestError(t("error.request.failed", name, `WebSocket failed after ${attempt} attempt(s)`)));
|
|
1330
|
+
stopped = true;
|
|
1331
|
+
closed.resolve();
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
attempt += 1;
|
|
1335
|
+
timer = setTimeout(connect, backoffDelay(attempt, policy));
|
|
1336
|
+
};
|
|
1337
|
+
const connect = () => {
|
|
1338
|
+
if (stopped) return;
|
|
1339
|
+
let ws;
|
|
1340
|
+
try {
|
|
1341
|
+
ws = new WebSocket(url, options.protocols);
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
logger.warn(t("error.request.failed", name, `WebSocket ${String(error)}`));
|
|
1344
|
+
scheduleReconnect();
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
socket = ws;
|
|
1348
|
+
ws.onopen = (event) => {
|
|
1349
|
+
connected = true;
|
|
1350
|
+
attempt = 0;
|
|
1351
|
+
opened.resolve();
|
|
1352
|
+
emit("open", event);
|
|
1353
|
+
logger.info(t("info.ws.open", name));
|
|
1354
|
+
flush();
|
|
1355
|
+
};
|
|
1356
|
+
ws.onmessage = (event) => {
|
|
1357
|
+
const raw = options.serializer === "text" ? event : deserializeEvent(event, serializer);
|
|
1358
|
+
emit("message", raw);
|
|
1359
|
+
};
|
|
1360
|
+
ws.onerror = (event) => {
|
|
1361
|
+
emit("error", event);
|
|
1362
|
+
};
|
|
1363
|
+
ws.onclose = (event) => {
|
|
1364
|
+
connected = false;
|
|
1365
|
+
emit("close", event);
|
|
1366
|
+
logger.info(t("info.ws.close", name, String(event.code)));
|
|
1367
|
+
if (!stopped) scheduleReconnect();
|
|
1368
|
+
};
|
|
1369
|
+
};
|
|
1370
|
+
connect();
|
|
1371
|
+
return withDispose({
|
|
1372
|
+
get connected() {
|
|
1373
|
+
return connected;
|
|
1374
|
+
},
|
|
1375
|
+
opened: opened.promise,
|
|
1376
|
+
closed: closed.promise,
|
|
1377
|
+
send(data) {
|
|
1378
|
+
if (stopped) throw new SnailRequestError(t("error.request.failed", name, "cannot send on a closed WebSocket"));
|
|
1379
|
+
if (!connected) {
|
|
1380
|
+
if (!queueWhileConnecting) throw new SnailRequestError(t("error.request.failed", name, "WebSocket is not open yet"));
|
|
1381
|
+
queue.push(data);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
socket.send(serializer.serialize(data));
|
|
1385
|
+
},
|
|
1386
|
+
close() {
|
|
1387
|
+
if (stopped) return;
|
|
1388
|
+
stopped = true;
|
|
1389
|
+
if (timer) clearTimeout(timer);
|
|
1390
|
+
queue = [];
|
|
1391
|
+
connected = false;
|
|
1392
|
+
try {
|
|
1393
|
+
socket?.close(1e3, "client closed");
|
|
1394
|
+
} catch {}
|
|
1395
|
+
closed.resolve();
|
|
1396
|
+
}
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
function normalizeSerializer(input) {
|
|
1400
|
+
if (!input || input === "json") return {
|
|
1401
|
+
serialize: (value) => typeof value === "string" ? value : JSON.stringify(value),
|
|
1402
|
+
deserialize: (raw) => {
|
|
1403
|
+
try {
|
|
1404
|
+
return JSON.parse(raw);
|
|
1405
|
+
} catch {
|
|
1406
|
+
return raw;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
};
|
|
1410
|
+
if (input === "text") return {
|
|
1411
|
+
serialize: (value) => String(value),
|
|
1412
|
+
deserialize: (raw) => raw
|
|
1413
|
+
};
|
|
1414
|
+
return {
|
|
1415
|
+
serialize: (value) => String(input.serialize?.(value) ?? value),
|
|
1416
|
+
deserialize: (raw) => input.deserialize?.(raw) ?? raw
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
/** Decode a `MessageEvent` into a plain value for the handlers. */
|
|
1420
|
+
function deserializeEvent(event, serializer) {
|
|
1421
|
+
if (typeof event.data !== "string") return event;
|
|
1422
|
+
const decoded = serializer.deserialize(event.data);
|
|
1423
|
+
return {
|
|
1424
|
+
...event,
|
|
1425
|
+
data: decoded
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
//#endregion
|
|
1429
|
+
//#region src/core/server.ts
|
|
1430
|
+
/**
|
|
1431
|
+
* The server base class.
|
|
1432
|
+
*
|
|
1433
|
+
* Extend it, decorate the subclass with `@Server(...)` and instantiate once:
|
|
1434
|
+
*
|
|
1435
|
+
* ```ts
|
|
1436
|
+
* @Server({ baseURL: "/api", timeout: 5000 })
|
|
1437
|
+
* class BackEnd extends SnailServer {}
|
|
1438
|
+
*
|
|
1439
|
+
* export const Service = new BackEnd();
|
|
1440
|
+
* ```
|
|
1441
|
+
*
|
|
1442
|
+
* The instance owns an axios instance, a plugin registry and the resolved
|
|
1443
|
+
* options. It is created eagerly at module load, so `use()` is synchronous and
|
|
1444
|
+
* chainable: `Service.use(A()).use(B())`.
|
|
1445
|
+
*/
|
|
1446
|
+
var SnailServer = class {
|
|
1447
|
+
constructor() {
|
|
1448
|
+
this.apiCache = /* @__PURE__ */ new WeakMap();
|
|
1449
|
+
const serverClass = this.constructor;
|
|
1450
|
+
this.options = resolveServerOptions(serverClass, serverClass.name);
|
|
1451
|
+
this.name = this.options.name;
|
|
1452
|
+
this.logger = createLogger(this.options.logLevel);
|
|
1453
|
+
this.axios = axios.create();
|
|
1454
|
+
this.pluginManager = new PluginManager(this.name, this.options);
|
|
1455
|
+
for (const { hook } of this.pluginManager.hooks("configureServer")) hook(this.options);
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Register a plugin.
|
|
1459
|
+
*
|
|
1460
|
+
* Synchronous and chainable. Validation (name, duplicates, `dependsOn`) throws
|
|
1461
|
+
* immediately; an async `install` hook is awaited once, before the first
|
|
1462
|
+
* request, via the manager's `ready` promise.
|
|
1463
|
+
*/
|
|
1464
|
+
use(plugin) {
|
|
1465
|
+
const instance = typeof plugin === "function" ? plugin() : plugin;
|
|
1466
|
+
this.pluginManager.register(instance);
|
|
1467
|
+
return this;
|
|
1468
|
+
}
|
|
1469
|
+
/** Unregister a plugin by instance or by name. */
|
|
1470
|
+
async remove(plugin) {
|
|
1471
|
+
const name = typeof plugin === "string" ? plugin : plugin?.name;
|
|
1472
|
+
if (!name || !this.pluginManager.has(name)) return false;
|
|
1473
|
+
await this.pluginManager.remove(name);
|
|
1474
|
+
return true;
|
|
1475
|
+
}
|
|
1476
|
+
/** `true` when a plugin with this name is registered. */
|
|
1477
|
+
hasPlugin(name) {
|
|
1478
|
+
return this.pluginManager.has(name);
|
|
1479
|
+
}
|
|
1480
|
+
/** Registered plugin names, in chain order. */
|
|
1481
|
+
get plugins() {
|
|
1482
|
+
return this.pluginManager.names();
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Turn a decorated api class into a proxy whose methods build requests.
|
|
1486
|
+
*
|
|
1487
|
+
* ```ts
|
|
1488
|
+
* @Api("/user")
|
|
1489
|
+
* class UserApi {
|
|
1490
|
+
* @Get("/:id")
|
|
1491
|
+
* getUser(@Params("id") id: string): Promise<User> { return null!; }
|
|
1492
|
+
* }
|
|
1493
|
+
*
|
|
1494
|
+
* export const userApi = Service.createApi(UserApi);
|
|
1495
|
+
* const method = userApi.getUser("1"); // nothing sent yet
|
|
1496
|
+
* const { data } = await method.send();
|
|
1497
|
+
* ```
|
|
1498
|
+
*
|
|
1499
|
+
* A declared return type — `Promise<User>` above — becomes the payload type,
|
|
1500
|
+
* so `data` is `User` with no explicit generic.
|
|
1501
|
+
*/
|
|
1502
|
+
createApi(apiClass) {
|
|
1503
|
+
const cached = this.apiCache.get(apiClass);
|
|
1504
|
+
if (cached) return cached;
|
|
1505
|
+
const apiOptions = resolveApiOptions(apiClass, apiClass.name);
|
|
1506
|
+
for (const { hook } of this.pluginManager.hooks("configureApi")) hook(apiOptions, apiClass);
|
|
1507
|
+
const instance = new apiClass();
|
|
1508
|
+
const descriptors = /* @__PURE__ */ new Map();
|
|
1509
|
+
const descriptorFor = (methodName) => {
|
|
1510
|
+
if (descriptors.has(methodName)) return descriptors.get(methodName);
|
|
1511
|
+
const resolved = this.resolveMethodDescriptor(apiClass, apiOptions, methodName);
|
|
1512
|
+
descriptors.set(methodName, resolved);
|
|
1513
|
+
return resolved;
|
|
1514
|
+
};
|
|
1515
|
+
const proxied = new Proxy(instance, { get: (target, propertyKey, receiver) => {
|
|
1516
|
+
const value = Reflect.get(target, propertyKey, receiver);
|
|
1517
|
+
if (typeof propertyKey === "symbol" || typeof value !== "function") return value;
|
|
1518
|
+
const descriptor = descriptorFor(String(propertyKey));
|
|
1519
|
+
if (!descriptor) return value;
|
|
1520
|
+
if (descriptor.kind === "stream") return (...args) => this.createHttpStream(apiClass, apiOptions, descriptor, args);
|
|
1521
|
+
return (...args) => this.createMethod(apiClass, target, apiOptions, descriptor, args);
|
|
1522
|
+
} });
|
|
1523
|
+
this.apiCache.set(apiClass, proxied);
|
|
1524
|
+
return proxied;
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Turn a class decorated with `@Sse(...)` into an endpoint factory.
|
|
1528
|
+
*
|
|
1529
|
+
* ```ts
|
|
1530
|
+
* @Sse("/events")
|
|
1531
|
+
* class Events { @SseEvent() onMessage(m: SnailSseMessage) {} }
|
|
1532
|
+
*
|
|
1533
|
+
* const events = Service.createSse(Events);
|
|
1534
|
+
* const connection = events.open();
|
|
1535
|
+
* ```
|
|
1536
|
+
*/
|
|
1537
|
+
createSse(sseClass) {
|
|
1538
|
+
const endpoint = resolveSseEndpoint(sseClass);
|
|
1539
|
+
if (!endpoint) throw new SnailDecoratorError(`[snail] ${sseClass.name} is missing the @Sse() decorator`);
|
|
1540
|
+
const instance = new sseClass();
|
|
1541
|
+
return { open: () => createSseConnection({
|
|
1542
|
+
url: buildRequestURL(this.options.baseURL, endpoint.url),
|
|
1543
|
+
options: endpoint.options,
|
|
1544
|
+
handlers: rebindSseHandlers(endpoint.handlers, instance),
|
|
1545
|
+
name: `${this.options.name}.${sseClass.name}`,
|
|
1546
|
+
logger: this.logger
|
|
1547
|
+
}) };
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Turn a class decorated with `@WebSocket(...)` into an endpoint factory.
|
|
1551
|
+
*
|
|
1552
|
+
* ```ts
|
|
1553
|
+
* @WebSocket("/ws")
|
|
1554
|
+
* class Chat { @OnWsMessage() incoming(event: MessageEvent) {} }
|
|
1555
|
+
*
|
|
1556
|
+
* const chat = Service.createWebSocket(Chat);
|
|
1557
|
+
* const socket = chat.open();
|
|
1558
|
+
* socket.send({ hello: "world" });
|
|
1559
|
+
* ```
|
|
1560
|
+
*/
|
|
1561
|
+
createWebSocket(wsClass) {
|
|
1562
|
+
const endpoint = resolveWsEndpoint(wsClass);
|
|
1563
|
+
if (!endpoint) throw new SnailDecoratorError(`[snail] ${wsClass.name} is missing the @WebSocket() decorator`);
|
|
1564
|
+
const instance = new wsClass();
|
|
1565
|
+
return { open: () => createWsConnection({
|
|
1566
|
+
url: toWebSocketURL(buildRequestURL(this.options.baseURL, endpoint.url)),
|
|
1567
|
+
options: endpoint.options,
|
|
1568
|
+
handlers: rebindWsHandlers(endpoint.handlers, instance),
|
|
1569
|
+
name: `${this.options.name}.${wsClass.name}`,
|
|
1570
|
+
logger: this.logger
|
|
1571
|
+
}) };
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Send a one-off request that is **not** backed by a decorated api class.
|
|
1575
|
+
*
|
|
1576
|
+
* This is an escape hatch, and it is deliberately thin: it awaits plugin
|
|
1577
|
+
* installation and then calls the axios instance directly. **No lifecycle hook
|
|
1578
|
+
* runs** — no `beforeRequest`, no `afterResponse`, no caching, no validation, no
|
|
1579
|
+
* response transformation — and the envelope is *not* unwrapped, so you get the
|
|
1580
|
+
* raw `AxiosResponse` back.
|
|
1581
|
+
*
|
|
1582
|
+
* Prefer a decorated api method for anything that should participate in the
|
|
1583
|
+
* plugin pipeline. Reach for this only for a call that genuinely has no place in
|
|
1584
|
+
* a service definition, such as a health check against a third party.
|
|
1585
|
+
*/
|
|
1586
|
+
async request(config) {
|
|
1587
|
+
await this.pluginManager.ready;
|
|
1588
|
+
const merged = {
|
|
1589
|
+
baseURL: this.options.baseURL,
|
|
1590
|
+
timeout: this.options.timeout,
|
|
1591
|
+
...config
|
|
1592
|
+
};
|
|
1593
|
+
return await this.axios.request(merged);
|
|
1594
|
+
}
|
|
1595
|
+
/** Uninstall every plugin, running their cleanup hooks. */
|
|
1596
|
+
async dispose() {
|
|
1597
|
+
await this.pluginManager.clear();
|
|
1598
|
+
}
|
|
1599
|
+
/** Metadata helper for tooling and tests. */
|
|
1600
|
+
describe() {
|
|
1601
|
+
return {
|
|
1602
|
+
name: this.name,
|
|
1603
|
+
baseURL: this.options.baseURL,
|
|
1604
|
+
timeout: this.options.timeout,
|
|
1605
|
+
codeKey: this.options.codeKey,
|
|
1606
|
+
messageKey: this.options.messageKey,
|
|
1607
|
+
dataKey: this.options.dataKey,
|
|
1608
|
+
logLevel: this.options.logLevel,
|
|
1609
|
+
plugins: this.pluginManager.list().map((entry) => ({
|
|
1610
|
+
name: entry.name,
|
|
1611
|
+
priority: entry.priority
|
|
1612
|
+
}))
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Resolve everything static about one decorated method.
|
|
1617
|
+
*
|
|
1618
|
+
* Called once per method per api class. Returns `null` for a member that is not
|
|
1619
|
+
* a decorated endpoint, which is how an api class may keep plain helpers next to
|
|
1620
|
+
* its requests.
|
|
1621
|
+
*/
|
|
1622
|
+
resolveMethodDescriptor(apiClass, apiOptions, methodName) {
|
|
1623
|
+
const streamEndpoint = resolveHttpStreamEndpoint(apiClass, methodName);
|
|
1624
|
+
if (streamEndpoint) return {
|
|
1625
|
+
kind: "stream",
|
|
1626
|
+
methodName,
|
|
1627
|
+
methodType: (streamEndpoint.options.method ?? "POST").toUpperCase(),
|
|
1628
|
+
route: resolveRoute(apiOptions.url, streamEndpoint.url),
|
|
1629
|
+
streamOptions: streamEndpoint.options,
|
|
1630
|
+
headers: resolveHeaders(apiClass, methodName),
|
|
1631
|
+
descriptors: getMetadata(SNAIL_PARAMS, apiClass, methodName) ?? []
|
|
1632
|
+
};
|
|
1633
|
+
const methodOptions = resolveMethodDecoratorOptions(apiClass, methodName);
|
|
1634
|
+
if (!methodOptions) return null;
|
|
1635
|
+
const methodType = methodOptions.method;
|
|
1636
|
+
const progress = resolveProgress(apiClass, methodName);
|
|
1637
|
+
const headers = resolveHeaders(apiClass, methodName);
|
|
1638
|
+
const route = resolveRoute(apiOptions.url, methodOptions.url ?? "");
|
|
1639
|
+
const resolvedMethodOptions = {
|
|
1640
|
+
...methodOptions,
|
|
1641
|
+
url: route,
|
|
1642
|
+
onUploadProgress: methodOptions.onUploadProgress ?? progress.onUploadProgress,
|
|
1643
|
+
onDownloadProgress: methodOptions.onDownloadProgress ?? progress.onDownloadProgress
|
|
1644
|
+
};
|
|
1645
|
+
for (const { hook } of this.pluginManager.hooks("configureMethod")) hook(resolvedMethodOptions, methodName, apiOptions.name);
|
|
1646
|
+
return {
|
|
1647
|
+
kind: "request",
|
|
1648
|
+
methodName,
|
|
1649
|
+
methodType,
|
|
1650
|
+
route,
|
|
1651
|
+
methodOptions: resolvedMethodOptions,
|
|
1652
|
+
headers,
|
|
1653
|
+
descriptors: getMetadata(SNAIL_PARAMS, apiClass, methodName) ?? []
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Build the stream controller behind an `@HttpStream` method.
|
|
1658
|
+
*
|
|
1659
|
+
* A real context is constructed rather than a look-alike, so the `@Query()` /
|
|
1660
|
+
* `@Data()` / `@HeaderValue()` resolvers behave exactly as they do for a normal
|
|
1661
|
+
* request. The plugin pipeline is deliberately skipped: a byte stream has no
|
|
1662
|
+
* envelope to cache or validate.
|
|
1663
|
+
*/
|
|
1664
|
+
createHttpStream(apiClass, apiOptions, descriptor, args) {
|
|
1665
|
+
const streamOptions = descriptor.streamOptions;
|
|
1666
|
+
const methodOptions = {
|
|
1667
|
+
...streamOptions,
|
|
1668
|
+
url: descriptor.route,
|
|
1669
|
+
method: descriptor.methodType
|
|
1670
|
+
};
|
|
1671
|
+
const config = buildBaseRequestConfig({
|
|
1672
|
+
serverOptions: this.options,
|
|
1673
|
+
apiOptions,
|
|
1674
|
+
methodOptions,
|
|
1675
|
+
methodType: descriptor.methodType,
|
|
1676
|
+
headers: descriptor.headers
|
|
1677
|
+
});
|
|
1678
|
+
config.url = descriptor.route;
|
|
1679
|
+
const ctx = new SnailContext({
|
|
1680
|
+
server: this,
|
|
1681
|
+
serverOptions: this.options,
|
|
1682
|
+
apiClass,
|
|
1683
|
+
api: void 0,
|
|
1684
|
+
apiName: apiOptions.name || apiClass.name,
|
|
1685
|
+
apiOptions,
|
|
1686
|
+
methodName: descriptor.methodName,
|
|
1687
|
+
methodType: descriptor.methodType,
|
|
1688
|
+
route: descriptor.route,
|
|
1689
|
+
request: config,
|
|
1690
|
+
descriptors: descriptor.descriptors,
|
|
1691
|
+
logger: this.logger
|
|
1692
|
+
});
|
|
1693
|
+
applyParamDescriptors(ctx, args);
|
|
1694
|
+
finalizeRequestURL(ctx);
|
|
1695
|
+
const headers = Object.fromEntries(Object.entries(ctx.request.headers.toJSON()).map(([key, value]) => [key, String(value)]));
|
|
1696
|
+
return createHttpStream({
|
|
1697
|
+
url: buildRequestURL(this.options.baseURL, ctx.request.url ?? descriptor.route),
|
|
1698
|
+
options: {
|
|
1699
|
+
...streamOptions,
|
|
1700
|
+
method: descriptor.methodType
|
|
1701
|
+
},
|
|
1702
|
+
body: ctx.request.data,
|
|
1703
|
+
name: ctx.fullName,
|
|
1704
|
+
headers,
|
|
1705
|
+
logger: this.logger
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
/** Construct the request object a proxied method call returns. */
|
|
1709
|
+
createMethod(apiClass, api, apiOptions, descriptor, args) {
|
|
1710
|
+
const { methodName, methodType, route, methodOptions, headers, descriptors } = descriptor;
|
|
1711
|
+
return new SnailMethod({
|
|
1712
|
+
server: this,
|
|
1713
|
+
pluginManager: this.pluginManager,
|
|
1714
|
+
axios: this.axios,
|
|
1715
|
+
apiClass,
|
|
1716
|
+
api,
|
|
1717
|
+
apiName: apiOptions.name || apiClass.name,
|
|
1718
|
+
apiOptions,
|
|
1719
|
+
serverOptions: this.options,
|
|
1720
|
+
methodName,
|
|
1721
|
+
methodType,
|
|
1722
|
+
route,
|
|
1723
|
+
methodOptions,
|
|
1724
|
+
descriptors,
|
|
1725
|
+
headers,
|
|
1726
|
+
logger: this.logger,
|
|
1727
|
+
requestConfig: () => buildBaseRequestConfig({
|
|
1728
|
+
serverOptions: this.options,
|
|
1729
|
+
apiOptions,
|
|
1730
|
+
methodOptions,
|
|
1731
|
+
methodType,
|
|
1732
|
+
headers
|
|
1733
|
+
})
|
|
1734
|
+
}, args);
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
//#endregion
|
|
1738
|
+
//#region src/decorators/api.ts
|
|
1739
|
+
function Api(urlOrOptions) {
|
|
1740
|
+
const options = typeof urlOrOptions === "string" || urlOrOptions === void 0 ? { url: urlOrOptions ?? "" } : urlOrOptions;
|
|
1741
|
+
if (options.url !== void 0 && typeof options.url !== "string") throw new SnailDecoratorError(t("error.options.api.url"));
|
|
1742
|
+
return (target) => {
|
|
1743
|
+
if (typeof target !== "function") throw new SnailDecoratorError(t("error.decorator.class.target", "Api"));
|
|
1744
|
+
const previous = getOwnMetadata(SNAIL_API_OPTIONS, target);
|
|
1745
|
+
defineMetadata(SNAIL_API_OPTIONS, previous ? {
|
|
1746
|
+
...previous,
|
|
1747
|
+
...options
|
|
1748
|
+
} : { ...options }, target);
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1751
|
+
//#endregion
|
|
1752
|
+
//#region src/decorators/server.ts
|
|
1753
|
+
function Server(baseURLOrOptions) {
|
|
1754
|
+
const options = typeof baseURLOrOptions === "string" ? { baseURL: baseURLOrOptions } : baseURLOrOptions;
|
|
1755
|
+
return (target) => {
|
|
1756
|
+
if (typeof target !== "function") throw new SnailDecoratorError(t("error.decorator.class.target", "Server"));
|
|
1757
|
+
const previous = getOwnMetadata(SNAIL_SERVER_OPTIONS, target);
|
|
1758
|
+
defineMetadata(SNAIL_SERVER_OPTIONS, previous ? {
|
|
1759
|
+
...previous,
|
|
1760
|
+
...options
|
|
1761
|
+
} : { ...options }, target);
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
//#endregion
|
|
1765
|
+
//#region src/decorators/methods.ts
|
|
1766
|
+
/**
|
|
1767
|
+
* Build one request-method decorator.
|
|
1768
|
+
*
|
|
1769
|
+
* Decorated methods are never executed: `createApi` replaces them with a factory
|
|
1770
|
+
* that returns a request object. Their body exists purely to declare the argument
|
|
1771
|
+
* and return types, so `return null!` (or nothing at all) is the convention.
|
|
1772
|
+
*
|
|
1773
|
+
* Applying two request-method decorators to one method is an error and throws
|
|
1774
|
+
* while the class is being defined, which is the earliest possible moment.
|
|
1775
|
+
*/
|
|
1776
|
+
function createRequestMethod(method) {
|
|
1777
|
+
return (path = "", options = {}) => {
|
|
1778
|
+
if (typeof path !== "string") throw new SnailDecoratorError(`@${method} expects its first argument to be a string path`);
|
|
1779
|
+
return (target, propertyKey) => {
|
|
1780
|
+
if (propertyKey === void 0) throw new SnailDecoratorError(`@${method} must decorate a method, not a class or a property`);
|
|
1781
|
+
if (getOwnMetadata(SNAIL_REQUEST_METHOD, target, propertyKey)) throw new SnailDecoratorError(t("error.decorator.method.duplicate", String(propertyKey)));
|
|
1782
|
+
defineMetadata(SNAIL_REQUEST_METHOD, {
|
|
1783
|
+
...options,
|
|
1784
|
+
method,
|
|
1785
|
+
url: path
|
|
1786
|
+
}, target, propertyKey);
|
|
1787
|
+
};
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
/** `GET` request. */
|
|
1791
|
+
var Get = createRequestMethod("GET");
|
|
1792
|
+
/** `POST` request. */
|
|
1793
|
+
var Post = createRequestMethod("POST");
|
|
1794
|
+
/** `PUT` request. */
|
|
1795
|
+
var Put = createRequestMethod("PUT");
|
|
1796
|
+
/** `DELETE` request. */
|
|
1797
|
+
var Delete = createRequestMethod("DELETE");
|
|
1798
|
+
/** `PATCH` request. */
|
|
1799
|
+
var Patch = createRequestMethod("PATCH");
|
|
1800
|
+
/** `HEAD` request. */
|
|
1801
|
+
var Head = createRequestMethod("HEAD");
|
|
1802
|
+
/** `OPTIONS` request. */
|
|
1803
|
+
var Options = createRequestMethod("OPTIONS");
|
|
1804
|
+
/** Alias kept for symmetry with `axios.request` style naming. */
|
|
1805
|
+
var Request = createRequestMethod;
|
|
1806
|
+
//#endregion
|
|
1807
|
+
//#region src/decorators/header.ts
|
|
1808
|
+
/**
|
|
1809
|
+
* Static request headers.
|
|
1810
|
+
*
|
|
1811
|
+
* Works on an api class and on a single method; the two levels merge, with the
|
|
1812
|
+
* method winning:
|
|
1813
|
+
*
|
|
1814
|
+
* ```ts
|
|
1815
|
+
* @Api("/user")
|
|
1816
|
+
* @Header({ "x-client": "web" })
|
|
1817
|
+
* class UserApi {
|
|
1818
|
+
* @Get("/secret")
|
|
1819
|
+
* @Header({ "x-scope": "admin" })
|
|
1820
|
+
* secret() {}
|
|
1821
|
+
* }
|
|
1822
|
+
* // → x-client: web, x-scope: admin
|
|
1823
|
+
* ```
|
|
1824
|
+
*
|
|
1825
|
+
* For a header whose value comes from an argument, use the `@HeaderValue()`
|
|
1826
|
+
* parameter decorator.
|
|
1827
|
+
*/
|
|
1828
|
+
function Header(record) {
|
|
1829
|
+
return ((target, propertyKey) => {
|
|
1830
|
+
mergeMetadata(SNAIL_HEADERS, record, target, propertyKey);
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
//#endregion
|
|
1834
|
+
//#region src/decorators/progress.ts
|
|
1835
|
+
/**
|
|
1836
|
+
* Report upload progress for the decorated method.
|
|
1837
|
+
*
|
|
1838
|
+
* ```ts
|
|
1839
|
+
* @Post("/upload")
|
|
1840
|
+
* @UploadProgress((e) => { if (e.total) bar.value = e.loaded / e.total; })
|
|
1841
|
+
* upload(@Data() file: FormData) {}
|
|
1842
|
+
* ```
|
|
1843
|
+
*
|
|
1844
|
+
* Progress events require the `xhr` adapter; axios' `fetch` adapter cannot
|
|
1845
|
+
* report them. Set `adapter: "xhr"` on the method or the server when using this
|
|
1846
|
+
* decorator in a browser.
|
|
1847
|
+
*/
|
|
1848
|
+
function UploadProgress(callback) {
|
|
1849
|
+
return (target, propertyKey) => {
|
|
1850
|
+
defineMetadata(SNAIL_UPLOAD_PROGRESS, callback, target, propertyKey);
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* Report download progress for the decorated method.
|
|
1855
|
+
*
|
|
1856
|
+
* @see {@link UploadProgress} for the adapter caveat.
|
|
1857
|
+
*/
|
|
1858
|
+
function DownloadProgress(callback) {
|
|
1859
|
+
return (target, propertyKey) => {
|
|
1860
|
+
defineMetadata(SNAIL_DOWNLOAD_PROGRESS, callback, target, propertyKey);
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
//#endregion
|
|
1864
|
+
//#region src/decorators/stream.ts
|
|
1865
|
+
/**
|
|
1866
|
+
* Declare a Server-Sent Events endpoint.
|
|
1867
|
+
*
|
|
1868
|
+
* ```ts
|
|
1869
|
+
* @Sse("/events")
|
|
1870
|
+
* class Ticker {
|
|
1871
|
+
* @OnSseOpen()
|
|
1872
|
+
* open() { console.log("connected"); }
|
|
1873
|
+
*
|
|
1874
|
+
* @OnSseError()
|
|
1875
|
+
* failed(event: Event) { console.warn("lost", event); }
|
|
1876
|
+
*
|
|
1877
|
+
* @SseEvent() // the default `message` event
|
|
1878
|
+
* message(message: SnailSseMessage) {}
|
|
1879
|
+
*
|
|
1880
|
+
* @SseEvent("tick")
|
|
1881
|
+
* tick(message: SnailSseMessage) {}
|
|
1882
|
+
* }
|
|
1883
|
+
*
|
|
1884
|
+
* const ticker = Service.createSse(Ticker);
|
|
1885
|
+
* const connection = ticker.open();
|
|
1886
|
+
*
|
|
1887
|
+
* // Decorate with @SseEvent, or subscribe on the connection itself:
|
|
1888
|
+
* const off = connection.on("tick", (message) => console.log(message.data));
|
|
1889
|
+
* connection.close();
|
|
1890
|
+
* ```
|
|
1891
|
+
*
|
|
1892
|
+
* The transport is `fetch` + a stream reader rather than `EventSource`: that is
|
|
1893
|
+
* what makes request headers, `POST` and `withCredentials` possible, none of which
|
|
1894
|
+
* `EventSource` supports.
|
|
1895
|
+
*/
|
|
1896
|
+
function Sse(path, options = {}) {
|
|
1897
|
+
return (target) => {
|
|
1898
|
+
if (typeof target !== "function") throw new SnailDecoratorError(t("error.decorator.class.target", "Sse"));
|
|
1899
|
+
if (getOwnMetadata(SNAIL_WS_OPTIONS, target)) throw new SnailDecoratorError(t("error.decorator.stream.duplicate", target.name));
|
|
1900
|
+
mergeMetadata(SNAIL_SSE_OPTIONS, {
|
|
1901
|
+
...options,
|
|
1902
|
+
url: path
|
|
1903
|
+
}, target);
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
/** Register a handler for the SSE `open` event. */
|
|
1907
|
+
function OnSseOpen() {
|
|
1908
|
+
return (target, propertyKey) => {
|
|
1909
|
+
appendSseHandler(target, propertyKey, (handlers, fn) => handlers.open.push(fn));
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
/** Register a handler for the SSE `error` event. */
|
|
1913
|
+
function OnSseError() {
|
|
1914
|
+
return (target, propertyKey) => {
|
|
1915
|
+
appendSseHandler(target, propertyKey, (handlers, fn) => handlers.error.push(fn));
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Register a handler for a named SSE event.
|
|
1920
|
+
*
|
|
1921
|
+
* @param event event name; omit for the default `message` event
|
|
1922
|
+
*/
|
|
1923
|
+
function SseEvent(event = "message") {
|
|
1924
|
+
return (target, propertyKey) => {
|
|
1925
|
+
appendSseHandler(target, propertyKey, (handlers, fn) => {
|
|
1926
|
+
handlers.events.push({
|
|
1927
|
+
event,
|
|
1928
|
+
handler: fn
|
|
1929
|
+
});
|
|
1930
|
+
});
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
function appendSseHandler(target, propertyKey, push) {
|
|
1934
|
+
if (propertyKey === void 0) throw new SnailDecoratorError("[snail] SSE handler decorators must be used on methods");
|
|
1935
|
+
const fn = target[propertyKey];
|
|
1936
|
+
if (typeof fn !== "function") throw new SnailDecoratorError(`[snail] @SseEvent/@OnSseOpen/@OnSseError must decorate a method, got "${String(propertyKey)}"`);
|
|
1937
|
+
const handlers = getOwnMetadata(SNAIL_SSE_HANDLERS, target) ?? {
|
|
1938
|
+
open: [],
|
|
1939
|
+
error: [],
|
|
1940
|
+
events: []
|
|
1941
|
+
};
|
|
1942
|
+
push(handlers, fn);
|
|
1943
|
+
defineMetadata(SNAIL_SSE_HANDLERS, handlers, target);
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Declare a WebSocket endpoint.
|
|
1947
|
+
*
|
|
1948
|
+
* ```ts
|
|
1949
|
+
* @WebSocket("/ws")
|
|
1950
|
+
* class ChatSocket {
|
|
1951
|
+
* @OnWsOpen() connected() {}
|
|
1952
|
+
* @OnWsMessage() incoming(event: MessageEvent) {}
|
|
1953
|
+
* @OnWsClose() gone(event: CloseEvent) {}
|
|
1954
|
+
* @OnWsError() failed(event: Event) {}
|
|
1955
|
+
* }
|
|
1956
|
+
*
|
|
1957
|
+
* const chat = Service.createWebSocket(ChatSocket);
|
|
1958
|
+
* const socket = chat.open();
|
|
1959
|
+
* socket.send({ hello: "world" });
|
|
1960
|
+
* ```
|
|
1961
|
+
*/
|
|
1962
|
+
function WebSocket$1(path, options = {}) {
|
|
1963
|
+
return (target) => {
|
|
1964
|
+
if (typeof target !== "function") throw new SnailDecoratorError(t("error.decorator.class.target", "WebSocket"));
|
|
1965
|
+
if (getOwnMetadata(SNAIL_SSE_OPTIONS, target)) throw new SnailDecoratorError(t("error.decorator.stream.duplicate", target.name));
|
|
1966
|
+
mergeMetadata(SNAIL_WS_OPTIONS, {
|
|
1967
|
+
...options,
|
|
1968
|
+
url: path
|
|
1969
|
+
}, target);
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
/** Register a handler for the socket `open` event. */
|
|
1973
|
+
function OnWsOpen() {
|
|
1974
|
+
return wsHandlerDecorator("open");
|
|
1975
|
+
}
|
|
1976
|
+
/** Register a handler for incoming messages. */
|
|
1977
|
+
function OnWsMessage() {
|
|
1978
|
+
return wsHandlerDecorator("message");
|
|
1979
|
+
}
|
|
1980
|
+
/** Register a handler for the socket `close` event. */
|
|
1981
|
+
function OnWsClose() {
|
|
1982
|
+
return wsHandlerDecorator("close");
|
|
1983
|
+
}
|
|
1984
|
+
/** Register a handler for the socket `error` event. */
|
|
1985
|
+
function OnWsError() {
|
|
1986
|
+
return wsHandlerDecorator("error");
|
|
1987
|
+
}
|
|
1988
|
+
function wsHandlerDecorator(kind) {
|
|
1989
|
+
return (target, propertyKey) => {
|
|
1990
|
+
if (propertyKey === void 0) throw new SnailDecoratorError("[snail] WebSocket handler decorators must be used on methods");
|
|
1991
|
+
const fn = target[propertyKey];
|
|
1992
|
+
if (typeof fn !== "function") throw new SnailDecoratorError(`[snail] @OnWs* must decorate a method, got "${String(propertyKey)}"`);
|
|
1993
|
+
const handlers = getOwnMetadata(SNAIL_WS_HANDLERS, target) ?? {
|
|
1994
|
+
open: [],
|
|
1995
|
+
message: [],
|
|
1996
|
+
close: [],
|
|
1997
|
+
error: []
|
|
1998
|
+
};
|
|
1999
|
+
handlers[kind].push(fn);
|
|
2000
|
+
defineMetadata(SNAIL_WS_HANDLERS, handlers, target);
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
/** Short alias for {@link WebSocket}. */
|
|
2004
|
+
var Ws = WebSocket$1;
|
|
2005
|
+
/**
|
|
2006
|
+
* Declare a streaming HTTP endpoint.
|
|
2007
|
+
*
|
|
2008
|
+
* ```ts
|
|
2009
|
+
* @Api("/ai")
|
|
2010
|
+
* class AiApi {
|
|
2011
|
+
* @HttpStream("/chat", { method: "POST" })
|
|
2012
|
+
* chat(@Data() prompt: { text: string }) {}
|
|
2013
|
+
* }
|
|
2014
|
+
*
|
|
2015
|
+
* const stream = aiApi.chat({ text: "hi" });
|
|
2016
|
+
* for await (const chunk of stream.stream()) { render(chunk); }
|
|
2017
|
+
* ```
|
|
2018
|
+
*
|
|
2019
|
+
* Unlike `@Get`/`@Post`, the proxied method returns a stream controller instead
|
|
2020
|
+
* of a `SnailMethod`, so no envelope validation happens.
|
|
2021
|
+
*/
|
|
2022
|
+
function HttpStream(path = "", options = {}) {
|
|
2023
|
+
return (target, propertyKey) => {
|
|
2024
|
+
if (propertyKey === void 0) throw new SnailDecoratorError("[snail] @HttpStream must decorate a method");
|
|
2025
|
+
defineMetadata(SNAIL_HTTP_STREAM, {
|
|
2026
|
+
...options,
|
|
2027
|
+
url: path
|
|
2028
|
+
}, target, propertyKey);
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
//#endregion
|
|
2032
|
+
export { Api, DEFAULT_ACCEPTED_CODES, DEFAULT_API_OPTIONS, DEFAULT_RESPONSE_KEYS, DEFAULT_SERVER_OPTIONS, Data, Delete, DownloadProgress, Emitter, Get, Head, Header, HeaderParam, HeaderValue, HttpStream, LOG_LEVEL_WEIGHT, Localization, OnSseError, OnSseOpen, OnWsClose, OnWsError, OnWsMessage, OnWsOpen, Options, Params, Patch, PluginManager, Post, Put, Query, Request, SNAIL_API_OPTIONS, SNAIL_CUSTOM_KEY_PREFIX, SNAIL_DOWNLOAD_PROGRESS, SNAIL_HEADERS, SNAIL_HTTP_STREAM, SNAIL_PARAMS, SNAIL_REQUEST_METHOD, SNAIL_SERVER_OPTIONS, SNAIL_SSE_HANDLERS, SNAIL_SSE_OPTIONS, SNAIL_UPLOAD_PROGRESS, SNAIL_WS_HANDLERS, SNAIL_WS_OPTIONS, Server, SnailCancelledError, SnailContext, SnailDecoratorError, SnailError, SnailHookError, SnailHttpError, SnailMethod, SnailOptionsError, SnailPluginError, SnailRequestError, SnailResponseError, SnailServer, SnailTimeoutError, Sse, SseEvent, StateBag, UploadProgress, WebSocket$1 as WebSocket, Ws, appendMetadata, buildRequestURL, capitalize, clearMetadataRegistry, collectMethodKeys, composeChain, createClassDecorator, createLogger, createMethodDecorator, createParamDecorator, createParamDecoratorFor, createPlugin, createPropertyDecorator, customMetadataKey, deepMerge, deferred, defineMetadata, defineParamDescriptor, definePlugin, deleteMetadata, en, filenameFromDisposition, getClassMetadata, getLocale, getMetadata, getMethodMetadata, getOwnMetadata, getOwnMethodMetadata, hasMetadata, hasParamResolver, isAbsoluteURL, isBinaryBody, isBrowser, isDefined, isFunction, isObject, isPlainObject, isPromise, joinURL, languages, localization, mergeMetadata, normalizeParamInput, omit, omitUndefined, paramResolvers, paramSources, pathParamNames, pick, registerMessages, registerParamResolver, replacePathParams, resolveOwner, setLocale, shortHash, stableStringify, stripQuery, t, triggerBlobDownload, triggerDownload, tryCatch, zh };
|
|
2033
|
+
|
|
2034
|
+
//# sourceMappingURL=index.js.map
|