@snail-js/api 0.1.27 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +125 -605
- package/dist/adapter/plain.d.ts +12 -0
- package/dist/adapter/plain.d.ts.map +1 -0
- package/dist/adapter/react.d.ts +14 -0
- package/dist/adapter/react.d.ts.map +1 -0
- package/dist/adapter/registry.d.ts +16 -0
- package/dist/adapter/registry.d.ts.map +1 -0
- package/dist/adapter/vue.d.ts +15 -0
- package/dist/adapter/vue.d.ts.map +1 -0
- package/dist/chunks/custom-UchOSg_s.js +466 -0
- package/dist/chunks/custom-UchOSg_s.js.map +1 -0
- package/dist/chunks/download-lBC7oOTT.js +160 -0
- package/dist/chunks/download-lBC7oOTT.js.map +1 -0
- package/dist/chunks/object-Ct0w-h4i.js +74 -0
- package/dist/chunks/object-Ct0w-h4i.js.map +1 -0
- package/dist/chunks/plugin-D3RDffDG.js +742 -0
- package/dist/chunks/plugin-D3RDffDG.js.map +1 -0
- package/dist/chunks/react-C1em-gxy.js +62 -0
- package/dist/chunks/react-C1em-gxy.js.map +1 -0
- package/dist/chunks/request-qeRhihEF.js +40 -0
- package/dist/chunks/request-qeRhihEF.js.map +1 -0
- package/dist/chunks/response-D-ubkAFl.js +127 -0
- package/dist/chunks/response-D-ubkAFl.js.map +1 -0
- package/dist/chunks/use-download-FAjtdJ58.js +1816 -0
- package/dist/chunks/use-download-FAjtdJ58.js.map +1 -0
- package/dist/chunks/vue-Dd9EhkQ3.js +34 -0
- package/dist/chunks/vue-Dd9EhkQ3.js.map +1 -0
- package/dist/core/args.d.ts +54 -0
- package/dist/core/args.d.ts.map +1 -0
- package/dist/core/connection.d.ts +15 -0
- package/dist/core/connection.d.ts.map +1 -0
- package/dist/core/context.d.ts +124 -0
- package/dist/core/context.d.ts.map +1 -0
- package/dist/core/http-stream.d.ts +28 -0
- package/dist/core/http-stream.d.ts.map +1 -0
- package/dist/core/logger.d.ts +22 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/metadata.d.ts +92 -0
- package/dist/core/metadata.d.ts.map +1 -0
- package/dist/core/metadata.keys.d.ts +41 -0
- package/dist/core/metadata.keys.d.ts.map +1 -0
- package/dist/core/method.d.ts +177 -0
- package/dist/core/method.d.ts.map +1 -0
- package/dist/core/plugin-manager.d.ts +106 -0
- package/dist/core/plugin-manager.d.ts.map +1 -0
- package/dist/core/plugin.d.ts +113 -0
- package/dist/core/plugin.d.ts.map +1 -0
- package/dist/core/reconnect.d.ts +22 -0
- package/dist/core/reconnect.d.ts.map +1 -0
- package/dist/core/resolve-stream.d.ts +45 -0
- package/dist/core/resolve-stream.d.ts.map +1 -0
- package/dist/core/resolve.d.ts +50 -0
- package/dist/core/resolve.d.ts.map +1 -0
- package/dist/core/response.d.ts +60 -0
- package/dist/core/response.d.ts.map +1 -0
- package/dist/core/server.d.ts +132 -0
- package/dist/core/server.d.ts.map +1 -0
- package/dist/core/sse.d.ts +27 -0
- package/dist/core/sse.d.ts.map +1 -0
- package/dist/core/state-bag.d.ts +36 -0
- package/dist/core/state-bag.d.ts.map +1 -0
- package/dist/core/websocket.d.ts +29 -0
- package/dist/core/websocket.d.ts.map +1 -0
- package/dist/decorators/api.d.ts +24 -4
- package/dist/decorators/api.d.ts.map +1 -0
- package/dist/decorators/args.d.ts +77 -14
- package/dist/decorators/args.d.ts.map +1 -0
- package/dist/decorators/custom.d.ts +81 -0
- package/dist/decorators/custom.d.ts.map +1 -0
- package/dist/decorators/header.d.ts +22 -0
- package/dist/decorators/header.d.ts.map +1 -0
- package/dist/decorators/index.d.ts +17 -0
- package/dist/decorators/index.d.ts.map +1 -0
- package/dist/decorators/methods.d.ts +33 -0
- package/dist/decorators/methods.d.ts.map +1 -0
- package/dist/decorators/progress.d.ts +24 -6
- package/dist/decorators/progress.d.ts.map +1 -0
- package/dist/decorators/server.d.ts +28 -7
- package/dist/decorators/server.d.ts.map +1 -0
- package/dist/decorators/stream.d.ts +113 -0
- package/dist/decorators/stream.d.ts.map +1 -0
- package/dist/default/options.d.ts +19 -0
- package/dist/default/options.d.ts.map +1 -0
- package/dist/error/base.d.ts +20 -0
- package/dist/error/base.d.ts.map +1 -0
- package/dist/error/decorator.d.ts +14 -0
- package/dist/error/decorator.d.ts.map +1 -0
- package/dist/error/hook.d.ts +14 -0
- package/dist/error/hook.d.ts.map +1 -0
- package/dist/error/index.d.ts +8 -0
- package/dist/error/index.d.ts.map +1 -0
- package/dist/error/options.d.ts +12 -0
- package/dist/error/options.d.ts.map +1 -0
- package/dist/error/plugin.d.ts +11 -0
- package/dist/error/plugin.d.ts.map +1 -0
- package/dist/error/request.d.ts +29 -0
- package/dist/error/request.d.ts.map +1 -0
- package/dist/error/response.d.ts +36 -0
- package/dist/error/response.d.ts.map +1 -0
- package/dist/index.d.ts +56 -11
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2034 -0
- package/dist/index.js.map +1 -0
- package/dist/locale/en.d.ts +5 -0
- package/dist/locale/en.d.ts.map +1 -0
- package/dist/locale/index.d.ts +57 -0
- package/dist/locale/index.d.ts.map +1 -0
- package/dist/locale/types.d.ts +11 -0
- package/dist/locale/types.d.ts.map +1 -0
- package/dist/locale/zh.d.ts +5 -0
- package/dist/locale/zh.d.ts.map +1 -0
- package/dist/plugins/cache/adapters/indexeddb.d.ts +52 -0
- package/dist/plugins/cache/adapters/indexeddb.d.ts.map +1 -0
- package/dist/plugins/cache/adapters/memory.d.ts +60 -0
- package/dist/plugins/cache/adapters/memory.d.ts.map +1 -0
- package/dist/plugins/cache/adapters/web-storage.d.ts +43 -0
- package/dist/plugins/cache/adapters/web-storage.d.ts.map +1 -0
- package/dist/plugins/cache/decorators.d.ts +62 -0
- package/dist/plugins/cache/decorators.d.ts.map +1 -0
- package/dist/plugins/cache/index.d.ts +41 -0
- package/dist/plugins/cache/index.d.ts.map +1 -0
- package/dist/plugins/cache/key.d.ts +48 -0
- package/dist/plugins/cache/key.d.ts.map +1 -0
- package/dist/plugins/cache/manager.d.ts +124 -0
- package/dist/plugins/cache/manager.d.ts.map +1 -0
- package/dist/plugins/cache/plugin.d.ts +64 -0
- package/dist/plugins/cache/plugin.d.ts.map +1 -0
- package/dist/plugins/cache/type.d.ts +116 -0
- package/dist/plugins/cache/type.d.ts.map +1 -0
- package/dist/plugins/index.d.ts +75 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +2265 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/interceptor/decorators.d.ts +44 -0
- package/dist/plugins/interceptor/decorators.d.ts.map +1 -0
- package/dist/plugins/interceptor/index.d.ts +31 -0
- package/dist/plugins/interceptor/index.d.ts.map +1 -0
- package/dist/plugins/interceptor/manager.d.ts +27 -0
- package/dist/plugins/interceptor/manager.d.ts.map +1 -0
- package/dist/plugins/interceptor/plugin.d.ts +65 -0
- package/dist/plugins/interceptor/plugin.d.ts.map +1 -0
- package/dist/plugins/interceptor/type.d.ts +45 -0
- package/dist/plugins/interceptor/type.d.ts.map +1 -0
- package/dist/plugins/pool/index.d.ts +26 -0
- package/dist/plugins/pool/index.d.ts.map +1 -0
- package/dist/plugins/pool/plugin.d.ts +83 -0
- package/dist/plugins/pool/plugin.d.ts.map +1 -0
- package/dist/plugins/pool/scheduler.d.ts +153 -0
- package/dist/plugins/pool/scheduler.d.ts.map +1 -0
- package/dist/plugins/pool/type.d.ts +38 -0
- package/dist/plugins/pool/type.d.ts.map +1 -0
- package/dist/plugins/react/index.d.ts +20 -0
- package/dist/plugins/react/index.d.ts.map +1 -0
- package/dist/plugins/react/index.js +145 -0
- package/dist/plugins/react/index.js.map +1 -0
- package/dist/plugins/react/plugin.d.ts +33 -0
- package/dist/plugins/react/plugin.d.ts.map +1 -0
- package/dist/plugins/react/type.d.ts +32 -0
- package/dist/plugins/react/type.d.ts.map +1 -0
- package/dist/plugins/transform/decorators.d.ts +54 -0
- package/dist/plugins/transform/decorators.d.ts.map +1 -0
- package/dist/plugins/transform/hydrate.d.ts +47 -0
- package/dist/plugins/transform/hydrate.d.ts.map +1 -0
- package/dist/plugins/transform/index.d.ts +41 -0
- package/dist/plugins/transform/index.d.ts.map +1 -0
- package/dist/plugins/transform/plugin.d.ts +20 -0
- package/dist/plugins/transform/plugin.d.ts.map +1 -0
- package/dist/plugins/transform/type.d.ts +67 -0
- package/dist/plugins/transform/type.d.ts.map +1 -0
- package/dist/plugins/validate/decorators.d.ts +24 -0
- package/dist/plugins/validate/decorators.d.ts.map +1 -0
- package/dist/plugins/validate/index.d.ts +40 -0
- package/dist/plugins/validate/index.d.ts.map +1 -0
- package/dist/plugins/validate/plugin.d.ts +13 -0
- package/dist/plugins/validate/plugin.d.ts.map +1 -0
- package/dist/plugins/validate/type.d.ts +54 -0
- package/dist/plugins/validate/type.d.ts.map +1 -0
- package/dist/plugins/version/decorators.d.ts +22 -0
- package/dist/plugins/version/decorators.d.ts.map +1 -0
- package/dist/plugins/version/index.d.ts +23 -0
- package/dist/plugins/version/index.d.ts.map +1 -0
- package/dist/plugins/version/plugin.d.ts +13 -0
- package/dist/plugins/version/plugin.d.ts.map +1 -0
- package/dist/plugins/version/type.d.ts +47 -0
- package/dist/plugins/version/type.d.ts.map +1 -0
- package/dist/plugins/vue/index.d.ts +21 -0
- package/dist/plugins/vue/index.d.ts.map +1 -0
- package/dist/plugins/vue/index.js +114 -0
- package/dist/plugins/vue/index.js.map +1 -0
- package/dist/plugins/vue/plugin.d.ts +15 -0
- package/dist/plugins/vue/plugin.d.ts.map +1 -0
- package/dist/plugins/vue/type.d.ts +34 -0
- package/dist/plugins/vue/type.d.ts.map +1 -0
- package/dist/strategies/index.d.ts +2 -0
- package/dist/strategies/index.d.ts.map +1 -0
- package/dist/strategies/index.js +29 -0
- package/dist/strategies/index.js.map +1 -0
- package/dist/strategies/plain.d.ts +2 -0
- package/dist/strategies/plain.d.ts.map +1 -0
- package/dist/strategies/plain.js +23 -0
- package/dist/strategies/plain.js.map +1 -0
- package/dist/strategies/react.d.ts +2 -0
- package/dist/strategies/react.d.ts.map +1 -0
- package/dist/strategies/react.js +30 -0
- package/dist/strategies/react.js.map +1 -0
- package/dist/strategies/shared/adapter.d.ts +23 -0
- package/dist/strategies/shared/adapter.d.ts.map +1 -0
- package/dist/strategies/shared/dom.d.ts +53 -0
- package/dist/strategies/shared/dom.d.ts.map +1 -0
- package/dist/strategies/shared/error.d.ts +30 -0
- package/dist/strategies/shared/error.d.ts.map +1 -0
- package/dist/strategies/shared/method.d.ts +51 -0
- package/dist/strategies/shared/method.d.ts.map +1 -0
- package/dist/strategies/shared/public.d.ts +33 -0
- package/dist/strategies/shared/public.d.ts.map +1 -0
- package/dist/strategies/shared/queue.d.ts +30 -0
- package/dist/strategies/shared/queue.d.ts.map +1 -0
- package/dist/strategies/shared/retry.d.ts +51 -0
- package/dist/strategies/shared/retry.d.ts.map +1 -0
- package/dist/strategies/shared/state.d.ts +117 -0
- package/dist/strategies/shared/state.d.ts.map +1 -0
- package/dist/strategies/shared/timing.d.ts +53 -0
- package/dist/strategies/shared/timing.d.ts.map +1 -0
- package/dist/strategies/shared/watcher.d.ts +37 -0
- package/dist/strategies/shared/watcher.d.ts.map +1 -0
- package/dist/strategies/use-auto-request.d.ts +77 -0
- package/dist/strategies/use-auto-request.d.ts.map +1 -0
- package/dist/strategies/use-download.d.ts +103 -0
- package/dist/strategies/use-download.d.ts.map +1 -0
- package/dist/strategies/use-fetcher.d.ts +52 -0
- package/dist/strategies/use-fetcher.d.ts.map +1 -0
- package/dist/strategies/use-pagination.d.ts +101 -0
- package/dist/strategies/use-pagination.d.ts.map +1 -0
- package/dist/strategies/use-request.d.ts +50 -0
- package/dist/strategies/use-request.d.ts.map +1 -0
- package/dist/strategies/use-retriable-request.d.ts +46 -0
- package/dist/strategies/use-retriable-request.d.ts.map +1 -0
- package/dist/strategies/use-sse.d.ts +103 -0
- package/dist/strategies/use-sse.d.ts.map +1 -0
- package/dist/strategies/use-token-auth.d.ts +88 -0
- package/dist/strategies/use-token-auth.d.ts.map +1 -0
- package/dist/strategies/use-uploader.d.ts +88 -0
- package/dist/strategies/use-uploader.d.ts.map +1 -0
- package/dist/strategies/use-watcher.d.ts +69 -0
- package/dist/strategies/use-watcher.d.ts.map +1 -0
- package/dist/typings/adapter.d.ts +61 -0
- package/dist/typings/adapter.d.ts.map +1 -0
- package/dist/typings/api.d.ts +122 -0
- package/dist/typings/api.d.ts.map +1 -0
- package/dist/typings/args.d.ts +51 -0
- package/dist/typings/args.d.ts.map +1 -0
- package/dist/typings/index.d.ts +8 -12
- package/dist/typings/index.d.ts.map +1 -0
- package/dist/typings/meta.d.ts +44 -0
- package/dist/typings/meta.d.ts.map +1 -0
- package/dist/typings/plugin.d.ts +114 -0
- package/dist/typings/plugin.d.ts.map +1 -0
- package/dist/typings/response.d.ts +118 -0
- package/dist/typings/response.d.ts.map +1 -0
- package/dist/typings/server.d.ts +83 -0
- package/dist/typings/server.d.ts.map +1 -0
- package/dist/typings/stream.d.ts +150 -0
- package/dist/typings/stream.d.ts.map +1 -0
- package/dist/utils/download.d.ts +97 -0
- package/dist/utils/download.d.ts.map +1 -0
- package/dist/utils/emitter.d.ts +36 -0
- package/dist/utils/emitter.d.ts.map +1 -0
- package/dist/utils/index.d.ts +6 -1
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/is.d.ts +22 -0
- package/dist/utils/is.d.ts.map +1 -0
- package/dist/utils/object.d.ts +44 -0
- package/dist/utils/object.d.ts.map +1 -0
- package/dist/utils/url.d.ts +44 -0
- package/dist/utils/url.d.ts.map +1 -0
- package/package.json +90 -27
- package/README_EN.md +0 -607
- package/dist/cache/index.d.ts +0 -9
- package/dist/cache/indexDBCache.d.ts +0 -13
- package/dist/cache/localstorageCache.d.ts +0 -10
- package/dist/cache/memoryCache.d.ts +0 -11
- package/dist/core/index.d.ts +0 -4
- package/dist/core/snailApi.d.ts +0 -22
- package/dist/core/snailMethod.d.ts +0 -49
- package/dist/core/snailServer.d.ts +0 -35
- package/dist/core/snailSse.d.ts +0 -20
- package/dist/decorators/cache.d.ts +0 -15
- package/dist/decorators/method.d.ts +0 -38
- package/dist/decorators/sse.d.ts +0 -30
- package/dist/decorators/strategy.d.ts +0 -10
- package/dist/decorators/versioning.d.ts +0 -16
- package/dist/eventEmmit/index.d.ts +0 -1
- package/dist/eventEmmit/snail.event.d.ts +0 -10
- package/dist/snail-api.js +0 -4868
- package/dist/snail-api.umd.cjs +0 -4872
- package/dist/strategies/jwtStrategy.d.ts +0 -4
- package/dist/typings/api.option.d.ts +0 -10
- package/dist/typings/apiProxy.d.ts +0 -8
- package/dist/typings/cache.management.option.d.ts +0 -25
- package/dist/typings/cache.type.d.ts +0 -23
- package/dist/typings/content.type.d.ts +0 -5
- package/dist/typings/request.body.d.ts +0 -3
- package/dist/typings/response.data.d.ts +0 -13
- package/dist/typings/snail.event.d.ts +0 -6
- package/dist/typings/snail.method.d.ts +0 -14
- package/dist/typings/snail.option.d.ts +0 -16
- package/dist/typings/sse.d.ts +0 -15
- package/dist/typings/strategy.d.ts +0 -5
- package/dist/typings/versioning.option.d.ts +0 -30
- package/dist/utils/function.d.ts +0 -31
- package/dist/versioning/index.d.ts +0 -1
- package/dist/versioning/versioning.d.ts +0 -12
|
@@ -0,0 +1,1816 @@
|
|
|
1
|
+
import { a as SnailHttpError, n as createPlugin, o as SnailResponseError } from "./plugin-D3RDffDG.js";
|
|
2
|
+
import { t as SnailCancelledError } from "./request-qeRhihEF.js";
|
|
3
|
+
import { n as deferred, r as noop } from "./object-Ct0w-h4i.js";
|
|
4
|
+
import { i as Emitter, r as triggerDownload } from "./download-lBC7oOTT.js";
|
|
5
|
+
import { isCancel } from "axios";
|
|
6
|
+
//#region src/adapter/plain.ts
|
|
7
|
+
/**
|
|
8
|
+
* Framework-free state adapter.
|
|
9
|
+
*
|
|
10
|
+
* A plain mutable box. Values update correctly, they just do not *trigger*
|
|
11
|
+
* anything — which is exactly right for a test, an SSR pass or a script, and is
|
|
12
|
+
* the safe default when no UI framework is present.
|
|
13
|
+
*
|
|
14
|
+
* A framework adapter registered with `setStateAdapter` replaces it.
|
|
15
|
+
*/
|
|
16
|
+
var plainStateAdapter = {
|
|
17
|
+
name: "plain",
|
|
18
|
+
create(initial) {
|
|
19
|
+
return { value: initial };
|
|
20
|
+
},
|
|
21
|
+
read(ref) {
|
|
22
|
+
return ref.value;
|
|
23
|
+
},
|
|
24
|
+
write(ref, value) {
|
|
25
|
+
ref.value = value;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/adapter/registry.ts
|
|
30
|
+
/**
|
|
31
|
+
* The active state adapter.
|
|
32
|
+
*
|
|
33
|
+
* Strategies never touch `ref()` or `useState()` directly — they ask this
|
|
34
|
+
* registry. That is what lets one `useRequest` implementation serve Vue, React
|
|
35
|
+
* and framework-free code without the core importing any of them.
|
|
36
|
+
*/
|
|
37
|
+
var active = plainStateAdapter;
|
|
38
|
+
/** Replace the active adapter. Called once by a framework entry point. */
|
|
39
|
+
function setStateAdapter(adapter) {
|
|
40
|
+
if (!adapter || typeof adapter.create !== "function") throw new TypeError("[snail] setStateAdapter() expects an object with a create() method");
|
|
41
|
+
active = adapter;
|
|
42
|
+
}
|
|
43
|
+
/** The active adapter. */
|
|
44
|
+
function getStateAdapter() {
|
|
45
|
+
return active;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/strategies/shared/adapter.ts
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the state adapter one strategy instance should use.
|
|
51
|
+
*
|
|
52
|
+
* The adapter is resolved **once per hook** rather than per read. A hook that
|
|
53
|
+
* asked the registry on every write could observe two different adapters if an
|
|
54
|
+
* application called `setStateAdapter()` between two requests, and would then mix
|
|
55
|
+
* Vue refs with plain boxes in one set of handles.
|
|
56
|
+
*/
|
|
57
|
+
function resolveStateAdapter(options = {}) {
|
|
58
|
+
return options.adapter ?? getStateAdapter();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read a handle for the current render.
|
|
62
|
+
*
|
|
63
|
+
* `useBind` exists for frameworks that only re-render on an explicit
|
|
64
|
+
* subscription (React's `useSyncExternalStore`); Vue tracks the `.value` access
|
|
65
|
+
* itself, so its adapter omits the method and the read is the whole story. The
|
|
66
|
+
* `??` fallback — rather than a truthiness check — keeps a legitimate `false`,
|
|
67
|
+
* `0` or `""` from being replaced by a second read.
|
|
68
|
+
*/
|
|
69
|
+
function bindRef(adapter, ref) {
|
|
70
|
+
return adapter.useBind?.(ref) ?? adapter.read(ref);
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/strategies/shared/error.ts
|
|
74
|
+
/**
|
|
75
|
+
* `true` when an error means "this request was deliberately stopped".
|
|
76
|
+
*
|
|
77
|
+
* Cancellation is **expected control flow**, not a failure: `method.abort()` and
|
|
78
|
+
* a strategy discarding a stale response both produce it. Every strategy must ask
|
|
79
|
+
* this question before writing `error` state or firing `onError`, otherwise a
|
|
80
|
+
* user aborting a request would see a spurious error toast.
|
|
81
|
+
*
|
|
82
|
+
* The duck-typed `code` check is what catches an `AbortError` produced by a
|
|
83
|
+
* caller-supplied `AbortSignal`, which axios does not always wrap in its own
|
|
84
|
+
* cancel class.
|
|
85
|
+
*/
|
|
86
|
+
function isCancellation(error) {
|
|
87
|
+
if (error instanceof SnailCancelledError) return true;
|
|
88
|
+
if (isCancel(error)) return true;
|
|
89
|
+
const code = error?.code;
|
|
90
|
+
return code === "ERR_CANCELED" || code === "ABORT_ERR";
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Best-effort business/HTTP code of a failure, for the `code` state handle.
|
|
94
|
+
*
|
|
95
|
+
* Reading it from the error rather than from a response keeps `code` meaningful
|
|
96
|
+
* on the failure path too — a `401` should be visible to the UI even though no
|
|
97
|
+
* envelope ever passed validation.
|
|
98
|
+
*/
|
|
99
|
+
function readErrorCode(error) {
|
|
100
|
+
if (error instanceof SnailResponseError) return error.businessCode;
|
|
101
|
+
if (error instanceof SnailHttpError) return error.status;
|
|
102
|
+
const candidate = error;
|
|
103
|
+
const value = candidate?.response?.status ?? candidate?.status;
|
|
104
|
+
return typeof value === "number" || typeof value === "string" ? value : void 0;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Best-effort human readable message of a failure, for the `message` handle.
|
|
108
|
+
*
|
|
109
|
+
* The backend envelope is preferred over the `Error.message` because an axios
|
|
110
|
+
* transport message ("Request failed with status code 401") is useless to show a
|
|
111
|
+
* user while `{ message: "token expired" }` is not.
|
|
112
|
+
*/
|
|
113
|
+
function readErrorMessage(error) {
|
|
114
|
+
if (error instanceof SnailResponseError) {
|
|
115
|
+
const payload = error.payload;
|
|
116
|
+
if (payload && typeof payload.message === "string") return payload.message;
|
|
117
|
+
}
|
|
118
|
+
const message = error?.message;
|
|
119
|
+
return typeof message === "string" ? message : void 0;
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/strategies/shared/state.ts
|
|
123
|
+
/** `true` when `key` was explicitly provided, even with an `undefined` value. */
|
|
124
|
+
function hasKey(source, key) {
|
|
125
|
+
return Object.prototype.hasOwnProperty.call(source, key);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Build the state handles, the listeners and the writers for one hook.
|
|
129
|
+
*
|
|
130
|
+
* All five refs are created here, even for a hook that will not use them: a
|
|
131
|
+
* `useFetcher({ withState: false })` still has to answer `abort()` and
|
|
132
|
+
* `onFinish()`, and creating state lazily would hand the UI handles that appear
|
|
133
|
+
* only after the first request.
|
|
134
|
+
*/
|
|
135
|
+
function createStrategyState(options = {}) {
|
|
136
|
+
const adapter = resolveStateAdapter(options);
|
|
137
|
+
const loading = adapter.create(false);
|
|
138
|
+
const data = adapter.create(options.initialData);
|
|
139
|
+
const error = adapter.create(void 0);
|
|
140
|
+
const code = adapter.create(void 0);
|
|
141
|
+
const message = adapter.create(void 0);
|
|
142
|
+
const events = new Emitter();
|
|
143
|
+
return {
|
|
144
|
+
adapter,
|
|
145
|
+
state: {
|
|
146
|
+
loading,
|
|
147
|
+
data,
|
|
148
|
+
error,
|
|
149
|
+
code,
|
|
150
|
+
message,
|
|
151
|
+
abort() {
|
|
152
|
+
options.onAbort?.();
|
|
153
|
+
},
|
|
154
|
+
update(patch) {
|
|
155
|
+
if (hasKey(patch, "data")) adapter.write(data, patch.data);
|
|
156
|
+
if (hasKey(patch, "loading")) adapter.write(loading, patch.loading);
|
|
157
|
+
if (hasKey(patch, "error")) adapter.write(error, patch.error);
|
|
158
|
+
if (hasKey(patch, "code")) adapter.write(code, patch.code);
|
|
159
|
+
if (hasKey(patch, "message")) adapter.write(message, patch.message);
|
|
160
|
+
},
|
|
161
|
+
bind() {
|
|
162
|
+
return {
|
|
163
|
+
loading: bindRef(adapter, loading),
|
|
164
|
+
data: bindRef(adapter, data),
|
|
165
|
+
error: bindRef(adapter, error),
|
|
166
|
+
code: bindRef(adapter, code),
|
|
167
|
+
message: bindRef(adapter, message)
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
onSuccess(callback) {
|
|
171
|
+
return events.on("success", callback);
|
|
172
|
+
},
|
|
173
|
+
onError(callback) {
|
|
174
|
+
return events.on("error", callback);
|
|
175
|
+
},
|
|
176
|
+
onFinish(callback) {
|
|
177
|
+
return events.on("finish", callback);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
setLoading(value) {
|
|
181
|
+
adapter.write(loading, value);
|
|
182
|
+
},
|
|
183
|
+
setData(value) {
|
|
184
|
+
adapter.write(data, value);
|
|
185
|
+
},
|
|
186
|
+
setError(value) {
|
|
187
|
+
adapter.write(error, value);
|
|
188
|
+
},
|
|
189
|
+
setCode(value) {
|
|
190
|
+
adapter.write(code, value);
|
|
191
|
+
},
|
|
192
|
+
setMessage(value) {
|
|
193
|
+
adapter.write(message, value);
|
|
194
|
+
},
|
|
195
|
+
resetForSend() {
|
|
196
|
+
adapter.write(error, void 0);
|
|
197
|
+
adapter.write(code, void 0);
|
|
198
|
+
adapter.write(message, void 0);
|
|
199
|
+
},
|
|
200
|
+
applySuccess(result) {
|
|
201
|
+
adapter.write(data, result.data);
|
|
202
|
+
adapter.write(code, result.code);
|
|
203
|
+
adapter.write(message, result.message);
|
|
204
|
+
adapter.write(error, void 0);
|
|
205
|
+
return result.data;
|
|
206
|
+
},
|
|
207
|
+
applyFailure(failure) {
|
|
208
|
+
adapter.write(error, failure);
|
|
209
|
+
adapter.write(code, readErrorCode(failure));
|
|
210
|
+
adapter.write(message, readErrorMessage(failure));
|
|
211
|
+
},
|
|
212
|
+
emitSuccess(value) {
|
|
213
|
+
events.emit("success", value);
|
|
214
|
+
},
|
|
215
|
+
emitError(failure) {
|
|
216
|
+
events.emit("error", failure);
|
|
217
|
+
},
|
|
218
|
+
emitFinish() {
|
|
219
|
+
events.emit("finish", void 0);
|
|
220
|
+
},
|
|
221
|
+
dispose() {
|
|
222
|
+
events.clear();
|
|
223
|
+
adapter.dispose?.(loading);
|
|
224
|
+
adapter.dispose?.(data);
|
|
225
|
+
adapter.dispose?.(error);
|
|
226
|
+
adapter.dispose?.(code);
|
|
227
|
+
adapter.dispose?.(message);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/strategies/shared/method.ts
|
|
233
|
+
/** Create a {@link MethodHolder} around a proxied api method. */
|
|
234
|
+
function createMethodHolder(method) {
|
|
235
|
+
let instance;
|
|
236
|
+
return {
|
|
237
|
+
get instance() {
|
|
238
|
+
return instance;
|
|
239
|
+
},
|
|
240
|
+
get pending() {
|
|
241
|
+
return instance?.pending ?? false;
|
|
242
|
+
},
|
|
243
|
+
resolve(args) {
|
|
244
|
+
instance ??= method(...args);
|
|
245
|
+
return instance;
|
|
246
|
+
},
|
|
247
|
+
abort() {
|
|
248
|
+
instance?.abort();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Attach a per-request upload progress callback.
|
|
254
|
+
*
|
|
255
|
+
* The core resolves `onUploadProgress` from `@UploadProgress(...)` metadata when
|
|
256
|
+
* the `SnailMethod` is built, which is once per method — not once per file. To
|
|
257
|
+
* vary it per request this writes onto the **live** axios config instead.
|
|
258
|
+
*
|
|
259
|
+
* The timing is load-bearing: `send()` calls `begin()` synchronously before its
|
|
260
|
+
* first `await`, so the config read here is the one axios will send. Calling this
|
|
261
|
+
* before `send()` would be silently overwritten by `begin()`, and calling it
|
|
262
|
+
* after an `await` would be too late.
|
|
263
|
+
*
|
|
264
|
+
* @returns `false` when there was no live config to attach to.
|
|
265
|
+
*/
|
|
266
|
+
function attachUploadProgress(snail, listener) {
|
|
267
|
+
const config = snail.context?.request;
|
|
268
|
+
if (!config) return false;
|
|
269
|
+
const previous = config.onUploadProgress;
|
|
270
|
+
config.onUploadProgress = (event) => {
|
|
271
|
+
if (typeof previous === "function") previous(event);
|
|
272
|
+
listener(event);
|
|
273
|
+
};
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/strategies/use-request.ts
|
|
278
|
+
/**
|
|
279
|
+
* Drive one api method from component state.
|
|
280
|
+
*
|
|
281
|
+
* ```ts
|
|
282
|
+
* const user = useRequest(userApi.getUser);
|
|
283
|
+
* await user.send("1"); // → payload
|
|
284
|
+
* user.data.value; // → the same payload
|
|
285
|
+
* ```
|
|
286
|
+
*
|
|
287
|
+
* The hook owns exactly one `SnailMethod`, built from the arguments of the first
|
|
288
|
+
* send and reused afterwards. That is what keeps the reactive handles stable
|
|
289
|
+
* across re-sends — see `shared/method.ts` for why a second instance would break
|
|
290
|
+
* the UI — while `send("2")` still overrides the arguments per call.
|
|
291
|
+
*
|
|
292
|
+
* One instance also means one request at a time: `SnailMethod` resets its context
|
|
293
|
+
* at the start of every `send()`, so a second `send()` issued while the first is
|
|
294
|
+
* still in flight would leave the first reading a context that belongs to the
|
|
295
|
+
* second. Call `abort()` first, or use `useWatcher`/`useAutoRequest`, which
|
|
296
|
+
* collapse bursts for exactly this reason.
|
|
297
|
+
*/
|
|
298
|
+
function useRequest(method, options = {}) {
|
|
299
|
+
const holder = createMethodHolder(method);
|
|
300
|
+
const controller = createStrategyState({
|
|
301
|
+
adapter: options.adapter,
|
|
302
|
+
initialData: options.initialData,
|
|
303
|
+
onAbort: () => holder.abort()
|
|
304
|
+
});
|
|
305
|
+
const { state } = controller;
|
|
306
|
+
if (options.onSuccess) state.onSuccess(options.onSuccess);
|
|
307
|
+
if (options.onError) state.onError(options.onError);
|
|
308
|
+
if (options.onFinish) state.onFinish(options.onFinish);
|
|
309
|
+
async function send(...args) {
|
|
310
|
+
const snail = holder.resolve(args);
|
|
311
|
+
controller.resetForSend();
|
|
312
|
+
if (options.resetOnSend) controller.setData(options.initialData);
|
|
313
|
+
controller.setLoading(true);
|
|
314
|
+
try {
|
|
315
|
+
const result = await snail.send(...args);
|
|
316
|
+
const payload = controller.applySuccess(result);
|
|
317
|
+
controller.emitSuccess(payload);
|
|
318
|
+
return payload;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (isCancellation(error)) throw error;
|
|
321
|
+
controller.applyFailure(error);
|
|
322
|
+
controller.emitError(error);
|
|
323
|
+
throw error;
|
|
324
|
+
} finally {
|
|
325
|
+
controller.setLoading(false);
|
|
326
|
+
controller.emitFinish();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const result = {
|
|
330
|
+
...state,
|
|
331
|
+
send
|
|
332
|
+
};
|
|
333
|
+
if (options.immediate) send(...[]).catch(noop);
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/strategies/shared/timing.ts
|
|
338
|
+
/**
|
|
339
|
+
* Sleep for `ms`, rejecting with a {@link SnailCancelledError} the moment the
|
|
340
|
+
* signal aborts.
|
|
341
|
+
*
|
|
342
|
+
* A plain `setTimeout` promise is the wrong primitive for a backoff: `abort()`
|
|
343
|
+
* during a 30 second retry delay would leave the loop asleep and the caller's
|
|
344
|
+
* promise pending long after it cancelled. The listener is `{ once: true }` and
|
|
345
|
+
* removed on either outcome so a long-lived signal cannot accumulate handlers.
|
|
346
|
+
*/
|
|
347
|
+
function cancellableDelay(ms, signal) {
|
|
348
|
+
return new Promise((resolve, reject) => {
|
|
349
|
+
if (signal?.aborted) {
|
|
350
|
+
reject(new SnailCancelledError("wait cancelled before it started"));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
let timer;
|
|
354
|
+
const onAbort = () => {
|
|
355
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
356
|
+
signal?.removeEventListener("abort", onAbort);
|
|
357
|
+
reject(new SnailCancelledError("wait cancelled"));
|
|
358
|
+
};
|
|
359
|
+
timer = setTimeout(() => {
|
|
360
|
+
signal?.removeEventListener("abort", onAbort);
|
|
361
|
+
resolve();
|
|
362
|
+
}, Math.max(0, ms));
|
|
363
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Detach a Node timer from the event loop, when it has an `unref`.
|
|
368
|
+
*
|
|
369
|
+
* A polling strategy that is never stopped would otherwise keep a Node process
|
|
370
|
+
* (or a test worker) alive forever. In a browser `setTimeout` returns a number,
|
|
371
|
+
* which has no `unref` — hence the optional call rather than a feature test.
|
|
372
|
+
*/
|
|
373
|
+
function unrefTimer(timer) {
|
|
374
|
+
timer?.unref?.();
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Collapse a burst of `schedule()` calls into at most one request.
|
|
378
|
+
*
|
|
379
|
+
* Two modes, one rule — **the most recent task wins**, because a request must be
|
|
380
|
+
* sent with the latest arguments, never with the ones captured when the burst
|
|
381
|
+
* started:
|
|
382
|
+
*
|
|
383
|
+
* - `debounce` waits for `debounce` ms of quiet. Every new call pushes the run
|
|
384
|
+
* back, so ten keystrokes produce one request, not ten.
|
|
385
|
+
* - `throttle` runs on the leading edge and then books exactly one trailing run
|
|
386
|
+
* per window. Without the trailing run the last change in a burst would be
|
|
387
|
+
* silently dropped — a search box that ignores the final character.
|
|
388
|
+
*
|
|
389
|
+
* `debounce` is preferred when both are configured (see `useWatcher`): the two
|
|
390
|
+
* express contradictory intents, and picking one deterministically beats a
|
|
391
|
+
* half-debounced, half-throttled hybrid nobody can reason about.
|
|
392
|
+
*/
|
|
393
|
+
function createRequestScheduler(options = {}) {
|
|
394
|
+
const debounce = options.debounce && options.debounce > 0 ? options.debounce : 0;
|
|
395
|
+
const throttle = options.throttle && options.throttle > 0 ? options.throttle : 0;
|
|
396
|
+
let timer;
|
|
397
|
+
let latest;
|
|
398
|
+
let lastRunAt = 0;
|
|
399
|
+
let ran = false;
|
|
400
|
+
const clear = () => {
|
|
401
|
+
if (timer !== void 0) {
|
|
402
|
+
clearTimeout(timer);
|
|
403
|
+
timer = void 0;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
const runNow = () => {
|
|
407
|
+
const task = latest;
|
|
408
|
+
latest = void 0;
|
|
409
|
+
clear();
|
|
410
|
+
lastRunAt = Date.now();
|
|
411
|
+
ran = true;
|
|
412
|
+
task?.();
|
|
413
|
+
};
|
|
414
|
+
return {
|
|
415
|
+
get pending() {
|
|
416
|
+
return latest !== void 0;
|
|
417
|
+
},
|
|
418
|
+
schedule(task) {
|
|
419
|
+
latest = task;
|
|
420
|
+
if (debounce > 0) {
|
|
421
|
+
clear();
|
|
422
|
+
timer = setTimeout(runNow, debounce);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (throttle > 0) {
|
|
426
|
+
if (timer !== void 0) return;
|
|
427
|
+
const elapsed = Date.now() - lastRunAt;
|
|
428
|
+
if (!ran || elapsed >= throttle) {
|
|
429
|
+
runNow();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
timer = setTimeout(runNow, throttle - elapsed);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
runNow();
|
|
436
|
+
},
|
|
437
|
+
cancel() {
|
|
438
|
+
clear();
|
|
439
|
+
latest = void 0;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/strategies/shared/watcher.ts
|
|
445
|
+
/**
|
|
446
|
+
* Read one entry of a `watching()` result.
|
|
447
|
+
*
|
|
448
|
+
* `docs/guide/plugin-lifecycle.md`'s adapter contract exposes no way to ask "is this a state
|
|
449
|
+
* handle?" portably: the Vue adapter has `isState`, React's has none, and the
|
|
450
|
+
* plain adapter's boxes are bare `{ value }` objects. So three cases are handled,
|
|
451
|
+
* in order of confidence:
|
|
452
|
+
*
|
|
453
|
+
* 1. `adapter.isState(value)` says yes — unwrap it (Vue).
|
|
454
|
+
* 2. the value is a plain object whose *only* own key is `value` — the exact shape
|
|
455
|
+
* the plain and React adapters allocate — unwrap it.
|
|
456
|
+
* 3. anything else is the value itself.
|
|
457
|
+
*
|
|
458
|
+
* Case 2 is a heuristic, and it is the reason `watching: () => [{ value: 1 }]`
|
|
459
|
+
* should be written as `() => [{ value: 1 }.value]` if the object is genuine
|
|
460
|
+
* data rather than a handle. Without it, `() => [pageRef]` — the natural thing to
|
|
461
|
+
* write — would compare the ref object itself and never detect a change.
|
|
462
|
+
*/
|
|
463
|
+
function unwrapWatchedValue(adapter, value) {
|
|
464
|
+
if (adapter.isState?.(value)) return adapter.read(value);
|
|
465
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
466
|
+
const keys = Object.keys(value);
|
|
467
|
+
if (keys.length === 1 && keys[0] === "value") return value.value;
|
|
468
|
+
}
|
|
469
|
+
return value;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Evaluate a `watching()` function into the plain values that get compared.
|
|
473
|
+
*
|
|
474
|
+
* A watcher is user code, so a non-array return is normalised to a one-element
|
|
475
|
+
* list rather than iterated blindly — spreading a string would compare its
|
|
476
|
+
* characters, and spreading `undefined` would throw inside the hook.
|
|
477
|
+
*/
|
|
478
|
+
function readWatchedValues(adapter, watching) {
|
|
479
|
+
const produced = watching();
|
|
480
|
+
return (Array.isArray(produced) ? produced : [produced]).map((value) => unwrapWatchedValue(adapter, value));
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Compare two watched snapshots with `Object.is`.
|
|
484
|
+
*
|
|
485
|
+
* `Object.is` rather than `===` so `NaN` does not look like a change on every
|
|
486
|
+
* render — a watcher over a numeric field that happens to be `NaN` would
|
|
487
|
+
* otherwise re-send forever.
|
|
488
|
+
*/
|
|
489
|
+
function shallowEqual(a, b) {
|
|
490
|
+
if (a === b) return true;
|
|
491
|
+
if (a.length !== b.length) return false;
|
|
492
|
+
for (let index = 0; index < a.length; index += 1) if (!Object.is(a[index], b[index])) return false;
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
//#endregion
|
|
496
|
+
//#region src/strategies/use-watcher.ts
|
|
497
|
+
/**
|
|
498
|
+
* Re-send a request when watched values move.
|
|
499
|
+
*
|
|
500
|
+
* ```ts
|
|
501
|
+
* const keyword = createState("");
|
|
502
|
+
* const search = useWatcher(searchApi.find, {
|
|
503
|
+
* watching: () => [keyword.value],
|
|
504
|
+
* debounce: 200
|
|
505
|
+
* });
|
|
506
|
+
*
|
|
507
|
+
* keyword.value = "a"; await search.send(); // sends
|
|
508
|
+
* await search.send(); // same values → no request
|
|
509
|
+
* keyword.value = "ab"; await search.send(); // sends again
|
|
510
|
+
* ```
|
|
511
|
+
*
|
|
512
|
+
* ## Why `send()` is the evaluation trigger
|
|
513
|
+
*
|
|
514
|
+
* `SnailStateAdapter` exposes `create`/`read`/`write` and an optional
|
|
515
|
+
* `subscribe`; it has no `watch` or `effect`, and the core deliberately keeps
|
|
516
|
+
* framework reactivity out of the strategy layer. So there is nothing to fire at
|
|
517
|
+
* the *moment* a value changes. The hook therefore evaluates `watching()` when
|
|
518
|
+
* `send()` is called and treats an unchanged snapshot as a no-op, which keeps the
|
|
519
|
+
* behaviour deterministic and framework-free: a Vue/React integration can call
|
|
520
|
+
* `send()` from its own reactive effect, and a script calls it directly.
|
|
521
|
+
*
|
|
522
|
+
* `debounce`/`throttle` then collapse the *bursts* of `send()` calls a reactive
|
|
523
|
+
* effect produces. Every collapsed caller's promise settles with the single
|
|
524
|
+
* request's outcome, so no `await` is ever left hanging.
|
|
525
|
+
*/
|
|
526
|
+
function useWatcher(method, options) {
|
|
527
|
+
const holder = createMethodHolder(method);
|
|
528
|
+
const scheduler = createRequestScheduler({
|
|
529
|
+
debounce: options.debounce,
|
|
530
|
+
throttle: options.throttle
|
|
531
|
+
});
|
|
532
|
+
let settleWaiters = [];
|
|
533
|
+
const rejectWaiters = (error) => {
|
|
534
|
+
const waiters = settleWaiters;
|
|
535
|
+
settleWaiters = [];
|
|
536
|
+
for (const waiter of waiters) waiter.reject(error);
|
|
537
|
+
};
|
|
538
|
+
const controller = createStrategyState({
|
|
539
|
+
adapter: options.adapter,
|
|
540
|
+
initialData: options.initialData,
|
|
541
|
+
onAbort: () => {
|
|
542
|
+
scheduler.cancel();
|
|
543
|
+
rejectWaiters(new SnailCancelledError("watched request cancelled before it was sent"));
|
|
544
|
+
holder.abort();
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
const { state } = controller;
|
|
548
|
+
const watching = controller.adapter.create(true);
|
|
549
|
+
let snapshot;
|
|
550
|
+
let queuedArgs;
|
|
551
|
+
if (options.onSuccess) state.onSuccess(options.onSuccess);
|
|
552
|
+
if (options.onError) state.onError(options.onError);
|
|
553
|
+
if (options.onFinish) state.onFinish(options.onFinish);
|
|
554
|
+
async function perform() {
|
|
555
|
+
const waiters = settleWaiters;
|
|
556
|
+
settleWaiters = [];
|
|
557
|
+
if (waiters.length === 0) return;
|
|
558
|
+
const args = queuedArgs ?? [];
|
|
559
|
+
const snail = holder.resolve(args);
|
|
560
|
+
controller.resetForSend();
|
|
561
|
+
if (options.resetOnSend) controller.setData(options.initialData);
|
|
562
|
+
controller.setLoading(true);
|
|
563
|
+
try {
|
|
564
|
+
const result = await snail.send(...args);
|
|
565
|
+
const payload = controller.applySuccess(result);
|
|
566
|
+
controller.emitSuccess(payload);
|
|
567
|
+
for (const waiter of waiters) waiter.resolve(payload);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (!isCancellation(error)) {
|
|
570
|
+
controller.applyFailure(error);
|
|
571
|
+
controller.emitError(error);
|
|
572
|
+
}
|
|
573
|
+
for (const waiter of waiters) waiter.reject(error);
|
|
574
|
+
} finally {
|
|
575
|
+
controller.setLoading(false);
|
|
576
|
+
controller.emitFinish();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function send(...args) {
|
|
580
|
+
let values;
|
|
581
|
+
try {
|
|
582
|
+
values = readWatchedValues(controller.adapter, options.watching);
|
|
583
|
+
} catch (error) {
|
|
584
|
+
controller.setError(error);
|
|
585
|
+
controller.emitError(error);
|
|
586
|
+
return Promise.reject(error);
|
|
587
|
+
}
|
|
588
|
+
const changed = snapshot === void 0 || !shallowEqual(values, snapshot);
|
|
589
|
+
snapshot = values;
|
|
590
|
+
queuedArgs = args;
|
|
591
|
+
if (controller.adapter.read(watching) && !changed) return Promise.resolve(controller.adapter.read(state.data));
|
|
592
|
+
const promise = new Promise((resolve, reject) => {
|
|
593
|
+
settleWaiters.push({
|
|
594
|
+
resolve,
|
|
595
|
+
reject
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
scheduler.schedule(() => {
|
|
599
|
+
perform();
|
|
600
|
+
});
|
|
601
|
+
return promise;
|
|
602
|
+
}
|
|
603
|
+
const result = {
|
|
604
|
+
...state,
|
|
605
|
+
watching,
|
|
606
|
+
send
|
|
607
|
+
};
|
|
608
|
+
if (options.immediate) send(...[]).catch(noop);
|
|
609
|
+
return result;
|
|
610
|
+
}
|
|
611
|
+
//#endregion
|
|
612
|
+
//#region src/strategies/use-fetcher.ts
|
|
613
|
+
function useFetcher(method, options = {}) {
|
|
614
|
+
const track = options.withState === true;
|
|
615
|
+
const holder = createMethodHolder(method);
|
|
616
|
+
const controller = createStrategyState({
|
|
617
|
+
adapter: options.adapter,
|
|
618
|
+
onAbort: () => holder.abort()
|
|
619
|
+
});
|
|
620
|
+
const { state } = controller;
|
|
621
|
+
if (options.onSuccess) state.onSuccess(options.onSuccess);
|
|
622
|
+
if (options.onError) state.onError(options.onError);
|
|
623
|
+
if (options.onFinish) state.onFinish(options.onFinish);
|
|
624
|
+
async function fetch(...args) {
|
|
625
|
+
const snail = holder.resolve(args);
|
|
626
|
+
if (track) {
|
|
627
|
+
controller.resetForSend();
|
|
628
|
+
controller.setLoading(true);
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
const result = await snail.send(...args);
|
|
632
|
+
const payload = result.data;
|
|
633
|
+
if (track) controller.applySuccess(result);
|
|
634
|
+
controller.emitSuccess(payload);
|
|
635
|
+
return payload;
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (!isCancellation(error)) {
|
|
638
|
+
if (track) controller.applyFailure(error);
|
|
639
|
+
controller.emitError(error);
|
|
640
|
+
}
|
|
641
|
+
throw error;
|
|
642
|
+
} finally {
|
|
643
|
+
if (track) controller.setLoading(false);
|
|
644
|
+
controller.emitFinish();
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const core = {
|
|
648
|
+
fetch,
|
|
649
|
+
abort: () => state.abort(),
|
|
650
|
+
onSuccess: (callback) => state.onSuccess(callback),
|
|
651
|
+
onError: (callback) => state.onError(callback),
|
|
652
|
+
onFinish: (callback) => state.onFinish(callback)
|
|
653
|
+
};
|
|
654
|
+
if (options.immediate) fetch(...[]).catch(noop);
|
|
655
|
+
if (!track) return core;
|
|
656
|
+
return {
|
|
657
|
+
...core,
|
|
658
|
+
...state
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region src/strategies/use-pagination.ts
|
|
663
|
+
/** Default row extractor — array payloads, then the two common wrapper keys. */
|
|
664
|
+
function defaultList(payload) {
|
|
665
|
+
if (Array.isArray(payload)) return payload;
|
|
666
|
+
const candidate = payload;
|
|
667
|
+
if (Array.isArray(candidate?.list)) return candidate.list;
|
|
668
|
+
if (Array.isArray(candidate?.items)) return candidate.items;
|
|
669
|
+
return [];
|
|
670
|
+
}
|
|
671
|
+
/** Default total extractor — `total`, then `count`, then the current page size. */
|
|
672
|
+
function defaultTotal(payload) {
|
|
673
|
+
if (Array.isArray(payload)) return payload.length;
|
|
674
|
+
const candidate = payload;
|
|
675
|
+
if (typeof candidate?.total === "number") return candidate.total;
|
|
676
|
+
if (typeof candidate?.count === "number") return candidate.count;
|
|
677
|
+
return defaultList(payload).length;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Page through one api method.
|
|
681
|
+
*
|
|
682
|
+
* ```ts
|
|
683
|
+
* const users = usePagination(userApi.list, {
|
|
684
|
+
* total: (payload) => payload.total,
|
|
685
|
+
* list: (payload) => payload.rows
|
|
686
|
+
* });
|
|
687
|
+
* await users.reload();
|
|
688
|
+
* await users.next();
|
|
689
|
+
* ```
|
|
690
|
+
*
|
|
691
|
+
* `next()`/`prev()` are **no-ops at the bounds and fire no request**: a user
|
|
692
|
+
* holding down the "next" button at the last page must not hammer the server, and
|
|
693
|
+
* the promise still resolves (with `undefined`) so an `await` never hangs.
|
|
694
|
+
*/
|
|
695
|
+
function usePagination(method, options = {}) {
|
|
696
|
+
const initialPage = Number.isFinite(options.initialPage) ? Math.max(1, Math.floor(options.initialPage)) : 1;
|
|
697
|
+
const initialPageSize = Number.isFinite(options.initialPageSize) ? Math.max(1, Math.floor(options.initialPageSize)) : 10;
|
|
698
|
+
const append = options.append === true;
|
|
699
|
+
const extractList = options.list ?? defaultList;
|
|
700
|
+
const extractTotal = options.total ?? defaultTotal;
|
|
701
|
+
/** The preloaded page, ready to be served without a request. */
|
|
702
|
+
let buffered;
|
|
703
|
+
let preloading = false;
|
|
704
|
+
const holder = createMethodHolder(method);
|
|
705
|
+
const controller = createStrategyState({
|
|
706
|
+
adapter: options.adapter,
|
|
707
|
+
onAbort: () => holder.abort()
|
|
708
|
+
});
|
|
709
|
+
const { state } = controller;
|
|
710
|
+
const adapter = controller.adapter;
|
|
711
|
+
const page = adapter.create(initialPage);
|
|
712
|
+
const pageSize = adapter.create(initialPageSize);
|
|
713
|
+
const total = adapter.create(0);
|
|
714
|
+
const list = adapter.create([]);
|
|
715
|
+
const isLastPage = adapter.create(false);
|
|
716
|
+
if (options.onSuccess) state.onSuccess(options.onSuccess);
|
|
717
|
+
if (options.onError) state.onError(options.onError);
|
|
718
|
+
if (options.onFinish) state.onFinish(options.onFinish);
|
|
719
|
+
/** Last page reachable, or `Infinity` while the total is still unknown. */
|
|
720
|
+
function lastPage() {
|
|
721
|
+
const size = Math.max(1, adapter.read(pageSize));
|
|
722
|
+
const known = adapter.read(total);
|
|
723
|
+
if (known > 0) return Math.max(1, Math.ceil(known / size));
|
|
724
|
+
return adapter.read(isLastPage) ? adapter.read(page) : Number.POSITIVE_INFINITY;
|
|
725
|
+
}
|
|
726
|
+
/** Write the paging derived from one payload. */
|
|
727
|
+
function applyPage(payload, target, size, accumulate) {
|
|
728
|
+
const rows = extractList(payload);
|
|
729
|
+
const known = extractTotal(payload);
|
|
730
|
+
adapter.write(page, target);
|
|
731
|
+
adapter.write(total, known);
|
|
732
|
+
adapter.write(list, accumulate ? [...adapter.read(list), ...rows] : rows);
|
|
733
|
+
adapter.write(isLastPage, known > 0 ? target * size >= known : rows.length < size);
|
|
734
|
+
}
|
|
735
|
+
async function request(target, size, accumulate) {
|
|
736
|
+
const snail = holder.resolve([{
|
|
737
|
+
page: target,
|
|
738
|
+
pageSize: size
|
|
739
|
+
}]);
|
|
740
|
+
controller.resetForSend();
|
|
741
|
+
controller.setLoading(true);
|
|
742
|
+
try {
|
|
743
|
+
const result = await snail.send({
|
|
744
|
+
page: target,
|
|
745
|
+
pageSize: size
|
|
746
|
+
});
|
|
747
|
+
const payload = controller.applySuccess(result);
|
|
748
|
+
applyPage(payload, target, size, accumulate);
|
|
749
|
+
controller.emitSuccess(payload);
|
|
750
|
+
if (options.preloadNext) startPreload(target + 1);
|
|
751
|
+
return payload;
|
|
752
|
+
} catch (error) {
|
|
753
|
+
if (!isCancellation(error)) {
|
|
754
|
+
controller.applyFailure(error);
|
|
755
|
+
controller.emitError(error);
|
|
756
|
+
}
|
|
757
|
+
throw error;
|
|
758
|
+
} finally {
|
|
759
|
+
controller.setLoading(false);
|
|
760
|
+
controller.emitFinish();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Warm the buffer with `target`, using a throwaway `SnailMethod`.
|
|
765
|
+
*
|
|
766
|
+
* A second instance is what keeps the preload invisible: it has its own context,
|
|
767
|
+
* so its `loading`/`data` never reach the caller's handles. A failed preload is
|
|
768
|
+
* swallowed on purpose — `next()` simply falls back to a real request.
|
|
769
|
+
*/
|
|
770
|
+
async function startPreload(target) {
|
|
771
|
+
if (preloading) return;
|
|
772
|
+
if (target > lastPage()) return;
|
|
773
|
+
preloading = true;
|
|
774
|
+
try {
|
|
775
|
+
const size = Math.max(1, adapter.read(pageSize));
|
|
776
|
+
buffered = {
|
|
777
|
+
page: target,
|
|
778
|
+
payload: (await method({
|
|
779
|
+
page: target,
|
|
780
|
+
pageSize: size
|
|
781
|
+
}).send({
|
|
782
|
+
page: target,
|
|
783
|
+
pageSize: size
|
|
784
|
+
})).data
|
|
785
|
+
};
|
|
786
|
+
} catch {
|
|
787
|
+
buffered = void 0;
|
|
788
|
+
} finally {
|
|
789
|
+
preloading = false;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function clamp(target) {
|
|
793
|
+
const bound = lastPage();
|
|
794
|
+
return Math.min(Math.max(1, Number.isFinite(target) ? Math.floor(target) : 1), bound);
|
|
795
|
+
}
|
|
796
|
+
async function next() {
|
|
797
|
+
const current = adapter.read(page);
|
|
798
|
+
if (current >= lastPage()) return void 0;
|
|
799
|
+
const target = current + 1;
|
|
800
|
+
const size = Math.max(1, adapter.read(pageSize));
|
|
801
|
+
if (buffered && buffered.page === target) {
|
|
802
|
+
const ready = buffered;
|
|
803
|
+
buffered = void 0;
|
|
804
|
+
applyPage(ready.payload, target, size, append);
|
|
805
|
+
if (options.preloadNext) startPreload(target + 1);
|
|
806
|
+
return ready.payload;
|
|
807
|
+
}
|
|
808
|
+
return request(target, size, append);
|
|
809
|
+
}
|
|
810
|
+
async function prev() {
|
|
811
|
+
const current = adapter.read(page);
|
|
812
|
+
if (current <= 1) return void 0;
|
|
813
|
+
return request(current - 1, Math.max(1, adapter.read(pageSize)), append);
|
|
814
|
+
}
|
|
815
|
+
async function goTo(target) {
|
|
816
|
+
const resolved = clamp(target);
|
|
817
|
+
if (resolved === adapter.read(page)) return void 0;
|
|
818
|
+
return request(resolved, Math.max(1, adapter.read(pageSize)), false);
|
|
819
|
+
}
|
|
820
|
+
async function reload() {
|
|
821
|
+
const size = Math.max(1, adapter.read(pageSize));
|
|
822
|
+
adapter.write(list, []);
|
|
823
|
+
adapter.write(isLastPage, false);
|
|
824
|
+
buffered = void 0;
|
|
825
|
+
return request(initialPage, size, append);
|
|
826
|
+
}
|
|
827
|
+
async function changePageSize(size) {
|
|
828
|
+
if (!Number.isFinite(size) || size < 1) return void 0;
|
|
829
|
+
const resolved = Math.max(1, Math.floor(size));
|
|
830
|
+
adapter.write(pageSize, resolved);
|
|
831
|
+
adapter.write(list, []);
|
|
832
|
+
adapter.write(isLastPage, false);
|
|
833
|
+
buffered = void 0;
|
|
834
|
+
return request(initialPage, resolved, false);
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
...state,
|
|
838
|
+
page,
|
|
839
|
+
pageSize,
|
|
840
|
+
total,
|
|
841
|
+
list,
|
|
842
|
+
isLastPage,
|
|
843
|
+
next,
|
|
844
|
+
prev,
|
|
845
|
+
goTo,
|
|
846
|
+
reload,
|
|
847
|
+
changePageSize
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
//#endregion
|
|
851
|
+
//#region src/strategies/shared/dom.ts
|
|
852
|
+
/** The global `window`, or `undefined` outside a DOM environment. */
|
|
853
|
+
function getWindowTarget() {
|
|
854
|
+
if (typeof window === "undefined") return void 0;
|
|
855
|
+
return window;
|
|
856
|
+
}
|
|
857
|
+
/** The global `document`, or `undefined` outside a DOM environment. */
|
|
858
|
+
function getDocumentTarget() {
|
|
859
|
+
if (typeof document === "undefined") return void 0;
|
|
860
|
+
return document;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* `true` when the page is on screen.
|
|
864
|
+
*
|
|
865
|
+
* Node reports `true` so a `visibilitychange`-driven strategy still works in a
|
|
866
|
+
* script; there is no hidden page to miss.
|
|
867
|
+
*/
|
|
868
|
+
function isDocumentVisible() {
|
|
869
|
+
if (typeof document === "undefined") return true;
|
|
870
|
+
return document.visibilityState !== "hidden";
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Create a listener bookkeeping scope.
|
|
874
|
+
*
|
|
875
|
+
* `stop()` and `dispose()` are called from event handlers, from `finally` blocks
|
|
876
|
+
* and sometimes twice; without one central registry it is far too easy to remove a
|
|
877
|
+
* listener with a *different* function reference than the one added, which leaks
|
|
878
|
+
* silently and keeps the component from being collected.
|
|
879
|
+
*/
|
|
880
|
+
function createListenerScope() {
|
|
881
|
+
const registered = [];
|
|
882
|
+
return {
|
|
883
|
+
get size() {
|
|
884
|
+
return registered.length;
|
|
885
|
+
},
|
|
886
|
+
add(target, type, listener) {
|
|
887
|
+
if (!target) return false;
|
|
888
|
+
target.addEventListener(type, listener);
|
|
889
|
+
registered.push({
|
|
890
|
+
target,
|
|
891
|
+
type,
|
|
892
|
+
listener
|
|
893
|
+
});
|
|
894
|
+
return true;
|
|
895
|
+
},
|
|
896
|
+
removeAll() {
|
|
897
|
+
while (registered.length > 0) {
|
|
898
|
+
const entry = registered.pop();
|
|
899
|
+
entry.target.removeEventListener(entry.type, entry.listener);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
//#endregion
|
|
905
|
+
//#region src/strategies/use-auto-request.ts
|
|
906
|
+
/**
|
|
907
|
+
* Keep a request fresh by itself: polling plus the three "the user is back"
|
|
908
|
+
* signals a dashboard actually needs.
|
|
909
|
+
*
|
|
910
|
+
* ```ts
|
|
911
|
+
* const stats = useAutoRequest(statsApi.get, {
|
|
912
|
+
* pollingInterval: 5000,
|
|
913
|
+
* refreshOnVisible: true
|
|
914
|
+
* });
|
|
915
|
+
* stats.start();
|
|
916
|
+
* stats.dispose(); // on unmount
|
|
917
|
+
* ```
|
|
918
|
+
*
|
|
919
|
+
* ## Lifecycle
|
|
920
|
+
*
|
|
921
|
+
* `stop()` is the single release point: it halts the polling timer **and** removes
|
|
922
|
+
* every listener, so an unmounted view cannot be kept alive by a focus handler.
|
|
923
|
+
* `start()` re-arms both. The refresh listeners are attached at creation rather
|
|
924
|
+
* than in `start()` — a hook whose only job is "refresh when the user comes back"
|
|
925
|
+
* should not need an extra call — while polling itself only runs between `start()`
|
|
926
|
+
* and `stop()`.
|
|
927
|
+
*
|
|
928
|
+
* ## Node safety
|
|
929
|
+
*
|
|
930
|
+
* `window`/`document` are looked up lazily and may be absent, so creating the hook
|
|
931
|
+
* during SSR is harmless. Every listener goes through one listener scope, which is
|
|
932
|
+
* what makes `stop()` remove exactly what was added; a listener removed with a
|
|
933
|
+
* fresh closure reference would leak silently. The polling timer is `unref`'d on
|
|
934
|
+
* Node so it never holds the process (or a test worker) open.
|
|
935
|
+
*/
|
|
936
|
+
function useAutoRequest(method, options = {}) {
|
|
937
|
+
const request = useRequest(method, {
|
|
938
|
+
...options,
|
|
939
|
+
immediate: false
|
|
940
|
+
});
|
|
941
|
+
const running = resolveStateAdapter(options).create(false);
|
|
942
|
+
const scope = createListenerScope();
|
|
943
|
+
const interval = Number.isFinite(options.pollingInterval) ? Math.max(0, options.pollingInterval) : 0;
|
|
944
|
+
let timer;
|
|
945
|
+
let disposed = false;
|
|
946
|
+
function clearTimer() {
|
|
947
|
+
if (timer !== void 0) {
|
|
948
|
+
clearTimeout(timer);
|
|
949
|
+
timer = void 0;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
function refresh() {
|
|
953
|
+
return request.send(...[]);
|
|
954
|
+
}
|
|
955
|
+
/** Run one refresh without letting a rejection escape into a timer/listener. */
|
|
956
|
+
async function refreshQuietly() {
|
|
957
|
+
try {
|
|
958
|
+
await refresh();
|
|
959
|
+
} catch {}
|
|
960
|
+
}
|
|
961
|
+
function schedule() {
|
|
962
|
+
if (disposed || !running.value || interval <= 0) return;
|
|
963
|
+
timer = setTimeout(() => {
|
|
964
|
+
timer = void 0;
|
|
965
|
+
loop();
|
|
966
|
+
}, interval);
|
|
967
|
+
unrefTimer(timer);
|
|
968
|
+
}
|
|
969
|
+
async function loop() {
|
|
970
|
+
await refreshQuietly();
|
|
971
|
+
schedule();
|
|
972
|
+
}
|
|
973
|
+
function attach() {
|
|
974
|
+
scope.removeAll();
|
|
975
|
+
if (options.enableFocusRefresh) scope.add(getWindowTarget(), "focus", () => {
|
|
976
|
+
refreshQuietly();
|
|
977
|
+
});
|
|
978
|
+
if (options.enableReconnectRefresh) scope.add(getWindowTarget(), "online", () => {
|
|
979
|
+
refreshQuietly();
|
|
980
|
+
});
|
|
981
|
+
if (options.refreshOnVisible) scope.add(getDocumentTarget(), "visibilitychange", () => {
|
|
982
|
+
if (isDocumentVisible()) refreshQuietly();
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
function start() {
|
|
986
|
+
if (disposed || running.value) return;
|
|
987
|
+
running.value = true;
|
|
988
|
+
attach();
|
|
989
|
+
loop();
|
|
990
|
+
}
|
|
991
|
+
function stop() {
|
|
992
|
+
running.value = false;
|
|
993
|
+
clearTimer();
|
|
994
|
+
scope.removeAll();
|
|
995
|
+
}
|
|
996
|
+
function dispose() {
|
|
997
|
+
stop();
|
|
998
|
+
disposed = true;
|
|
999
|
+
}
|
|
1000
|
+
attach();
|
|
1001
|
+
if (options.immediate) start();
|
|
1002
|
+
return {
|
|
1003
|
+
...request,
|
|
1004
|
+
running,
|
|
1005
|
+
start,
|
|
1006
|
+
stop,
|
|
1007
|
+
refresh,
|
|
1008
|
+
dispose
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
//#endregion
|
|
1012
|
+
//#region src/strategies/shared/retry.ts
|
|
1013
|
+
/** Defaults are deliberately conservative: three tries, exponential, jittered. */
|
|
1014
|
+
var DEFAULT_RETRY_POLICY = {
|
|
1015
|
+
retries: 3,
|
|
1016
|
+
delayMs: 1e3,
|
|
1017
|
+
maxDelayMs: 3e4,
|
|
1018
|
+
factor: 2,
|
|
1019
|
+
jitter: true
|
|
1020
|
+
};
|
|
1021
|
+
/**
|
|
1022
|
+
* Fill in the retry defaults.
|
|
1023
|
+
*
|
|
1024
|
+
* `retries` is floored and clamped to `>= 0`: a negative value would make the
|
|
1025
|
+
* attempt loop run zero times and resolve without ever sending, which is a
|
|
1026
|
+
* silent no-op rather than a validation error the caller can see.
|
|
1027
|
+
*/
|
|
1028
|
+
function resolveRetryPolicy(options = {}) {
|
|
1029
|
+
return {
|
|
1030
|
+
retries: Number.isFinite(options.retries) ? Math.max(0, Math.floor(options.retries)) : DEFAULT_RETRY_POLICY.retries,
|
|
1031
|
+
delayMs: Number.isFinite(options.delayMs) ? Math.max(0, options.delayMs) : DEFAULT_RETRY_POLICY.delayMs,
|
|
1032
|
+
maxDelayMs: Number.isFinite(options.maxDelayMs) ? Math.max(0, options.maxDelayMs) : DEFAULT_RETRY_POLICY.maxDelayMs,
|
|
1033
|
+
factor: Number.isFinite(options.factor) ? Math.max(1, options.factor) : DEFAULT_RETRY_POLICY.factor,
|
|
1034
|
+
jitter: options.jitter ?? DEFAULT_RETRY_POLICY.jitter
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Delay before attempt `attempt` (1-based), with exponential growth and an
|
|
1039
|
+
* optional jitter.
|
|
1040
|
+
*
|
|
1041
|
+
* Jitter is *half* the computed delay plus a random share, not a fresh random
|
|
1042
|
+
* pick: full jitter can produce a near-zero delay that turns a retry storm into a
|
|
1043
|
+
* hammering loop, while half jitter still spreads a fleet of clients out.
|
|
1044
|
+
*/
|
|
1045
|
+
function computeBackoffDelay(attempt, policy) {
|
|
1046
|
+
const exponent = Math.max(0, attempt - 1);
|
|
1047
|
+
const base = Math.min(policy.delayMs * policy.factor ** exponent, policy.maxDelayMs);
|
|
1048
|
+
if (!policy.jitter) return base;
|
|
1049
|
+
return base / 2 + Math.random() * (base / 2);
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* The default {@link RetryPredicate}: retry everything except a cancellation.
|
|
1053
|
+
*
|
|
1054
|
+
* Retrying a cancellation is the one case that is always wrong — the caller asked
|
|
1055
|
+
* for the request to stop, so a retry ignores an explicit instruction and keeps
|
|
1056
|
+
* the socket busy.
|
|
1057
|
+
*/
|
|
1058
|
+
var defaultRetryPredicate = (error) => !isCancellation(error);
|
|
1059
|
+
//#endregion
|
|
1060
|
+
//#region src/strategies/use-retriable-request.ts
|
|
1061
|
+
/**
|
|
1062
|
+
* A request that heals itself.
|
|
1063
|
+
*
|
|
1064
|
+
* ```ts
|
|
1065
|
+
* const save = useRetriableRequest(api.save, { retries: 3, delayMs: 200 });
|
|
1066
|
+
* await save.send(payload); // may hit the network up to 4 times
|
|
1067
|
+
* save.attempts.value; // how many tries it actually took
|
|
1068
|
+
* ```
|
|
1069
|
+
*
|
|
1070
|
+
* ## Cancellation is the hard part
|
|
1071
|
+
*
|
|
1072
|
+
* The hook keeps its own `AbortController` for the backoff *between* attempts,
|
|
1073
|
+
* because at that moment there is no in-flight request to cancel. `abort()` trips
|
|
1074
|
+
* both, so a `abort()` during a 30 second delay rejects immediately instead of
|
|
1075
|
+
* leaving the caller's promise pending until the timer fires. A cancellation is
|
|
1076
|
+
* never counted as a failed attempt and never written to `error`.
|
|
1077
|
+
*
|
|
1078
|
+
* `attempts` is exposed because it is genuinely useful for telemetry ("this call
|
|
1079
|
+
* needed three tries") and because it makes the retry loop testable without
|
|
1080
|
+
* counting requests.
|
|
1081
|
+
*/
|
|
1082
|
+
function useRetriableRequest(method, options = {}) {
|
|
1083
|
+
const policy = resolveRetryPolicy(options);
|
|
1084
|
+
const retryAllowed = options.retryOn ?? defaultRetryPredicate;
|
|
1085
|
+
const holder = createMethodHolder(method);
|
|
1086
|
+
let backoff;
|
|
1087
|
+
const controller = createStrategyState({
|
|
1088
|
+
adapter: options.adapter,
|
|
1089
|
+
initialData: options.initialData,
|
|
1090
|
+
onAbort: () => {
|
|
1091
|
+
backoff?.abort();
|
|
1092
|
+
holder.abort();
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
const { state } = controller;
|
|
1096
|
+
const attempts = controller.adapter.create(0);
|
|
1097
|
+
if (options.onSuccess) state.onSuccess(options.onSuccess);
|
|
1098
|
+
if (options.onError) state.onError(options.onError);
|
|
1099
|
+
if (options.onFinish) state.onFinish(options.onFinish);
|
|
1100
|
+
async function send(...args) {
|
|
1101
|
+
const snail = holder.resolve(args);
|
|
1102
|
+
const signal = new AbortController();
|
|
1103
|
+
backoff = signal;
|
|
1104
|
+
controller.resetForSend();
|
|
1105
|
+
if (options.resetOnSend) controller.setData(options.initialData);
|
|
1106
|
+
controller.setLoading(true);
|
|
1107
|
+
controller.adapter.write(attempts, 0);
|
|
1108
|
+
try {
|
|
1109
|
+
for (let attempt = 0; attempt <= policy.retries; attempt += 1) {
|
|
1110
|
+
controller.adapter.write(attempts, attempt + 1);
|
|
1111
|
+
try {
|
|
1112
|
+
const result = await snail.send(...args);
|
|
1113
|
+
const payload = controller.applySuccess(result);
|
|
1114
|
+
controller.emitSuccess(payload);
|
|
1115
|
+
return payload;
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
if (isCancellation(error)) throw error;
|
|
1118
|
+
if (!(attempt < policy.retries && retryAllowed(error, attempt + 1))) throw error;
|
|
1119
|
+
controller.applyFailure(error);
|
|
1120
|
+
await cancellableDelay(computeBackoffDelay(attempt + 1, policy), signal.signal);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
throw new SnailCancelledError("retry loop ended without a result");
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
if (!isCancellation(error)) {
|
|
1126
|
+
controller.applyFailure(error);
|
|
1127
|
+
controller.emitError(error);
|
|
1128
|
+
}
|
|
1129
|
+
throw error;
|
|
1130
|
+
} finally {
|
|
1131
|
+
if (backoff === signal) backoff = void 0;
|
|
1132
|
+
controller.setLoading(false);
|
|
1133
|
+
controller.emitFinish();
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return {
|
|
1137
|
+
...state,
|
|
1138
|
+
send,
|
|
1139
|
+
attempts
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
//#endregion
|
|
1143
|
+
//#region src/strategies/shared/queue.ts
|
|
1144
|
+
/**
|
|
1145
|
+
* Create a queue that runs at most `concurrency` tasks at a time.
|
|
1146
|
+
*
|
|
1147
|
+
* A task that rejects is swallowed rather than failing the queue: one broken
|
|
1148
|
+
* upload must not stall the remaining ones, and the caller observes the failure
|
|
1149
|
+
* through per-file state anyway. `concurrency` is clamped to at least 1, because a
|
|
1150
|
+
* queue with zero slots would never drain and `drain()` would never resolve.
|
|
1151
|
+
*/
|
|
1152
|
+
function createTaskQueue(concurrency = 1) {
|
|
1153
|
+
const limit = Number.isFinite(concurrency) ? Math.max(1, Math.floor(concurrency)) : 1;
|
|
1154
|
+
const waiting = [];
|
|
1155
|
+
let active = 0;
|
|
1156
|
+
let idleWaiters = [];
|
|
1157
|
+
const settleIdle = () => {
|
|
1158
|
+
if (active > 0 || waiting.length > 0) return;
|
|
1159
|
+
const waiters = idleWaiters;
|
|
1160
|
+
idleWaiters = [];
|
|
1161
|
+
for (const waiter of waiters) waiter();
|
|
1162
|
+
};
|
|
1163
|
+
const pump = () => {
|
|
1164
|
+
while (active < limit && waiting.length > 0) {
|
|
1165
|
+
const task = waiting.shift();
|
|
1166
|
+
active += 1;
|
|
1167
|
+
Promise.resolve().then(task).catch(noop).then(() => {
|
|
1168
|
+
active -= 1;
|
|
1169
|
+
pump();
|
|
1170
|
+
settleIdle();
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
settleIdle();
|
|
1174
|
+
};
|
|
1175
|
+
return {
|
|
1176
|
+
get active() {
|
|
1177
|
+
return active;
|
|
1178
|
+
},
|
|
1179
|
+
get pending() {
|
|
1180
|
+
return waiting.length;
|
|
1181
|
+
},
|
|
1182
|
+
add(task) {
|
|
1183
|
+
waiting.push(task);
|
|
1184
|
+
queueMicrotask(pump);
|
|
1185
|
+
},
|
|
1186
|
+
drain() {
|
|
1187
|
+
if (active === 0 && waiting.length === 0) return Promise.resolve();
|
|
1188
|
+
return new Promise((resolve) => {
|
|
1189
|
+
idleWaiters.push(resolve);
|
|
1190
|
+
});
|
|
1191
|
+
},
|
|
1192
|
+
clear() {
|
|
1193
|
+
waiting.length = 0;
|
|
1194
|
+
settleIdle();
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
//#endregion
|
|
1199
|
+
//#region src/strategies/use-uploader.ts
|
|
1200
|
+
/** Normalise the several shapes a file input hands out. */
|
|
1201
|
+
function normalizeFiles(input) {
|
|
1202
|
+
if (!input) return [];
|
|
1203
|
+
if (Array.isArray(input)) return input;
|
|
1204
|
+
if (typeof input.length === "number") return Array.from(input);
|
|
1205
|
+
return [input];
|
|
1206
|
+
}
|
|
1207
|
+
/** Keep a fraction inside `0`–`1`; a transport can report `loaded > total`. */
|
|
1208
|
+
function clamp01(value) {
|
|
1209
|
+
if (!Number.isFinite(value)) return 0;
|
|
1210
|
+
return Math.min(1, Math.max(0, value));
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Upload files with a bounded number of parallel requests.
|
|
1214
|
+
*
|
|
1215
|
+
* ```ts
|
|
1216
|
+
* const uploader = useUploader(api.upload, { concurrency: 2 });
|
|
1217
|
+
* await uploader.upload(input.files);
|
|
1218
|
+
* uploader.files.value; // per-file status, progress, error, response
|
|
1219
|
+
* uploader.retry(id); // re-queue one failure
|
|
1220
|
+
* ```
|
|
1221
|
+
*
|
|
1222
|
+
* ## Progress
|
|
1223
|
+
*
|
|
1224
|
+
* Per-file progress comes from the transport via axios' `onUploadProgress`, which
|
|
1225
|
+
* the hook attaches to the **live** request config of each file (see
|
|
1226
|
+
* `shared/method.ts` for why that has to happen right after `send()` starts). The
|
|
1227
|
+
* aggregate is the mean of the per-file values, and a finished file counts as `1`
|
|
1228
|
+
* regardless of what the transport reported — a mocked or `fetch`-based adapter
|
|
1229
|
+
* reports nothing at all, and without that rule the bar would stop at `0`.
|
|
1230
|
+
*
|
|
1231
|
+
* ## `data` and `code`
|
|
1232
|
+
*
|
|
1233
|
+
* The state handles are shared by the whole batch, so `data` holds the payload of
|
|
1234
|
+
* the most recently finished file. Use `files[i].response` for anything
|
|
1235
|
+
* per-file. `error` mirrors the file list: it holds the first failed file's error
|
|
1236
|
+
* while any file is in the `error` state, and clears itself once the last failure
|
|
1237
|
+
* has been retried successfully.
|
|
1238
|
+
*/
|
|
1239
|
+
function useUploader(method, options = {}) {
|
|
1240
|
+
const fieldName = options.fieldName ?? "file";
|
|
1241
|
+
const queue = createTaskQueue(options.concurrency ?? 3);
|
|
1242
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
1243
|
+
let states = [];
|
|
1244
|
+
let sequence = 0;
|
|
1245
|
+
const controller = createStrategyState({
|
|
1246
|
+
adapter: options.adapter,
|
|
1247
|
+
onAbort: () => {
|
|
1248
|
+
for (const snail of [...inFlight]) snail.abort();
|
|
1249
|
+
inFlight.clear();
|
|
1250
|
+
queue.clear();
|
|
1251
|
+
for (const entry of states) {
|
|
1252
|
+
if (entry.status === "success") continue;
|
|
1253
|
+
patch(entry.id, {
|
|
1254
|
+
status: "pending",
|
|
1255
|
+
progress: 0,
|
|
1256
|
+
error: void 0
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
const { state } = controller;
|
|
1262
|
+
const adapter = controller.adapter;
|
|
1263
|
+
const files = adapter.create([]);
|
|
1264
|
+
const progress = adapter.create(0);
|
|
1265
|
+
if (options.onSuccess) state.onSuccess(options.onSuccess);
|
|
1266
|
+
if (options.onError) state.onError(options.onError);
|
|
1267
|
+
if (options.onFinish) state.onFinish(options.onFinish);
|
|
1268
|
+
function writeStates() {
|
|
1269
|
+
adapter.write(files, states.slice());
|
|
1270
|
+
}
|
|
1271
|
+
function recalc() {
|
|
1272
|
+
const aggregate = states.length === 0 ? 0 : states.reduce((sum, entry) => sum + entry.progress, 0) / states.length;
|
|
1273
|
+
adapter.write(progress, aggregate);
|
|
1274
|
+
const failure = states.find((entry) => entry.status === "error");
|
|
1275
|
+
controller.setError(failure ? failure.error : void 0);
|
|
1276
|
+
options.onProgress?.({
|
|
1277
|
+
progress: aggregate,
|
|
1278
|
+
files: states.slice()
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
function patch(id, changes) {
|
|
1282
|
+
states = states.map((entry) => entry.id === id ? {
|
|
1283
|
+
...entry,
|
|
1284
|
+
...changes
|
|
1285
|
+
} : entry);
|
|
1286
|
+
writeStates();
|
|
1287
|
+
recalc();
|
|
1288
|
+
}
|
|
1289
|
+
async function runFile(id) {
|
|
1290
|
+
const entry = states.find((candidate) => candidate.id === id);
|
|
1291
|
+
if (!entry || entry.status === "success" || entry.status === "uploading") return;
|
|
1292
|
+
patch(id, {
|
|
1293
|
+
status: "uploading",
|
|
1294
|
+
error: void 0
|
|
1295
|
+
});
|
|
1296
|
+
const form = new FormData();
|
|
1297
|
+
form.append(fieldName, entry.file, entry.file.name);
|
|
1298
|
+
const snail = method(form);
|
|
1299
|
+
inFlight.add(snail);
|
|
1300
|
+
try {
|
|
1301
|
+
const sending = snail.send(form);
|
|
1302
|
+
attachUploadProgress(snail, (event) => {
|
|
1303
|
+
if (!event.total || event.total <= 0) return;
|
|
1304
|
+
patch(id, { progress: clamp01(event.loaded / event.total) });
|
|
1305
|
+
});
|
|
1306
|
+
const result = await sending;
|
|
1307
|
+
patch(id, {
|
|
1308
|
+
status: "success",
|
|
1309
|
+
progress: 1,
|
|
1310
|
+
response: result.data,
|
|
1311
|
+
error: void 0
|
|
1312
|
+
});
|
|
1313
|
+
controller.setData(result.data);
|
|
1314
|
+
controller.setCode(result.code);
|
|
1315
|
+
controller.setMessage(result.message);
|
|
1316
|
+
controller.emitSuccess(result.data);
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
if (isCancellation(error)) {
|
|
1319
|
+
patch(id, {
|
|
1320
|
+
status: "pending",
|
|
1321
|
+
progress: 0,
|
|
1322
|
+
error: void 0
|
|
1323
|
+
});
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
patch(id, {
|
|
1327
|
+
status: "error",
|
|
1328
|
+
error
|
|
1329
|
+
});
|
|
1330
|
+
controller.applyFailure(error);
|
|
1331
|
+
controller.emitError(error);
|
|
1332
|
+
} finally {
|
|
1333
|
+
inFlight.delete(snail);
|
|
1334
|
+
recalc();
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
async function upload(input) {
|
|
1338
|
+
const incoming = normalizeFiles(input);
|
|
1339
|
+
const accepted = options.multiple === false ? incoming.slice(0, 1) : incoming;
|
|
1340
|
+
if (accepted.length === 0) return;
|
|
1341
|
+
const queued = accepted.map((file) => {
|
|
1342
|
+
sequence += 1;
|
|
1343
|
+
return {
|
|
1344
|
+
id: `upload-${sequence}`,
|
|
1345
|
+
file,
|
|
1346
|
+
status: "pending",
|
|
1347
|
+
progress: 0,
|
|
1348
|
+
error: void 0,
|
|
1349
|
+
response: void 0
|
|
1350
|
+
};
|
|
1351
|
+
});
|
|
1352
|
+
controller.resetForSend();
|
|
1353
|
+
states = [...states, ...queued];
|
|
1354
|
+
writeStates();
|
|
1355
|
+
recalc();
|
|
1356
|
+
for (const entry of queued) queue.add(() => runFile(entry.id));
|
|
1357
|
+
await queue.drain();
|
|
1358
|
+
}
|
|
1359
|
+
function retry(id) {
|
|
1360
|
+
const entry = states.find((candidate) => candidate.id === id);
|
|
1361
|
+
if (!entry || entry.status === "success" || entry.status === "uploading") return;
|
|
1362
|
+
patch(id, {
|
|
1363
|
+
status: "pending",
|
|
1364
|
+
progress: 0,
|
|
1365
|
+
error: void 0
|
|
1366
|
+
});
|
|
1367
|
+
queue.add(() => runFile(id));
|
|
1368
|
+
}
|
|
1369
|
+
return {
|
|
1370
|
+
...state,
|
|
1371
|
+
upload,
|
|
1372
|
+
files,
|
|
1373
|
+
progress,
|
|
1374
|
+
retry
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
//#endregion
|
|
1378
|
+
//#region src/strategies/use-token-auth.ts
|
|
1379
|
+
/** `ctx.state` key holding the token one request was actually sent with. */
|
|
1380
|
+
var USED_TOKEN = "token-auth:used";
|
|
1381
|
+
/**
|
|
1382
|
+
* Read the HTTP status a failure carried, without assuming an axios error class.
|
|
1383
|
+
*
|
|
1384
|
+
* Only a transport-level 401 is recoverable here. A backend that answers HTTP 200
|
|
1385
|
+
* with `{ code: 401 }` produces its `SnailResponseError` in `SnailMethod.finalize`,
|
|
1386
|
+
* *after* the `beforeRequest` chain has already returned — and no hook can recover
|
|
1387
|
+
* a post-chain failure (`onError` is observe-only by design). Such a response is
|
|
1388
|
+
* left to the caller.
|
|
1389
|
+
*/
|
|
1390
|
+
function statusOf(error) {
|
|
1391
|
+
const candidate = error;
|
|
1392
|
+
const status = candidate?.response?.status ?? candidate?.status;
|
|
1393
|
+
return typeof status === "number" ? status : void 0;
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Bearer-token authentication with single-flight refresh, as a plugin.
|
|
1397
|
+
*
|
|
1398
|
+
* ```ts
|
|
1399
|
+
* const auth = useTokenAuth({
|
|
1400
|
+
* token: () => localStorage.getItem("token"),
|
|
1401
|
+
* refresh: async () => (await api.refresh().send()).token,
|
|
1402
|
+
* onUnauthorized: () => router.push("/login")
|
|
1403
|
+
* });
|
|
1404
|
+
*
|
|
1405
|
+
* Service.use(auth.plugin);
|
|
1406
|
+
* auth.setToken("...");
|
|
1407
|
+
* ```
|
|
1408
|
+
*
|
|
1409
|
+
* ## Why a plugin and not a hook
|
|
1410
|
+
*
|
|
1411
|
+
* A token is global: every request of the service has to carry it, and a 401 on
|
|
1412
|
+
* any one of them has to invalidate the token for all of them. A per-method hook
|
|
1413
|
+
* could not see the other requests, so refresh coordination would be impossible.
|
|
1414
|
+
*
|
|
1415
|
+
* ## Exactly one refresh per wave of 401s
|
|
1416
|
+
*
|
|
1417
|
+
* Three parallel requests, one expired token, three 401s. Refreshing per request
|
|
1418
|
+
* would fire three refreshes and, with a rotating refresh token, two of them would
|
|
1419
|
+
* fail and log the user out. Two mechanisms prevent that:
|
|
1420
|
+
*
|
|
1421
|
+
* 1. a request that 401s while a refresh is already in flight **joins** it instead
|
|
1422
|
+
* of starting a second one;
|
|
1423
|
+
* 2. a request whose 401 was produced by a token that has since been replaced
|
|
1424
|
+
* replays directly, without refreshing again.
|
|
1425
|
+
*
|
|
1426
|
+
* The token each request was sent with is remembered in `ctx.state` (which the core
|
|
1427
|
+
* clears per send), and that is what makes rule 2 decidable without keeping
|
|
1428
|
+
* timers around.
|
|
1429
|
+
*
|
|
1430
|
+
* ## Replaying
|
|
1431
|
+
*
|
|
1432
|
+
* `onError` cannot recover a failure — the core rethrows after every `onError`
|
|
1433
|
+
* hook — so the recovery lives in `beforeRequest`, around `await next()`. The
|
|
1434
|
+
* replay re-runs the `requestInterceptor` reduce, the transport and the
|
|
1435
|
+
* `afterResponse` chain, so headers a plugin signs are recomputed for the retried
|
|
1436
|
+
* call. `beforeRequest` hooks are deliberately *not* re-entered: the refresh
|
|
1437
|
+
* decision has already been made, and re-entering could loop.
|
|
1438
|
+
*
|
|
1439
|
+
* ## What this cannot recover
|
|
1440
|
+
*
|
|
1441
|
+
* Only a **transport** 401 (HTTP 401). A backend that reports `{ code: 401 }` with
|
|
1442
|
+
* HTTP 200 fails in `finalize`, after the chain, where no hook can intervene; that
|
|
1443
|
+
* response reaches the caller as a `SnailResponseError`.
|
|
1444
|
+
*/
|
|
1445
|
+
function useTokenAuth(options) {
|
|
1446
|
+
const header = options.header ?? "authorization";
|
|
1447
|
+
const scheme = options.scheme ?? "Bearer";
|
|
1448
|
+
let cached;
|
|
1449
|
+
let refreshing;
|
|
1450
|
+
let disposed = false;
|
|
1451
|
+
/** Resolves when the plugin is uninstalled; lets queued requests bail out. */
|
|
1452
|
+
const disposal = deferred();
|
|
1453
|
+
async function readToken() {
|
|
1454
|
+
if (cached !== void 0) return cached;
|
|
1455
|
+
const value = await options.token();
|
|
1456
|
+
if (typeof value === "string" && value.length > 0) cached = value;
|
|
1457
|
+
return cached;
|
|
1458
|
+
}
|
|
1459
|
+
function inject(ctx, token) {
|
|
1460
|
+
ctx.request.headers.set(header, scheme ? `${scheme} ${token}` : token);
|
|
1461
|
+
}
|
|
1462
|
+
/** Start the one refresh this wave gets, or return the one already running. */
|
|
1463
|
+
function startRefresh() {
|
|
1464
|
+
if (refreshing) return refreshing;
|
|
1465
|
+
const tracked = Promise.resolve().then(() => options.refresh()).then((token) => {
|
|
1466
|
+
if (typeof token === "string" && token.length > 0) cached = token;
|
|
1467
|
+
return cached ?? token;
|
|
1468
|
+
}).finally(() => {
|
|
1469
|
+
if (refreshing === tracked) refreshing = void 0;
|
|
1470
|
+
});
|
|
1471
|
+
refreshing = tracked;
|
|
1472
|
+
tracked.catch(noop);
|
|
1473
|
+
return tracked;
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Await a refresh, giving up early when the plugin is uninstalled.
|
|
1477
|
+
*
|
|
1478
|
+
* @returns `true` when a fresh token is available, `false` when the refresh
|
|
1479
|
+
* failed or the plugin went away — in both cases the caller rethrows the
|
|
1480
|
+
* **original** 401, because the refresh error is an implementation detail the
|
|
1481
|
+
* application cannot act on.
|
|
1482
|
+
*/
|
|
1483
|
+
async function settleRefresh(pending) {
|
|
1484
|
+
try {
|
|
1485
|
+
await Promise.race([pending, disposal.promise]);
|
|
1486
|
+
} catch {
|
|
1487
|
+
return false;
|
|
1488
|
+
}
|
|
1489
|
+
return !disposed;
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Re-send the request that 401'd, with the refreshed token.
|
|
1493
|
+
*
|
|
1494
|
+
* The replay reproduces `SnailMethod.dispatch` using public plugin-manager
|
|
1495
|
+
* surface. It intentionally skips the `beforeRequest` chain — see the hook
|
|
1496
|
+
* documentation.
|
|
1497
|
+
*/
|
|
1498
|
+
async function replay(ctx) {
|
|
1499
|
+
const manager = ctx.server.pluginManager;
|
|
1500
|
+
const config = manager.reduce("requestInterceptor", ctx.request, ctx);
|
|
1501
|
+
ctx.request = config;
|
|
1502
|
+
const response = await ctx.server.axios.request(config);
|
|
1503
|
+
ctx.setResponse(response);
|
|
1504
|
+
await manager.runChain("afterResponse", ctx);
|
|
1505
|
+
}
|
|
1506
|
+
return {
|
|
1507
|
+
plugin: createPlugin({
|
|
1508
|
+
name: "token-auth",
|
|
1509
|
+
priority: 20,
|
|
1510
|
+
setup(_pluginOptions, api) {
|
|
1511
|
+
api.onDispose(() => {
|
|
1512
|
+
disposed = true;
|
|
1513
|
+
disposal.resolve();
|
|
1514
|
+
});
|
|
1515
|
+
return { async beforeRequest(ctx, next) {
|
|
1516
|
+
if (disposed) {
|
|
1517
|
+
await next();
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
if (refreshing) await settleRefresh(refreshing);
|
|
1521
|
+
const token = await readToken();
|
|
1522
|
+
if (token) inject(ctx, token);
|
|
1523
|
+
ctx.state.set(USED_TOKEN, token);
|
|
1524
|
+
try {
|
|
1525
|
+
await next();
|
|
1526
|
+
} catch (error) {
|
|
1527
|
+
if (disposed || statusOf(error) !== 401) throw error;
|
|
1528
|
+
const used = ctx.state.get(USED_TOKEN);
|
|
1529
|
+
if (!(cached !== void 0 && used !== void 0 && cached !== used)) {
|
|
1530
|
+
if (!await settleRefresh(refreshing ?? startRefresh())) {
|
|
1531
|
+
options.onUnauthorized?.(error);
|
|
1532
|
+
throw error;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
const refreshed = await readToken();
|
|
1536
|
+
if (refreshed) inject(ctx, refreshed);
|
|
1537
|
+
try {
|
|
1538
|
+
await replay(ctx);
|
|
1539
|
+
} catch (replayError) {
|
|
1540
|
+
if (statusOf(replayError) === 401) options.onUnauthorized?.(replayError);
|
|
1541
|
+
throw replayError;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
} };
|
|
1545
|
+
}
|
|
1546
|
+
})(options),
|
|
1547
|
+
setToken(token) {
|
|
1548
|
+
cached = typeof token === "string" && token.length > 0 ? token : void 0;
|
|
1549
|
+
},
|
|
1550
|
+
getToken() {
|
|
1551
|
+
return cached;
|
|
1552
|
+
},
|
|
1553
|
+
clearToken() {
|
|
1554
|
+
cached = void 0;
|
|
1555
|
+
}
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
//#endregion
|
|
1559
|
+
//#region src/strategies/use-sse.ts
|
|
1560
|
+
/** Find the message tap an endpoint or its connection offers, if any. */
|
|
1561
|
+
function attachTap(endpoint, connection, listener) {
|
|
1562
|
+
if (typeof endpoint.subscribe === "function") return endpoint.subscribe(listener) ?? noop;
|
|
1563
|
+
const onMessage = connection.onMessage;
|
|
1564
|
+
if (typeof onMessage === "function") return onMessage.call(connection, listener) ?? noop;
|
|
1565
|
+
return noop;
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Consume a Server-Sent Events endpoint as reactive state.
|
|
1569
|
+
*
|
|
1570
|
+
* ```ts
|
|
1571
|
+
* const events = Service.createSse(Events);
|
|
1572
|
+
* const feed = useSSE(events, { maxMessages: 50 });
|
|
1573
|
+
* feed.open();
|
|
1574
|
+
* feed.messages.value; // SnailSseMessage[]
|
|
1575
|
+
* feed.close();
|
|
1576
|
+
* ```
|
|
1577
|
+
*
|
|
1578
|
+
* ## The message tap
|
|
1579
|
+
*
|
|
1580
|
+
* `Service.createSse()` returns an object whose only member is `open()`, and the
|
|
1581
|
+
* messages it parses are dispatched to the decorated class's methods
|
|
1582
|
+
* (`@SseEvent()`), not to the caller. `useSSE` therefore accepts an endpoint that
|
|
1583
|
+
* additionally implements `subscribe(listener)`, or a connection that implements
|
|
1584
|
+
* `onMessage(listener)`. Both extend the core contract without changing it; an
|
|
1585
|
+
* endpoint that implements neither still reports `connected`/`error` faithfully,
|
|
1586
|
+
* it just never fills `messages`.
|
|
1587
|
+
*
|
|
1588
|
+
* ## Never an unhandled rejection
|
|
1589
|
+
*
|
|
1590
|
+
* A rejected `opened` promise is exactly how a failed SSE connect is reported, and
|
|
1591
|
+
* nothing else in the application will ever await it. Both `opened` and `closed`
|
|
1592
|
+
* are handled here, so a dead server cannot take the process down with an
|
|
1593
|
+
* unhandled rejection.
|
|
1594
|
+
*/
|
|
1595
|
+
function useSSE(endpoint, options = {}) {
|
|
1596
|
+
const adapter = resolveStateAdapter(options);
|
|
1597
|
+
const maxMessages = Number.isFinite(options.maxMessages) ? Math.max(1, Math.floor(options.maxMessages)) : 100;
|
|
1598
|
+
const messages = adapter.create([]);
|
|
1599
|
+
const lastMessage = adapter.create(void 0);
|
|
1600
|
+
const connected = adapter.create(false);
|
|
1601
|
+
const error = adapter.create(void 0);
|
|
1602
|
+
let connection;
|
|
1603
|
+
let detach;
|
|
1604
|
+
let listening = false;
|
|
1605
|
+
function handle(message) {
|
|
1606
|
+
try {
|
|
1607
|
+
if (options.filter && !options.filter(message)) return;
|
|
1608
|
+
const buffered = [...adapter.read(messages), message];
|
|
1609
|
+
if (buffered.length > maxMessages) buffered.splice(0, buffered.length - maxMessages);
|
|
1610
|
+
adapter.write(messages, buffered);
|
|
1611
|
+
adapter.write(lastMessage, message);
|
|
1612
|
+
options.onMessage?.(message);
|
|
1613
|
+
} catch (cause) {
|
|
1614
|
+
adapter.write(error, cause);
|
|
1615
|
+
}
|
|
1616
|
+
adapter.write(connected, connection?.connected ?? adapter.read(connected));
|
|
1617
|
+
}
|
|
1618
|
+
function open() {
|
|
1619
|
+
if (listening) return;
|
|
1620
|
+
detach?.();
|
|
1621
|
+
detach = void 0;
|
|
1622
|
+
const next = endpoint.open();
|
|
1623
|
+
connection = next;
|
|
1624
|
+
listening = true;
|
|
1625
|
+
detach = attachTap(endpoint, next, handle);
|
|
1626
|
+
adapter.write(connected, next.connected);
|
|
1627
|
+
adapter.write(error, void 0);
|
|
1628
|
+
next.opened.then(() => {
|
|
1629
|
+
if (connection !== next) return;
|
|
1630
|
+
adapter.write(connected, true);
|
|
1631
|
+
adapter.write(error, void 0);
|
|
1632
|
+
}, (cause) => {
|
|
1633
|
+
if (connection !== next) return;
|
|
1634
|
+
adapter.write(connected, false);
|
|
1635
|
+
adapter.write(error, cause);
|
|
1636
|
+
});
|
|
1637
|
+
next.closed.then(() => {
|
|
1638
|
+
if (connection !== next) return;
|
|
1639
|
+
listening = false;
|
|
1640
|
+
adapter.write(connected, false);
|
|
1641
|
+
}, () => {
|
|
1642
|
+
if (connection !== next) return;
|
|
1643
|
+
listening = false;
|
|
1644
|
+
adapter.write(connected, false);
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
function close() {
|
|
1648
|
+
const current = connection;
|
|
1649
|
+
connection = void 0;
|
|
1650
|
+
listening = false;
|
|
1651
|
+
adapter.write(connected, false);
|
|
1652
|
+
detach?.();
|
|
1653
|
+
detach = void 0;
|
|
1654
|
+
current?.close();
|
|
1655
|
+
}
|
|
1656
|
+
function clear() {
|
|
1657
|
+
adapter.write(messages, []);
|
|
1658
|
+
adapter.write(lastMessage, void 0);
|
|
1659
|
+
}
|
|
1660
|
+
if (options.immediate) open();
|
|
1661
|
+
return {
|
|
1662
|
+
messages,
|
|
1663
|
+
lastMessage,
|
|
1664
|
+
connected,
|
|
1665
|
+
error,
|
|
1666
|
+
open,
|
|
1667
|
+
close,
|
|
1668
|
+
clear,
|
|
1669
|
+
bind() {
|
|
1670
|
+
return {
|
|
1671
|
+
messages: bindRef(adapter, messages),
|
|
1672
|
+
lastMessage: bindRef(adapter, lastMessage),
|
|
1673
|
+
connected: bindRef(adapter, connected),
|
|
1674
|
+
error: bindRef(adapter, error)
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
//#endregion
|
|
1680
|
+
//#region src/strategies/use-download.ts
|
|
1681
|
+
/**
|
|
1682
|
+
* Drive a server-prepared download.
|
|
1683
|
+
*
|
|
1684
|
+
* ```ts
|
|
1685
|
+
* @Api("/report")
|
|
1686
|
+
* class ReportApi {
|
|
1687
|
+
* /** Prepares the export server-side and answers with a temp url. *\/
|
|
1688
|
+
* @Post("/export")
|
|
1689
|
+
* create(@Data() query: ReportQuery): Promise<{ url: string; filename: string }> {
|
|
1690
|
+
* return null!;
|
|
1691
|
+
* }
|
|
1692
|
+
* }
|
|
1693
|
+
*
|
|
1694
|
+
* const { download, loading, error } = useDownload(reportApi.create);
|
|
1695
|
+
* await download({ from: "2026-01-01" });
|
|
1696
|
+
* ```
|
|
1697
|
+
*
|
|
1698
|
+
* ## Why it does not fetch the file
|
|
1699
|
+
*
|
|
1700
|
+
* `useDownload` awaits the request that *mints* a download URL, and only that. It
|
|
1701
|
+
* never fetches the file itself. Buffering a response into a `Blob` costs the whole
|
|
1702
|
+
* file in JavaScript memory — twice, once for the body and once for the object URL
|
|
1703
|
+
* — with no progress and no resume. Letting the browser fetch the URL instead gives
|
|
1704
|
+
* you the native download manager, streaming to disk, resume support and a download
|
|
1705
|
+
* that survives navigation.
|
|
1706
|
+
*
|
|
1707
|
+
* ## Why this is a strategy and not a plugin
|
|
1708
|
+
*
|
|
1709
|
+
* A plugin is for a cross-cutting concern that applies to requests an application
|
|
1710
|
+
* did not write specially: caching, interceptors, validation. A download is the
|
|
1711
|
+
* opposite — one explicit user action, with its own visible state and its own
|
|
1712
|
+
* failure modes, invoked from a click handler. That is the definition of a request
|
|
1713
|
+
* strategy. The reusable half that is *not* about reactivity — clicking a temporary
|
|
1714
|
+
* anchor — lives in `triggerDownload()`, which is exported from the package root so
|
|
1715
|
+
* a non-hook caller can use it directly.
|
|
1716
|
+
*/
|
|
1717
|
+
function useDownload(method, options = {}) {
|
|
1718
|
+
const holder = createMethodHolder(method);
|
|
1719
|
+
const controller = createStrategyState({
|
|
1720
|
+
adapter: options.adapter,
|
|
1721
|
+
onAbort: () => holder.abort()
|
|
1722
|
+
});
|
|
1723
|
+
const { state, adapter } = controller;
|
|
1724
|
+
const info = adapter.create(void 0);
|
|
1725
|
+
const downloadListeners = /* @__PURE__ */ new Set();
|
|
1726
|
+
if (options.onSuccess) state.onSuccess(options.onSuccess);
|
|
1727
|
+
if (options.onError) state.onError(options.onError);
|
|
1728
|
+
if (options.onFinish) state.onFinish(options.onFinish);
|
|
1729
|
+
const triggerOptions = {
|
|
1730
|
+
openInNewTab: options.openInNewTab,
|
|
1731
|
+
container: options.container,
|
|
1732
|
+
referrerPolicy: options.referrerPolicy
|
|
1733
|
+
};
|
|
1734
|
+
/** Fire the browser download, surfacing a throwing callback as a state error. */
|
|
1735
|
+
function dispatch(descriptor) {
|
|
1736
|
+
const result = triggerDownload(descriptor.url, {
|
|
1737
|
+
...triggerOptions,
|
|
1738
|
+
filename: options.filename ?? descriptor.filename
|
|
1739
|
+
});
|
|
1740
|
+
adapter.write(info, descriptor);
|
|
1741
|
+
for (const listener of [...downloadListeners]) try {
|
|
1742
|
+
listener(descriptor);
|
|
1743
|
+
} catch {}
|
|
1744
|
+
return result;
|
|
1745
|
+
}
|
|
1746
|
+
async function download(...args) {
|
|
1747
|
+
controller.resetForSend();
|
|
1748
|
+
controller.setLoading(true);
|
|
1749
|
+
try {
|
|
1750
|
+
const result = await holder.resolve(args).send(...args);
|
|
1751
|
+
const descriptor = resolveDescriptor(result.data, options);
|
|
1752
|
+
let triggered;
|
|
1753
|
+
if (options.autoTrigger === false) {
|
|
1754
|
+
adapter.write(info, descriptor);
|
|
1755
|
+
triggered = {
|
|
1756
|
+
url: descriptor.url,
|
|
1757
|
+
filename: descriptor.filename
|
|
1758
|
+
};
|
|
1759
|
+
} else triggered = dispatch(descriptor);
|
|
1760
|
+
controller.applySuccess(result);
|
|
1761
|
+
controller.emitSuccess(result.data);
|
|
1762
|
+
return triggered;
|
|
1763
|
+
} catch (error) {
|
|
1764
|
+
if (!isCancellation(error)) {
|
|
1765
|
+
controller.applyFailure(error);
|
|
1766
|
+
controller.emitError(error);
|
|
1767
|
+
}
|
|
1768
|
+
throw error;
|
|
1769
|
+
} finally {
|
|
1770
|
+
controller.setLoading(false);
|
|
1771
|
+
controller.emitFinish();
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
return {
|
|
1775
|
+
...state,
|
|
1776
|
+
download,
|
|
1777
|
+
info,
|
|
1778
|
+
onDownload(callback) {
|
|
1779
|
+
downloadListeners.add(callback);
|
|
1780
|
+
return () => {
|
|
1781
|
+
downloadListeners.delete(callback);
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Turn a payload into a descriptor.
|
|
1788
|
+
*
|
|
1789
|
+
* Accepting several spellings is not sloppiness — it removes a naming argument from
|
|
1790
|
+
* every integration. A backend that answers `{ fileUrl }` should not force a `pick`
|
|
1791
|
+
* implementation for the sake of one word.
|
|
1792
|
+
*/
|
|
1793
|
+
function resolveDescriptor(payload, options) {
|
|
1794
|
+
if (options.pick) {
|
|
1795
|
+
const descriptor = options.pick(payload);
|
|
1796
|
+
if (!descriptor || typeof descriptor.url !== "string" || descriptor.url.length === 0) throw new TypeError("[snail] useDownload: `pick` must return a descriptor with a non-empty `url`");
|
|
1797
|
+
return descriptor;
|
|
1798
|
+
}
|
|
1799
|
+
if (typeof payload === "string" && payload.length > 0) return { url: payload };
|
|
1800
|
+
const candidate = payload;
|
|
1801
|
+
if (candidate && typeof candidate === "object") {
|
|
1802
|
+
const url = candidate.url ?? candidate.downloadUrl ?? candidate.fileUrl;
|
|
1803
|
+
if (typeof url === "string" && url.length > 0) {
|
|
1804
|
+
const filename = candidate.filename ?? candidate.name;
|
|
1805
|
+
return filename ? {
|
|
1806
|
+
url,
|
|
1807
|
+
filename
|
|
1808
|
+
} : { url };
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
throw new TypeError("[snail] useDownload: the response carried no download url. Expected a string, or an object with `url` / `downloadUrl` / `fileUrl`. Pass `pick` if your server names it differently.");
|
|
1812
|
+
}
|
|
1813
|
+
//#endregion
|
|
1814
|
+
export { useRetriableRequest as a, useFetcher as c, createStrategyState as d, setStateAdapter as f, useUploader as i, useWatcher as l, useSSE as n, useAutoRequest as o, plainStateAdapter as p, useTokenAuth as r, usePagination as s, useDownload as t, useRequest as u };
|
|
1815
|
+
|
|
1816
|
+
//# sourceMappingURL=use-download-FAjtdJ58.js.map
|