@planet-matrix/mobius-model 0.5.0 → 0.6.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.
Files changed (175) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +123 -36
  3. package/dist/index.js +45 -4
  4. package/dist/index.js.map +183 -11
  5. package/oxlint.config.ts +6 -0
  6. package/package.json +16 -10
  7. package/src/abort/README.md +92 -0
  8. package/src/abort/abort-manager.ts +278 -0
  9. package/src/abort/abort-signal-listener-manager.ts +81 -0
  10. package/src/abort/index.ts +2 -0
  11. package/src/basic/README.md +69 -118
  12. package/src/basic/function.ts +81 -62
  13. package/src/basic/is.ts +152 -71
  14. package/src/basic/promise.ts +29 -8
  15. package/src/basic/string.ts +2 -33
  16. package/src/color/README.md +105 -0
  17. package/src/color/index.ts +3 -0
  18. package/src/color/internal.ts +42 -0
  19. package/src/color/rgb/analyze.ts +236 -0
  20. package/src/color/rgb/construct.ts +130 -0
  21. package/src/color/rgb/convert.ts +227 -0
  22. package/src/color/rgb/derive.ts +303 -0
  23. package/src/color/rgb/index.ts +6 -0
  24. package/src/color/rgb/internal.ts +208 -0
  25. package/src/color/rgb/parse.ts +302 -0
  26. package/src/color/rgb/serialize.ts +144 -0
  27. package/src/color/types.ts +57 -0
  28. package/src/color/xyz/analyze.ts +80 -0
  29. package/src/color/xyz/construct.ts +19 -0
  30. package/src/color/xyz/convert.ts +71 -0
  31. package/src/color/xyz/index.ts +3 -0
  32. package/src/color/xyz/internal.ts +23 -0
  33. package/src/css/README.md +93 -0
  34. package/src/css/class.ts +559 -0
  35. package/src/css/index.ts +1 -0
  36. package/src/encoding/README.md +66 -79
  37. package/src/encoding/base64.ts +13 -4
  38. package/src/environment/README.md +97 -0
  39. package/src/environment/basic.ts +26 -0
  40. package/src/environment/device.ts +311 -0
  41. package/src/environment/feature.ts +285 -0
  42. package/src/environment/geo.ts +337 -0
  43. package/src/environment/index.ts +7 -0
  44. package/src/environment/runtime.ts +400 -0
  45. package/src/environment/snapshot.ts +60 -0
  46. package/src/environment/variable.ts +239 -0
  47. package/src/event/README.md +90 -0
  48. package/src/event/class-event-proxy.ts +228 -0
  49. package/src/event/common.ts +19 -0
  50. package/src/event/event-manager.ts +203 -0
  51. package/src/event/index.ts +4 -0
  52. package/src/event/instance-event-proxy.ts +186 -0
  53. package/src/event/internal.ts +24 -0
  54. package/src/exception/README.md +96 -0
  55. package/src/exception/browser.ts +219 -0
  56. package/src/exception/index.ts +4 -0
  57. package/src/exception/nodejs.ts +169 -0
  58. package/src/exception/normalize.ts +106 -0
  59. package/src/exception/types.ts +99 -0
  60. package/src/identifier/README.md +92 -0
  61. package/src/identifier/id.ts +119 -0
  62. package/src/identifier/index.ts +2 -0
  63. package/src/identifier/uuid.ts +187 -0
  64. package/src/index.ts +16 -1
  65. package/src/log/README.md +79 -0
  66. package/src/log/index.ts +5 -0
  67. package/src/log/log-emitter.ts +72 -0
  68. package/src/log/log-record.ts +10 -0
  69. package/src/log/log-scheduler.ts +74 -0
  70. package/src/log/log-type.ts +8 -0
  71. package/src/log/logger.ts +543 -0
  72. package/src/orchestration/README.md +89 -0
  73. package/src/orchestration/coordination/barrier.ts +214 -0
  74. package/src/orchestration/coordination/count-down-latch.ts +215 -0
  75. package/src/orchestration/coordination/errors.ts +98 -0
  76. package/src/orchestration/coordination/index.ts +16 -0
  77. package/src/orchestration/coordination/internal/wait-constraints.ts +95 -0
  78. package/src/orchestration/coordination/internal/wait-queue.ts +109 -0
  79. package/src/orchestration/coordination/keyed-lock.ts +168 -0
  80. package/src/orchestration/coordination/mutex.ts +257 -0
  81. package/src/orchestration/coordination/permit.ts +127 -0
  82. package/src/orchestration/coordination/read-write-lock.ts +444 -0
  83. package/src/orchestration/coordination/semaphore.ts +280 -0
  84. package/src/orchestration/index.ts +1 -0
  85. package/src/random/README.md +55 -86
  86. package/src/random/index.ts +1 -1
  87. package/src/random/string.ts +35 -0
  88. package/src/reactor/README.md +4 -0
  89. package/src/reactor/reactor-core/primitive.ts +9 -9
  90. package/src/reactor/reactor-core/reactive-system.ts +5 -5
  91. package/src/singleton/README.md +79 -0
  92. package/src/singleton/factory.ts +55 -0
  93. package/src/singleton/index.ts +2 -0
  94. package/src/singleton/manager.ts +204 -0
  95. package/src/storage/README.md +107 -0
  96. package/src/storage/index.ts +1 -0
  97. package/src/storage/table.ts +449 -0
  98. package/src/timer/README.md +86 -0
  99. package/src/timer/expiration/expiration-manager.ts +594 -0
  100. package/src/timer/expiration/index.ts +3 -0
  101. package/src/timer/expiration/min-heap.ts +208 -0
  102. package/src/timer/expiration/remaining-manager.ts +241 -0
  103. package/src/timer/index.ts +1 -0
  104. package/src/type/README.md +54 -307
  105. package/src/type/class.ts +2 -2
  106. package/src/type/index.ts +14 -14
  107. package/src/type/is.ts +265 -2
  108. package/src/type/object.ts +37 -0
  109. package/src/type/string.ts +7 -2
  110. package/src/type/tuple.ts +6 -6
  111. package/src/type/union.ts +16 -0
  112. package/src/web/README.md +77 -0
  113. package/src/web/capture.ts +35 -0
  114. package/src/web/clipboard.ts +97 -0
  115. package/src/web/dom.ts +117 -0
  116. package/src/web/download.ts +16 -0
  117. package/src/web/event.ts +46 -0
  118. package/src/web/index.ts +10 -0
  119. package/src/web/local-storage.ts +113 -0
  120. package/src/web/location.ts +28 -0
  121. package/src/web/permission.ts +172 -0
  122. package/src/web/script-loader.ts +432 -0
  123. package/tests/unit/abort/abort-manager.spec.ts +225 -0
  124. package/tests/unit/abort/abort-signal-listener-manager.spec.ts +62 -0
  125. package/tests/unit/basic/array.spec.ts +1 -1
  126. package/tests/unit/basic/stream.spec.ts +1 -1
  127. package/tests/unit/basic/string.spec.ts +0 -9
  128. package/tests/unit/color/rgb/analyze.spec.ts +110 -0
  129. package/tests/unit/color/rgb/construct.spec.ts +56 -0
  130. package/tests/unit/color/rgb/convert.spec.ts +60 -0
  131. package/tests/unit/color/rgb/derive.spec.ts +103 -0
  132. package/tests/unit/color/rgb/parse.spec.ts +66 -0
  133. package/tests/unit/color/rgb/serialize.spec.ts +46 -0
  134. package/tests/unit/color/xyz/analyze.spec.ts +33 -0
  135. package/tests/unit/color/xyz/construct.spec.ts +10 -0
  136. package/tests/unit/color/xyz/convert.spec.ts +18 -0
  137. package/tests/unit/css/class.spec.ts +157 -0
  138. package/tests/unit/environment/basic.spec.ts +20 -0
  139. package/tests/unit/environment/device.spec.ts +146 -0
  140. package/tests/unit/environment/feature.spec.ts +388 -0
  141. package/tests/unit/environment/geo.spec.ts +111 -0
  142. package/tests/unit/environment/runtime.spec.ts +364 -0
  143. package/tests/unit/environment/snapshot.spec.ts +4 -0
  144. package/tests/unit/environment/variable.spec.ts +190 -0
  145. package/tests/unit/event/class-event-proxy.spec.ts +225 -0
  146. package/tests/unit/event/event-manager.spec.ts +246 -0
  147. package/tests/unit/event/instance-event-proxy.spec.ts +187 -0
  148. package/tests/unit/exception/browser.spec.ts +213 -0
  149. package/tests/unit/exception/nodejs.spec.ts +144 -0
  150. package/tests/unit/exception/normalize.spec.ts +57 -0
  151. package/tests/unit/identifier/id.spec.ts +71 -0
  152. package/tests/unit/identifier/uuid.spec.ts +85 -0
  153. package/tests/unit/log/log-emitter.spec.ts +33 -0
  154. package/tests/unit/log/log-scheduler.spec.ts +40 -0
  155. package/tests/unit/log/log-type.spec.ts +7 -0
  156. package/tests/unit/log/logger.spec.ts +222 -0
  157. package/tests/unit/orchestration/coordination/barrier.spec.ts +96 -0
  158. package/tests/unit/orchestration/coordination/count-down-latch.spec.ts +63 -0
  159. package/tests/unit/orchestration/coordination/errors.spec.ts +29 -0
  160. package/tests/unit/orchestration/coordination/keyed-lock.spec.ts +109 -0
  161. package/tests/unit/orchestration/coordination/mutex.spec.ts +132 -0
  162. package/tests/unit/orchestration/coordination/permit.spec.ts +43 -0
  163. package/tests/unit/orchestration/coordination/read-write-lock.spec.ts +154 -0
  164. package/tests/unit/orchestration/coordination/semaphore.spec.ts +135 -0
  165. package/tests/unit/random/string.spec.ts +11 -0
  166. package/tests/unit/reactor/alien-signals-effect.spec.ts +11 -10
  167. package/tests/unit/reactor/preact-signal.spec.ts +1 -2
  168. package/tests/unit/singleton/singleton.spec.ts +49 -0
  169. package/tests/unit/storage/table.spec.ts +620 -0
  170. package/tests/unit/timer/expiration/expiration-manager.spec.ts +464 -0
  171. package/tests/unit/timer/expiration/min-heap.spec.ts +71 -0
  172. package/tests/unit/timer/expiration/remaining-manager.spec.ts +234 -0
  173. package/.oxlintrc.json +0 -5
  174. package/src/random/uuid.ts +0 -103
  175. package/tests/unit/random/uuid.spec.ts +0 -37
@@ -0,0 +1,172 @@
1
+ /**
2
+ * 表示可用于 Web 权限查询与订阅的权限名称。
3
+ *
4
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Permissions}
5
+ */
6
+ export type WebPermissionName =
7
+ | PermissionName
8
+ | "accelerometer"
9
+ | "accessibility-events"
10
+ | "ambient-light-sensor"
11
+ | "background-sync"
12
+ | "camera"
13
+ | "capture-surface-control"
14
+ | "clipboard-read"
15
+ | "clipboard-write"
16
+ | "geolocation"
17
+ | "gyroscope"
18
+ | "local-fonts"
19
+ | "magnetometer"
20
+ | "microphone"
21
+ | "midi"
22
+ | "notifications"
23
+ | "payment-handler"
24
+ | "persistent-storage"
25
+ | "push"
26
+ | "screen-wake-lock"
27
+ | "top-level-storage-access"
28
+ | "window-management"
29
+
30
+ /**
31
+ * 表示查询单个 Web 权限时的输入选项。
32
+ */
33
+ export interface QueryPermissionOptions {
34
+ name: WebPermissionName
35
+ }
36
+
37
+ /**
38
+ * 表示单个 Web 权限查询后的标准化结果。
39
+ */
40
+ export interface QueryPermissionResult {
41
+ name: WebPermissionName
42
+ state: PermissionState | "unsupported" | "exception"
43
+ detail?: string | undefined
44
+ }
45
+ /**
46
+ * 查询指定 Web 权限的当前状态。
47
+ *
48
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Permissions/query}
49
+ */
50
+ export const queryPermission = async (options: QueryPermissionOptions): Promise<QueryPermissionResult> => {
51
+ const { name } = options
52
+ try {
53
+ const permissionStatus = await navigator.permissions.query({
54
+ // oxlint-disable-next-line no-unsafe-type-assertion
55
+ name: name as PermissionName
56
+ })
57
+
58
+ return {
59
+ name: name,
60
+ state: permissionStatus.state
61
+ }
62
+ } catch (exception) {
63
+ if (exception instanceof TypeError) {
64
+ return {
65
+ name: name,
66
+ state: "unsupported",
67
+ detail: exception.message
68
+ }
69
+ } else if (exception instanceof DOMException) {
70
+ return {
71
+ name: name,
72
+ state: "exception",
73
+ detail: exception.message
74
+ }
75
+ } else {
76
+ return {
77
+ name: name,
78
+ state: "exception",
79
+ detail: String(exception)
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 确保指定权限已被授予;如果权限状态不为 "granted",则抛出错误。
87
+ */
88
+ export const ensurePermissionGranted = async (options: QueryPermissionOptions): Promise<void> => {
89
+ try {
90
+ const queryResult = await queryPermission(options)
91
+ if (queryResult.state !== "granted") {
92
+ throw new Error(`Permission "${options.name}" is not granted. Current state: ${queryResult.state}`)
93
+ }
94
+ } catch (exception) {
95
+ throw new Error(`Failed to ensure permission "${options.name}"`, { cause: exception })
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 表示订阅单个 Web 权限变化时的输入选项。
101
+ */
102
+ export interface SubscribePermissionOptions {
103
+ name: WebPermissionName
104
+ subscribe: (queryPermissionResult: QueryPermissionResult) => void
105
+ }
106
+ /**
107
+ * 订阅指定权限状态的变化。当权限状态发生变化时,回调函数会被调用并传入最新的权限状态。
108
+ */
109
+ export const subscribePermission = (options: SubscribePermissionOptions): (() => void) => {
110
+ const { name, subscribe } = options
111
+
112
+ let unsubscribed = false
113
+ let internalUnsubscribe: (() => void) | undefined = undefined
114
+ const unsubscribe = (): void => {
115
+ unsubscribed = true
116
+ internalUnsubscribe?.()
117
+ }
118
+
119
+ try {
120
+ void navigator.permissions.query({
121
+ // oxlint-disable-next-line no-unsafe-type-assertion
122
+ name: name as PermissionName
123
+ }).then((permissionStatus) => {
124
+ if (unsubscribed === true) {
125
+ return
126
+ }
127
+
128
+ const internalSubscribe = (): void => {
129
+ subscribe({
130
+ name: name,
131
+ state: permissionStatus.state
132
+ })
133
+ }
134
+ permissionStatus.addEventListener("change", internalSubscribe)
135
+ internalUnsubscribe = (): void => {
136
+ permissionStatus.removeEventListener("change", internalSubscribe)
137
+ }
138
+ }).catch((exception: unknown) => {
139
+ if (unsubscribed === true) {
140
+ return
141
+ }
142
+
143
+ if (exception instanceof TypeError) {
144
+ subscribe({
145
+ name: name,
146
+ state: "unsupported",
147
+ detail: exception.message
148
+ })
149
+ } else if (exception instanceof DOMException) {
150
+ subscribe({
151
+ name: name,
152
+ state: "exception",
153
+ detail: exception.message
154
+ })
155
+ } else {
156
+ subscribe({
157
+ name: name,
158
+ state: "exception",
159
+ detail: String(exception)
160
+ })
161
+ }
162
+ })
163
+ } catch (exception: unknown) {
164
+ subscribe({
165
+ name: name,
166
+ state: "unsupported",
167
+ detail: String(exception)
168
+ })
169
+ }
170
+
171
+ return unsubscribe
172
+ }
@@ -0,0 +1,432 @@
1
+
2
+ /**
3
+ * 将脚本地址转换为基于当前文档的绝对地址,避免相对路径、完整地址和等价地址之间比较不一致。
4
+ */
5
+ export const normalizeScriptSrc = (src: string): string => {
6
+ return new URL(src, document.baseURI).href
7
+ }
8
+
9
+ /**
10
+ * 在当前页面中查找与目标地址匹配的 `<script>` 元素。
11
+ *
12
+ * 查找时会先将输入地址与现有脚本地址都标准化为绝对地址,
13
+ * 因此可以识别相同脚本的不同写法。
14
+ */
15
+ export const findScript = (src: string): HTMLScriptElement | null => {
16
+ const normalizedSrc = normalizeScriptSrc(src)
17
+ return Array.from(document.scripts).find((item) => {
18
+ if (item.hasAttribute("src") === false || item.src === "") {
19
+ return false
20
+ }
21
+ return normalizeScriptSrc(item.src) === normalizedSrc
22
+ }) ?? null
23
+ }
24
+
25
+ /**
26
+ * 表示页面中的内联 JavaScript 脚本项。
27
+ */
28
+ export interface JavaScriptCollectionInternalItem {
29
+ element: HTMLScriptElement
30
+ }
31
+
32
+ /**
33
+ * 表示页面中的外部 JavaScript 脚本项。
34
+ */
35
+ export interface JavaScriptCollectionExternalItem {
36
+ element: HTMLScriptElement
37
+ src: string
38
+ }
39
+
40
+ /**
41
+ * 表示页面中脚本采集结果的集合。
42
+ */
43
+ export interface JavaScriptCollection {
44
+ internal: JavaScriptCollectionInternalItem[]
45
+ external: JavaScriptCollectionExternalItem[]
46
+ }
47
+
48
+ /**
49
+ * 采集页面中已经存在的 JavaScript 脚本,包括内部脚本和外部脚本两种。
50
+ *
51
+ * - 内部脚本:(InternalScript)指 JavaScript 代码直接写在 `<script></script>` 标签内部。
52
+ *
53
+ * - 外部脚本:(ExternalScript)指通过设置 `<script></script>` 标签的 `src` 属性进行引入的 JavaScript 脚本文件。
54
+ *
55
+ * {@link https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/script#attr-type | \<script\> 脚本元素}
56
+ */
57
+ export const collectJavaScript = (): JavaScriptCollection => {
58
+ const scripts = Array.from(document.scripts)
59
+ const collection = scripts.reduce<JavaScriptCollection>((value, item) => {
60
+ if (item.hasAttribute("src")) {
61
+ const normalizedSrc = normalizeScriptSrc(item.src)
62
+ value.external.push({ src: normalizedSrc, element: item })
63
+ }
64
+ else {
65
+ value.internal.push({ element: item })
66
+ }
67
+ return value
68
+ }, { internal: [], external: [] })
69
+ return collection
70
+ }
71
+
72
+ /**
73
+ * 表示单个脚本的加载选项。
74
+ */
75
+ export interface ScriptLoadOptions {
76
+ /**
77
+ * 待加载脚本的地址。
78
+ */
79
+ src: string
80
+ /**
81
+ * 调用方自定义的脚本标识,仅用于透传到结果与回调中。
82
+ */
83
+ name?: string | undefined
84
+ /**
85
+ * 指定脚本类型。
86
+ */
87
+ type?: "module" | undefined
88
+ /**
89
+ * 是否为插入的脚本节点设置 `async=true`。
90
+ */
91
+ async?: boolean | undefined
92
+ /**
93
+ * 加载超时时间,单位为毫秒。
94
+ */
95
+ timeout?: number | undefined
96
+ /**
97
+ * 脚本可用后执行的采集函数,返回值会保存在加载结果中。
98
+ */
99
+ collect?: ((element: HTMLScriptElement) => unknown) | undefined
100
+ /**
101
+ * 在后续脚本开始加载前执行,用于隔离当前脚本对全局环境的影响。
102
+ */
103
+ isolate?: (() => void) | undefined
104
+ /**
105
+ * 在后续脚本加载完成后执行,用于恢复当前脚本的全局环境。
106
+ */
107
+ deisolate?: (() => void) | undefined
108
+ beforeLoad?: ((context: { options: ScriptLoadOptions }) => void) | undefined
109
+ onLoad?: ((context: { options: ScriptLoadOptions, element: HTMLScriptElement }) => void) | undefined
110
+ afterLoad?: ((context: { options: ScriptLoadOptions, element: HTMLScriptElement }) => void) | undefined
111
+ onError?: ((context: { options: ScriptLoadOptions, error: unknown }) => void) | undefined
112
+ }
113
+
114
+ /**
115
+ * 表示单个脚本的加载结果。
116
+ */
117
+ export interface SingleScriptLoadResult extends ScriptLoadOptions {
118
+ element: HTMLScriptElement
119
+ /**
120
+ * Save result of `collect` function.
121
+ */
122
+ collected?: unknown | undefined
123
+ }
124
+
125
+ /**
126
+ * 表示批量脚本加载的输入选项。
127
+ */
128
+ export interface MultipleScriptLoadOptions {
129
+ /**
130
+ * 待加载脚本列表。字符串会被视为仅包含 `src` 的加载项。
131
+ */
132
+ items: Array<string | ScriptLoadOptions>
133
+ /**
134
+ * 为未单独指定 `onLoad` 的加载项提供统一的成功回调。
135
+ */
136
+ onLoad?: ScriptLoadOptions["onLoad"] | undefined
137
+ /**
138
+ * 为未单独指定 `onError` 的加载项提供统一的失败回调。
139
+ */
140
+ onError?: ScriptLoadOptions["onError"] | undefined
141
+ }
142
+
143
+ /**
144
+ * 表示批量脚本加载的汇总结果。
145
+ */
146
+ export interface MultipleScriptLoadResult {
147
+ success: SingleScriptLoadResult[]
148
+ failure: Array<{ src: string, reason: unknown }>
149
+ }
150
+
151
+ /**
152
+ * 按串行队列将指定脚本加载到当前页面中。
153
+ *
154
+ * 加载之前进行检测:
155
+ * - 如果指定地址(src)的脚本已经存在于页面中,直接返回该脚本作为加载结果。
156
+ * - 如果指定地址(src)的脚本不存在于页面中,则通过插入 `script` 标签的方式将脚本加载到页面中,然后返回加载结果。
157
+ *
158
+ * 以 `script` 标签加载到页面中的脚本全部拥有 JavaScript 环境的完全访问权限,当脚本加载涉及到全局变量的变更时,
159
+ * 并行加载会出现难以预料的错误,所以这里统一采用串行加载。
160
+ * - 加载器会维护一个加载队列,只有先申请的脚本加载完成之后才会加载后申请的脚本,
161
+ * - 脚本加载的选项中可以定义辅助隔离全局变量的函数(isolate、deisolate),
162
+ * 在加载某个脚本之前,加载器会遍历执行其它已加载的脚本的 isolate 函数,加载完成之后,再遍历执行已加载脚本的 deisolate 函数。
163
+ */
164
+ export class ScriptLoader {
165
+ private queue: Promise<void>
166
+ private readonly registry: Map<string, Promise<SingleScriptLoadResult>>
167
+
168
+ private readonly loaded: Map<string, SingleScriptLoadResult>
169
+
170
+ constructor() {
171
+ this.queue = Promise.resolve()
172
+ this.registry = new Map()
173
+
174
+ this.loaded = new Map()
175
+ }
176
+
177
+ protected hasLoaded(src: string): boolean {
178
+ return this.loaded.has(normalizeScriptSrc(src))
179
+ }
180
+
181
+ protected getLoaded(src: string): SingleScriptLoadResult | undefined {
182
+ return this.loaded.get(normalizeScriptSrc(src))
183
+ }
184
+
185
+ private setLoaded(src: string, result: SingleScriptLoadResult): void {
186
+ this.loaded.set(normalizeScriptSrc(src), result)
187
+ }
188
+
189
+ protected removeLoaded(src: string): void {
190
+ this.loaded.delete(normalizeScriptSrc(src))
191
+ }
192
+
193
+ protected isolateAll(): void {
194
+ this.loaded.forEach((item) => {
195
+ if (item.isolate !== undefined) {
196
+ item.isolate()
197
+ }
198
+ })
199
+ }
200
+
201
+ protected deisolateAll(): void {
202
+ this.loaded.forEach((item) => {
203
+ if (item.deisolate !== undefined) {
204
+ item.deisolate()
205
+ }
206
+ })
207
+ }
208
+
209
+ /**
210
+ * 统一组装加载结果,并在需要时执行 `collect`。
211
+ */
212
+ private createResult(options: ScriptLoadOptions, element: HTMLScriptElement): SingleScriptLoadResult {
213
+ const result: SingleScriptLoadResult = { ...options, element }
214
+ if (options.collect !== undefined) {
215
+ result.collected = options.collect(element)
216
+ }
217
+ return result
218
+ }
219
+
220
+ /**
221
+ * 将各种失败原因统一转换为 `Error` 实例,便于上层消费。
222
+ */
223
+ private toError(error: unknown): Error {
224
+ if (typeof error === "object" && error !== null && "message" in error) {
225
+ const { message } = error as { message?: unknown }
226
+ if (typeof message === "string") {
227
+ return new Error(message)
228
+ }
229
+ }
230
+ return new Error(typeof error === "string" ? error : "Unknown script load error.")
231
+ }
232
+
233
+ /**
234
+ * 实际创建并注入 `<script>` 节点,负责处理生命周期回调、超时与错误清理。
235
+ */
236
+ private async injectScript(options: ScriptLoadOptions): Promise<SingleScriptLoadResult> {
237
+ const { src, type, async, timeout, beforeLoad, onLoad, afterLoad, onError } = options
238
+
239
+ return await new Promise<SingleScriptLoadResult>((resolve, reject) => {
240
+ const head = document.head ?? document.querySelector("head")
241
+ if (head === null) {
242
+ throw new Error("Unable to find document head.")
243
+ }
244
+
245
+ const script = document.createElement("script")
246
+ script.src = src
247
+ script.type = type === "module" ? "module" : "text/javascript"
248
+ if (async === true) {
249
+ script.async = true
250
+ }
251
+
252
+ let settled = false
253
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
254
+
255
+ const start = (): void => {
256
+ script.addEventListener("load", handleLoad)
257
+ script.addEventListener("error", handleError)
258
+ head.append(script)
259
+ }
260
+
261
+ const cleanup = (): void => {
262
+ script.removeEventListener("load", handleLoad)
263
+ script.removeEventListener("error", handleError)
264
+ if (timeoutId !== undefined) {
265
+ clearTimeout(timeoutId)
266
+ }
267
+ }
268
+
269
+ const fail = (exception: unknown, shouldRemove: boolean): void => {
270
+ if (settled === true) {
271
+ return
272
+ }
273
+ settled = true
274
+ cleanup()
275
+ if (shouldRemove === true) {
276
+ script.remove()
277
+ }
278
+ const actualError = this.toError(exception)
279
+ onError?.({ options, error: actualError })
280
+ reject(actualError)
281
+ }
282
+
283
+ const handleError = (event: Event): void => {
284
+ fail(event, true)
285
+ }
286
+
287
+ const handleLoad = (): void => {
288
+ try {
289
+ onLoad?.({ options, element: script })
290
+ } catch (exception) {
291
+ fail(exception, false)
292
+ return
293
+ }
294
+
295
+ try {
296
+ afterLoad?.({ options, element: script })
297
+ } catch (exception) {
298
+ fail(exception, false)
299
+ return
300
+ }
301
+
302
+ try {
303
+ const result = this.createResult(options, script)
304
+ cleanup()
305
+ resolve(result)
306
+ } catch (exception) {
307
+ fail(exception, false)
308
+ }
309
+ }
310
+
311
+ if (timeout !== undefined && timeout > 0) {
312
+ timeoutId = setTimeout(() => {
313
+ const exception = new Error(`Script load timeout: ${src}`)
314
+ fail(exception, true)
315
+ }, timeout)
316
+ }
317
+
318
+ try {
319
+ beforeLoad?.({ options })
320
+ } catch (exception) {
321
+ fail(exception, false)
322
+ return
323
+ }
324
+
325
+ start()
326
+ })
327
+ }
328
+
329
+ /**
330
+ * 执行一次脚本加载。
331
+ *
332
+ * 如果页面中已经存在目标脚本,则直接返回现有节点;
333
+ * 否则在隔离已加载脚本的前提下插入新脚本。
334
+ */
335
+ private async executeLoad(options: ScriptLoadOptions): Promise<SingleScriptLoadResult> {
336
+ const { src } = options
337
+
338
+ const existingScript = findScript(src)
339
+ if (existingScript !== null) {
340
+ const result = this.createResult(options, existingScript)
341
+ this.setLoaded(src, result)
342
+ return result
343
+ }
344
+
345
+ this.isolateAll()
346
+ try {
347
+ const result = await this.injectScript(options)
348
+ this.setLoaded(src, result)
349
+ return result
350
+ }
351
+ finally {
352
+ this.deisolateAll()
353
+ }
354
+ }
355
+
356
+ /**
357
+ * 加载单个脚本。
358
+ *
359
+ * - 同一标准化地址只会注册一个进行中的加载任务;重复请求会复用同一个 Promise。
360
+ * - 已完成加载的脚本会缓存在实例内部,后续请求会直接返回缓存结果。
361
+ */
362
+ async load(options: ScriptLoadOptions): Promise<SingleScriptLoadResult> {
363
+ options.src = normalizeScriptSrc(options.src)
364
+ const { src } = options
365
+
366
+ const loaded = this.getLoaded(src)
367
+ if (loaded !== undefined) {
368
+ return loaded
369
+ }
370
+
371
+ const registeredTask = this.registry.get(src)
372
+ if (registeredTask !== undefined) {
373
+ return await registeredTask
374
+ }
375
+
376
+ const task = this.queue.then(async () => {
377
+ return await this.executeLoad(options)
378
+ })
379
+ this.registry.set(src, task)
380
+ this.queue = task.then(() => undefined, () => undefined)
381
+
382
+ void task.finally(() => {
383
+ if (this.registry.get(src) === task) {
384
+ this.registry.delete(src)
385
+ }
386
+ })
387
+
388
+ return await task
389
+ }
390
+
391
+ /**
392
+ * 批量加载多个脚本,并汇总成功与失败结果。
393
+ *
394
+ * 每一项最终仍通过 `load` 进入同一串行队列,因此不会破坏加载顺序约束。
395
+ */
396
+ async loadMultiple(options: MultipleScriptLoadOptions): Promise<MultipleScriptLoadResult> {
397
+ const { items, onLoad, onError } = options
398
+
399
+ const preparedItems = items.map<ScriptLoadOptions>((item) => {
400
+ if (typeof item === "string") {
401
+ return { src: item }
402
+ }
403
+ return {
404
+ src: item.src,
405
+ name: item.name,
406
+ type: item.type,
407
+ async: item.async,
408
+ timeout: item.timeout,
409
+ collect: item.collect,
410
+ isolate: item.isolate,
411
+ deisolate: item.deisolate,
412
+ beforeLoad: item.beforeLoad,
413
+ onLoad: item.onLoad ?? onLoad,
414
+ afterLoad: item.afterLoad,
415
+ onError: item.onError ?? onError,
416
+ }
417
+ })
418
+
419
+ const settledResults = await Promise.allSettled(preparedItems.map(async item => await this.load(item)))
420
+ const result = settledResults.reduce<MultipleScriptLoadResult>((value, item, index) => {
421
+ if (item.status === "fulfilled") {
422
+ value.success.push(item.value)
423
+ }
424
+ else {
425
+ value.failure.push({ src: preparedItems[index]!.src, reason: item.reason })
426
+ }
427
+ return value
428
+ }, { success: [], failure: [] })
429
+
430
+ return result
431
+ }
432
+ }