@greendrake/util 0.1.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 +194 -0
- package/package.json +40 -0
- package/src/Emitter.ts +43 -0
- package/src/Retrier.ts +99 -0
- package/src/StalenessDetector.ts +46 -0
- package/src/compare.ts +34 -0
- package/src/dom.ts +43 -0
- package/src/geo.ts +26 -0
- package/src/locale.ts +11 -0
- package/src/main.ts +10 -0
- package/src/misc.ts +85 -0
- package/src/net.ts +43 -0
- package/src/timing.ts +91 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eugene Greendrake
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# @greendrake/util
|
|
2
|
+
|
|
3
|
+
Framework-free, Node-safe TypeScript utilities: a payload-typed event emitter, timing primitives, retry orchestration, a staleness detector, comparators, `fetch` helpers, locale and browser helpers, and small misc functions. Ships as TypeScript source (`src/main.ts`) with no build step and no `.d.ts`; `sideEffects: false`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @greendrake/util
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`npm install @greendrake/util` works equally.
|
|
12
|
+
|
|
13
|
+
## Emitter
|
|
14
|
+
|
|
15
|
+
`Emitter<E extends EventMap>`, where `EventMap = Record<string, unknown[]>` maps each event name to its argument tuple. Meant for composition (`Retrier` and `StalenessDetector` below extend it). `on`/`once` return the unsubscribe function; `off` detaches a listener from both kinds; `emit` calls the `on` listeners, then drains the `once` set.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Emitter } from '@greendrake/util'
|
|
19
|
+
|
|
20
|
+
interface Events {
|
|
21
|
+
hit: [count: number]
|
|
22
|
+
flat: []
|
|
23
|
+
[key: string]: unknown[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const e = new Emitter<Events>()
|
|
27
|
+
const off = e.on('hit', n => console.log(n))
|
|
28
|
+
e.once('flat', () => {})
|
|
29
|
+
e.emit('hit', 1)
|
|
30
|
+
off()
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Timing
|
|
34
|
+
|
|
35
|
+
- `TimeOutPromise(ms)` — `promise` resolves after `ms`; `isResolved`; `cancel()` clears the timer and rejects the promise (with `undefined`) while it is pending. `TimeOutPromise.wait(ms)` is the one-shot form.
|
|
36
|
+
- `Debouncer(ms)` — `fire(ms?)` waits the delay (the argument overrides the constructed default for that call only) and resolves with a `proceed` predicate that is true only if no later `fire()` or `cancel()` superseded this one. `cancel()` invalidates every in-flight `fire()`.
|
|
37
|
+
- `Delayed` — named-timeout registry: `go(name, fn, ms)` schedules `fn`, replacing a pending timeout of the same name; `cancel(name)`; `cancelAll()`. Instance-scoped, so equal names in unrelated instances cannot collide.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { Debouncer, Delayed, TimeOutPromise } from '@greendrake/util'
|
|
41
|
+
|
|
42
|
+
const debouncer = new Debouncer(300)
|
|
43
|
+
const proceed = await debouncer.fire()
|
|
44
|
+
if (proceed()) {
|
|
45
|
+
// commit
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const delayed = new Delayed()
|
|
49
|
+
delayed.go('save', () => save(), 1000)
|
|
50
|
+
delayed.cancel('save')
|
|
51
|
+
|
|
52
|
+
await TimeOutPromise.wait(50)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Retry
|
|
56
|
+
|
|
57
|
+
`Retrier<T>` (extends `Emitter<RetrierEvents>`): `run()` resolves with the first accepted result, rethrows an error its `shouldRetry` gate declares fatal, and rejects with `RetryStopped` once `stop()` is called (`stopped` getter). Status is observable via events; rendering it is the consumer's concern.
|
|
58
|
+
|
|
59
|
+
`RetrierOptions<T>`:
|
|
60
|
+
|
|
61
|
+
- `attempt: () => Promise<T>`
|
|
62
|
+
- `accept?: (result: T) => boolean` — a resolved attempt whose result is not accepted counts as a failure and schedules a retry.
|
|
63
|
+
- `shouldRetry?: (error: unknown) => boolean` — return false to make the error fatal.
|
|
64
|
+
- `delaySeconds?: number | ((retryIndex: number) => number)` — seconds between attempts, a constant or a function of the 0-based retry index; default 4.
|
|
65
|
+
|
|
66
|
+
`RetrierEvents`: `trying: []` before each attempt; `waiting: [secondsLeft: number]` once per second while waiting, counting down.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { fetchJson, NetworkError, Retrier, RetryStopped } from '@greendrake/util'
|
|
70
|
+
|
|
71
|
+
const retrier = new Retrier({
|
|
72
|
+
attempt: () => fetchJson<Status>('/status'),
|
|
73
|
+
accept: status => status.ready,
|
|
74
|
+
shouldRetry: e => e instanceof NetworkError,
|
|
75
|
+
delaySeconds: i => Math.min(30, 2 ** i)
|
|
76
|
+
})
|
|
77
|
+
retrier.on('waiting', s => console.log(`retry in ${s}s`))
|
|
78
|
+
try {
|
|
79
|
+
const status = await retrier.run()
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (e instanceof RetryStopped) {
|
|
82
|
+
// stop() was called
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Staleness
|
|
88
|
+
|
|
89
|
+
`StalenessDetector(ttlMs)` (extends `Emitter<StalenessEvents>`): a renewable freshness flag for push data streams. `renew()` on every inbound datum keeps `isFresh` true; when no renewal arrives within `ttlMs` the flag drops. `fresh` and `stale` fire only on transitions. `dispose()` clears the pending timer.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { StalenessDetector } from '@greendrake/util'
|
|
93
|
+
|
|
94
|
+
const staleness = new StalenessDetector(10_000)
|
|
95
|
+
staleness.on('stale', () => showOfflineBadge())
|
|
96
|
+
staleness.on('fresh', () => hideOfflineBadge())
|
|
97
|
+
socket.onmessage = () => staleness.renew()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Comparators
|
|
101
|
+
|
|
102
|
+
`Comparator<T> = (a: T, b: T) => number`, for `Array.prototype.sort`.
|
|
103
|
+
|
|
104
|
+
- `byProp(prop, descending?)` — orders by a property using `===`/`>`.
|
|
105
|
+
- `byPreset(prop, preset: string[])` — orders by position in a preset list, case-insensitive; values missing from the preset tie with everything and so defer to the next comparator.
|
|
106
|
+
- `composeComparators(...comparators)` — tie-breaking composition: later comparators apply only where earlier ones tie.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { byPreset, byProp, composeComparators } from '@greendrake/util'
|
|
110
|
+
|
|
111
|
+
interface Item {
|
|
112
|
+
kind: string
|
|
113
|
+
n: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
items.sort(composeComparators<Item>(byPreset('kind', ['grid', 'solar']), byProp('n', true)))
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Fetch
|
|
120
|
+
|
|
121
|
+
- `safeFetch(...args)` — same signature as `fetch`; a rejection (DNS failure, connection refused, TLS/CORS rejection, device offline) is rethrown as `NetworkError`. A non-2xx status resolves normally, as with `fetch`.
|
|
122
|
+
- `fetchJson<T>(url, init?)` — `safeFetch`, then `HttpError` on non-2xx, else the parsed JSON body as `T`.
|
|
123
|
+
- `NetworkError` — `name` `'NetworkError'`; message taken from the wrapped error, which is kept as `cause`. Transport-level failures elsewhere (an abnormal WebSocket close, say) can extend it so one `instanceof` check classifies all of them.
|
|
124
|
+
- `HttpError` — `status`, `statusText`; message `"<status> <statusText>"`.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { fetchJson, HttpError, NetworkError } from '@greendrake/util'
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const user = await fetchJson<User>('/api/me')
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if (e instanceof NetworkError) {
|
|
133
|
+
// transport failure, e.cause holds the browser error
|
|
134
|
+
}
|
|
135
|
+
if (e instanceof HttpError && e.status === 401) {
|
|
136
|
+
// sign in
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Locale
|
|
142
|
+
|
|
143
|
+
- `RTL_LANGUAGES: readonly string[]` — ISO 639-1 subtags of right-to-left scripts: `ar`, `fa`, `he`, `ur`, `ps`, `sd`, `ug`, `yi`.
|
|
144
|
+
- `isRTLLocale(tag)` — true when the tag's language subtag (the text before the first `-`) is in `RTL_LANGUAGES`.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { isRTLLocale } from '@greendrake/util'
|
|
148
|
+
|
|
149
|
+
document.documentElement.dir = isRTLLocale(navigator.language) ? 'rtl' : 'ltr'
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## DOM and geolocation
|
|
153
|
+
|
|
154
|
+
Importable in Node; these touch `document`, `window` and `navigator` only when called.
|
|
155
|
+
|
|
156
|
+
- `getReadyStatePromise()` — resolves when `document.readyState` reaches `'complete'` (immediately if it already has); one shared promise per module instance.
|
|
157
|
+
- `hasFinePointer()` — `matchMedia('(hover: hover) and (pointer: fine)')`: true where the primary input is a mouse or trackpad rather than a finger. Serves as a proxy for "focusing a field will not raise an on-screen keyboard", which no platform exposes directly.
|
|
158
|
+
- `toggleFullscreen(el)` — requests fullscreen for `el`, or exits fullscreen if `el` is already the fullscreen element. Returns false without acting where `document.fullscreenEnabled` is false (iPhone Safari), leaving the caller to pick a substitute.
|
|
159
|
+
- `getUserLocation(): Promise<UserLocation>` — `navigator.geolocation.getCurrentPosition` with `timeout: LOCATION_TIMEOUT_MS` (5000) and `maximumAge: LOCATION_MAX_AGE_MS` (60000); resolves `{ latitude, longitude }`, rejects with the geolocation error, or with `Error('Location unknown')` when the position carries no coords. The two constants are exported so an app that obtains the position from a platform API instead uses the same numbers.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { getReadyStatePromise, getUserLocation, hasFinePointer, toggleFullscreen } from '@greendrake/util'
|
|
163
|
+
|
|
164
|
+
await getReadyStatePromise()
|
|
165
|
+
if (hasFinePointer()) input.focus()
|
|
166
|
+
if (!toggleFullscreen(viewer)) openOverlayInstead()
|
|
167
|
+
const { latitude, longitude } = await getUserLocation()
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Misc
|
|
171
|
+
|
|
172
|
+
- `randomString(prefix?)` — `prefix` followed by a base-36 fragment of `Math.random()`.
|
|
173
|
+
- `dClone(data)` — deep clone via a JSON round-trip (JSON-representable data only).
|
|
174
|
+
- `isEmpty(o, emptyArraysAreNotEmpty?)` — true for `null`, `undefined`, `''`, `NaN`, an empty array (unless the flag is set) and an empty plain object.
|
|
175
|
+
- `arrayRemove(a, el)` — removes the first occurrence of `el` in place.
|
|
176
|
+
- `capitalise(s)` — upper-cases the first character.
|
|
177
|
+
- `humanSeconds(seconds)` — compact duration, two most significant units: `3661` → `'1h 1m'`.
|
|
178
|
+
- `humanFileSize(bytes, si = false, dp = 1)` — binary units (`KiB`, `MiB`, …) by default, decimal units (`kB`, `MB`, …) with `si`; `dp` decimal places.
|
|
179
|
+
- `fileMime(file)` — `File.type`, falling back to an extension map (`heic`, `heif`, `avif`, `webp`) when the OS reports none, then `application/octet-stream`.
|
|
180
|
+
- `createDeferred<T = void>()` — a `Deferred<T>`: `{ promise, resolve, reject }`.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { createDeferred, humanFileSize, humanSeconds, isEmpty } from '@greendrake/util'
|
|
184
|
+
|
|
185
|
+
humanSeconds(3661) // '1h 1m'
|
|
186
|
+
humanFileSize(1536) // '1.5 KiB'
|
|
187
|
+
humanFileSize(1536, true) // '1.5 kB'
|
|
188
|
+
isEmpty({}) // true
|
|
189
|
+
isEmpty([], true) // false
|
|
190
|
+
|
|
191
|
+
const ready = createDeferred<string>()
|
|
192
|
+
ready.resolve('ok')
|
|
193
|
+
await ready.promise
|
|
194
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@greendrake/util",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-free, Node-safe utilities: typed event emitter, timing, retry, staleness, comparators, fetch helpers, misc",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Eugene Greendrake <eugene@greendrake.nz>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/greendrake/lib.git",
|
|
10
|
+
"directory": "util"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/greendrake/lib/tree/main/util#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/greendrake/lib/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/main.ts",
|
|
18
|
+
"types": "src/main.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./src/main.ts",
|
|
22
|
+
"default": "./src/main.ts"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"check": "tsc --noEmit"
|
|
34
|
+
},
|
|
35
|
+
"prettier": "@greendrake/dev-config/prettier",
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@greendrake/dev-config": "0.1.0",
|
|
38
|
+
"typescript": "6.0.3"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/Emitter.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type EventMap = Record<string, unknown[]>
|
|
2
|
+
|
|
3
|
+
type AnyListener = (...args: unknown[]) => void
|
|
4
|
+
|
|
5
|
+
// Payload-typed event emitter used by composition (never inheritance of a god
|
|
6
|
+
// base class). `on` returns the unsubscribe function so callers — Vue
|
|
7
|
+
// components in particular — can detach in one line on teardown.
|
|
8
|
+
export class Emitter<E extends EventMap> {
|
|
9
|
+
#listeners = new Map<keyof E, Set<AnyListener>>()
|
|
10
|
+
#onceListeners = new Map<keyof E, Set<AnyListener>>()
|
|
11
|
+
|
|
12
|
+
on<K extends keyof E>(event: K, listener: (...args: E[K]) => void): () => void {
|
|
13
|
+
return this.#add(this.#listeners, event, listener)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
once<K extends keyof E>(event: K, listener: (...args: E[K]) => void): () => void {
|
|
17
|
+
return this.#add(this.#onceListeners, event, listener)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#add<K extends keyof E>(map: Map<keyof E, Set<AnyListener>>, event: K, listener: (...args: E[K]) => void): () => void {
|
|
21
|
+
let set = map.get(event)
|
|
22
|
+
if (!set) {
|
|
23
|
+
set = new Set()
|
|
24
|
+
map.set(event, set)
|
|
25
|
+
}
|
|
26
|
+
set.add(listener as AnyListener)
|
|
27
|
+
return () => this.off(event, listener)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
off<K extends keyof E>(event: K, listener: (...args: E[K]) => void): void {
|
|
31
|
+
this.#listeners.get(event)?.delete(listener as AnyListener)
|
|
32
|
+
this.#onceListeners.get(event)?.delete(listener as AnyListener)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
emit<K extends keyof E>(event: K, ...args: E[K]): void {
|
|
36
|
+
this.#listeners.get(event)?.forEach(l => l(...args))
|
|
37
|
+
const once = this.#onceListeners.get(event)
|
|
38
|
+
if (once) {
|
|
39
|
+
this.#onceListeners.delete(event)
|
|
40
|
+
once.forEach(l => l(...args))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/Retrier.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Emitter } from './Emitter'
|
|
2
|
+
import { TimeOutPromise } from './timing'
|
|
3
|
+
|
|
4
|
+
export class RetryStopped extends Error {
|
|
5
|
+
constructor() {
|
|
6
|
+
super('Retrier stopped')
|
|
7
|
+
this.name = 'RetryStopped'
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RetrierOptions<T> {
|
|
12
|
+
attempt: () => Promise<T>
|
|
13
|
+
// Result gate: a resolved attempt whose result is not accepted counts as a
|
|
14
|
+
// failure and schedules a retry.
|
|
15
|
+
accept?: (result: T) => boolean
|
|
16
|
+
// Error gate: return false to make the error fatal (rethrown from run()).
|
|
17
|
+
shouldRetry?: (error: unknown) => boolean
|
|
18
|
+
// Seconds to wait between attempts: a constant, or a function of the
|
|
19
|
+
// 0-based retry index for backoff schedules.
|
|
20
|
+
delaySeconds?: number | ((retryIndex: number) => number)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RetrierEvents {
|
|
24
|
+
trying: []
|
|
25
|
+
// Emitted once per second while waiting for the next attempt, counting down.
|
|
26
|
+
waiting: [secondsLeft: number]
|
|
27
|
+
[key: string]: unknown[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_DELAY_SECONDS = 4
|
|
31
|
+
|
|
32
|
+
// Retry orchestration: run() resolves with the first accepted result, rethrows
|
|
33
|
+
// errors its gate declares fatal, and rejects with RetryStopped when stop() is
|
|
34
|
+
// called. Status is observable via typed events — rendering (message strings,
|
|
35
|
+
// reactive refs) is entirely the consumer's concern.
|
|
36
|
+
export class Retrier<T> extends Emitter<RetrierEvents> {
|
|
37
|
+
#options: RetrierOptions<T>
|
|
38
|
+
#stopped = false
|
|
39
|
+
#pendingWait?: TimeOutPromise
|
|
40
|
+
|
|
41
|
+
constructor(options: RetrierOptions<T>) {
|
|
42
|
+
super()
|
|
43
|
+
this.#options = options
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get stopped(): boolean {
|
|
47
|
+
return this.#stopped
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async run(): Promise<T> {
|
|
51
|
+
let retryIndex = 0
|
|
52
|
+
while (true) {
|
|
53
|
+
if (this.#stopped) {
|
|
54
|
+
throw new RetryStopped()
|
|
55
|
+
}
|
|
56
|
+
this.emit('trying')
|
|
57
|
+
try {
|
|
58
|
+
const result = await this.#options.attempt()
|
|
59
|
+
if (this.#options.accept && !this.#options.accept(result)) {
|
|
60
|
+
throw new Error('Unacceptable result')
|
|
61
|
+
}
|
|
62
|
+
return result
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (this.#stopped) {
|
|
65
|
+
throw new RetryStopped()
|
|
66
|
+
}
|
|
67
|
+
if (this.#options.shouldRetry && !this.#options.shouldRetry(e)) {
|
|
68
|
+
throw e
|
|
69
|
+
}
|
|
70
|
+
await this.#wait(retryIndex)
|
|
71
|
+
retryIndex++
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async #wait(retryIndex: number): Promise<void> {
|
|
77
|
+
const { delaySeconds } = this.#options
|
|
78
|
+
const delay = typeof delaySeconds === 'function' ? delaySeconds(retryIndex) : (delaySeconds ?? DEFAULT_DELAY_SECONDS)
|
|
79
|
+
let remainingMs = delay * 1000
|
|
80
|
+
let secondsLeft = Math.ceil(delay)
|
|
81
|
+
while (remainingMs > 0 && !this.#stopped) {
|
|
82
|
+
this.emit('waiting', secondsLeft)
|
|
83
|
+
const chunk = Math.min(1000, remainingMs)
|
|
84
|
+
this.#pendingWait = new TimeOutPromise(chunk)
|
|
85
|
+
try {
|
|
86
|
+
await this.#pendingWait.promise
|
|
87
|
+
} catch {
|
|
88
|
+
// cancelled by stop(); the run() loop re-checks #stopped
|
|
89
|
+
}
|
|
90
|
+
remainingMs -= chunk
|
|
91
|
+
secondsLeft--
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
stop(): void {
|
|
96
|
+
this.#stopped = true
|
|
97
|
+
this.#pendingWait?.cancel()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Emitter } from './Emitter'
|
|
2
|
+
|
|
3
|
+
export interface StalenessEvents {
|
|
4
|
+
fresh: []
|
|
5
|
+
stale: []
|
|
6
|
+
[key: string]: unknown[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Renewable freshness flag for push data streams: renew() on every inbound
|
|
10
|
+
// datum keeps `isFresh` true; when no renewal arrives within ttlMs the flag
|
|
11
|
+
// drops and 'stale' fires. Events fire only on transitions.
|
|
12
|
+
export class StalenessDetector extends Emitter<StalenessEvents> {
|
|
13
|
+
#ttlMs: number
|
|
14
|
+
#timeout?: ReturnType<typeof setTimeout>
|
|
15
|
+
#fresh = false
|
|
16
|
+
|
|
17
|
+
constructor(ttlMs: number) {
|
|
18
|
+
super()
|
|
19
|
+
this.#ttlMs = ttlMs
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get isFresh(): boolean {
|
|
23
|
+
return this.#fresh
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
renew(): void {
|
|
27
|
+
if (this.#timeout) {
|
|
28
|
+
clearTimeout(this.#timeout)
|
|
29
|
+
}
|
|
30
|
+
if (!this.#fresh) {
|
|
31
|
+
this.#fresh = true
|
|
32
|
+
this.emit('fresh')
|
|
33
|
+
}
|
|
34
|
+
this.#timeout = setTimeout(() => {
|
|
35
|
+
this.#fresh = false
|
|
36
|
+
this.emit('stale')
|
|
37
|
+
}, this.#ttlMs)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
dispose(): void {
|
|
41
|
+
if (this.#timeout) {
|
|
42
|
+
clearTimeout(this.#timeout)
|
|
43
|
+
this.#timeout = undefined
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/compare.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type Comparator<T> = (a: T, b: T) => number
|
|
2
|
+
|
|
3
|
+
// Tie-breaking composition: later comparators apply only where earlier ones tie.
|
|
4
|
+
export const composeComparators =
|
|
5
|
+
<T>(...comparators: Comparator<T>[]): Comparator<T> =>
|
|
6
|
+
(a, b) => {
|
|
7
|
+
for (const compare of comparators) {
|
|
8
|
+
const result = compare(a, b)
|
|
9
|
+
if (result !== 0) {
|
|
10
|
+
return result
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return 0
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const byProp = <T, K extends keyof T>(prop: K, descending?: boolean): Comparator<T> => {
|
|
17
|
+
const direction = descending ? -1 : 1
|
|
18
|
+
return (a, b) => {
|
|
19
|
+
const aVal = a[prop]
|
|
20
|
+
const bVal = b[prop]
|
|
21
|
+
return aVal === bVal ? 0 : aVal > bVal ? direction : -direction
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Order by position in a preset list (case-insensitive); values missing from
|
|
26
|
+
// the preset tie with everything (defer to the next comparator).
|
|
27
|
+
export const byPreset = <T, K extends keyof T>(prop: K, preset: string[]): Comparator<T> => {
|
|
28
|
+
const indexByValue = new Map(preset.map((key, i) => [key.toLowerCase(), i]))
|
|
29
|
+
return (a, b) => {
|
|
30
|
+
const iA = indexByValue.get(String(a[prop]).toLowerCase())
|
|
31
|
+
const iB = indexByValue.get(String(b[prop]).toLowerCase())
|
|
32
|
+
return iA === undefined || iB === undefined || iA === iB ? 0 : iA > iB ? 1 : -1
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/dom.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Puts the element in fullscreen, or leaves fullscreen if it is already the one
|
|
2
|
+
// in it. Returns false without doing anything where the browser has no element
|
|
3
|
+
// fullscreen at all — iPhone Safari, whose only fullscreen is the native video
|
|
4
|
+
// player — leaving the caller to decide on a substitute.
|
|
5
|
+
export const toggleFullscreen = (el: Element): boolean => {
|
|
6
|
+
if (!document.fullscreenEnabled) {
|
|
7
|
+
return false
|
|
8
|
+
}
|
|
9
|
+
void (document.fullscreenElement === el ? document.exitFullscreen() : el.requestFullscreen())
|
|
10
|
+
return true
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// True where the primary input is a precise pointer that can hover — a mouse or
|
|
14
|
+
// trackpad, not a finger.
|
|
15
|
+
//
|
|
16
|
+
// Callers use it as a proxy for "focusing a field will not raise an on-screen
|
|
17
|
+
// keyboard", which no platform exposes directly (navigator.virtualKeyboard is
|
|
18
|
+
// Chromium-only and governs overlay policy, not availability). The proxy holds
|
|
19
|
+
// for a touchscreen laptop — physical keyboard, so nothing pops up — and fails
|
|
20
|
+
// only for a touch device driven by a bare mouse with no keyboard attached.
|
|
21
|
+
export const hasFinePointer = (): boolean => window.matchMedia('(hover: hover) and (pointer: fine)').matches
|
|
22
|
+
|
|
23
|
+
let readyStatePromise: Promise<void> | undefined
|
|
24
|
+
|
|
25
|
+
// Resolves when document.readyState reaches 'complete' (immediately if it
|
|
26
|
+
// already has). Browser-only at call time; safe to import in Node.
|
|
27
|
+
export const getReadyStatePromise = (): Promise<void> => {
|
|
28
|
+
if (!readyStatePromise) {
|
|
29
|
+
readyStatePromise =
|
|
30
|
+
document.readyState === 'complete'
|
|
31
|
+
? Promise.resolve()
|
|
32
|
+
: new Promise<void>(resolve => {
|
|
33
|
+
const listener = (): void => {
|
|
34
|
+
if (document.readyState === 'complete') {
|
|
35
|
+
document.removeEventListener('readystatechange', listener)
|
|
36
|
+
resolve()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
document.addEventListener('readystatechange', listener)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
return readyStatePromise
|
|
43
|
+
}
|
package/src/geo.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface UserLocation {
|
|
2
|
+
latitude: number
|
|
3
|
+
longitude: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// How long to wait for a fix, and how stale a cached one may be. One decision,
|
|
7
|
+
// not two that agree by coincidence, and exported because it is not this
|
|
8
|
+
// function's alone: an app that asks a platform for the position instead of the
|
|
9
|
+
// browser is producing the same user-visible behaviour and takes the same
|
|
10
|
+
// numbers. Without a timeout the W3C default is Infinity — devices that never
|
|
11
|
+
// complete a fix (OS location services off, no positioning source) would leave
|
|
12
|
+
// the promise hanging forever. A cached fix both speeds up the common case and
|
|
13
|
+
// avoids spurious timeouts where a fresh one takes longer than the wait.
|
|
14
|
+
export const LOCATION_TIMEOUT_MS = 5000
|
|
15
|
+
export const LOCATION_MAX_AGE_MS = 60000
|
|
16
|
+
|
|
17
|
+
export const getUserLocation = async (): Promise<UserLocation> => {
|
|
18
|
+
const result = await new Promise<GeolocationPosition>((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: LOCATION_TIMEOUT_MS, maximumAge: LOCATION_MAX_AGE_MS }))
|
|
19
|
+
if (!result.coords) {
|
|
20
|
+
throw new Error('Location unknown')
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
latitude: result.coords.latitude,
|
|
24
|
+
longitude: result.coords.longitude
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/locale.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Right-to-left scripts, by ISO 639-1 language subtag. A document's direction
|
|
2
|
+
// follows its locale, and `dir` on <html> is what makes a layout's logical
|
|
3
|
+
// properties resolve the other way round.
|
|
4
|
+
//
|
|
5
|
+
// Here rather than in the app that renders: the direction is decided twice per
|
|
6
|
+
// load — once by the inline bootstrap snippet a build generates, before any
|
|
7
|
+
// bundle parses, and again when the catalogue activates — and the two must not
|
|
8
|
+
// be able to disagree.
|
|
9
|
+
export const RTL_LANGUAGES: readonly string[] = ['ar', 'fa', 'he', 'ur', 'ps', 'sd', 'ug', 'yi']
|
|
10
|
+
|
|
11
|
+
export const isRTLLocale = (tag: string): boolean => RTL_LANGUAGES.includes(tag.split('-')[0])
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { Emitter, type EventMap } from './Emitter'
|
|
2
|
+
export { randomString, dClone, isEmpty, arrayRemove, capitalise, humanSeconds, humanFileSize, fileMime, createDeferred, type Deferred } from './misc'
|
|
3
|
+
export { TimeOutPromise, Debouncer, Delayed } from './timing'
|
|
4
|
+
export { Retrier, RetryStopped, type RetrierOptions, type RetrierEvents } from './Retrier'
|
|
5
|
+
export { StalenessDetector, type StalenessEvents } from './StalenessDetector'
|
|
6
|
+
export { composeComparators, byProp, byPreset, type Comparator } from './compare'
|
|
7
|
+
export { NetworkError, HttpError, safeFetch, fetchJson } from './net'
|
|
8
|
+
export { getReadyStatePromise, hasFinePointer, toggleFullscreen } from './dom'
|
|
9
|
+
export { getUserLocation, LOCATION_TIMEOUT_MS, LOCATION_MAX_AGE_MS, type UserLocation } from './geo'
|
|
10
|
+
export { RTL_LANGUAGES, isRTLLocale } from './locale'
|
package/src/misc.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export const randomString = (prefix?: string): string => `${prefix || ''}${Math.random().toString(36).substring(2)}`
|
|
2
|
+
|
|
3
|
+
export const dClone = <T>(data: T): T => JSON.parse(JSON.stringify(data))
|
|
4
|
+
|
|
5
|
+
const isEmptyObject = (o: unknown): boolean => !!o && (o as object).constructor === Object && Object.keys(o as object).length === 0
|
|
6
|
+
|
|
7
|
+
export const isEmpty = (o: unknown, emptyArraysAreNotEmpty?: boolean): boolean => [null, undefined, '', NaN].includes(o as null) || (Array.isArray(o) && !o.length && !emptyArraysAreNotEmpty) || isEmptyObject(o)
|
|
8
|
+
|
|
9
|
+
export const arrayRemove = <T>(a: T[], el: T): void => {
|
|
10
|
+
const index = a.indexOf(el)
|
|
11
|
+
if (index > -1) {
|
|
12
|
+
a.splice(index, 1)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const capitalise = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1) : '')
|
|
17
|
+
|
|
18
|
+
// Compact human duration, two most significant units: 3661s → "1h 1m".
|
|
19
|
+
export const humanSeconds = (seconds: number): string => {
|
|
20
|
+
const levels: [number, string][] = [
|
|
21
|
+
[Math.floor(seconds / 31536000), 'y'],
|
|
22
|
+
[Math.floor((seconds % 31536000) / 86400), 'd'],
|
|
23
|
+
[Math.floor(((seconds % 31536000) % 86400) / 3600), 'h'],
|
|
24
|
+
[Math.floor((((seconds % 31536000) % 86400) % 3600) / 60), 'm'],
|
|
25
|
+
[(((seconds % 31536000) % 86400) % 3600) % 60, 's']
|
|
26
|
+
]
|
|
27
|
+
const parts: string[] = []
|
|
28
|
+
for (const [value, unit] of levels) {
|
|
29
|
+
if (value === 0) continue
|
|
30
|
+
parts.push(`${value}${unit}`)
|
|
31
|
+
if (parts.length === 2) break
|
|
32
|
+
}
|
|
33
|
+
return parts.join(' ')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const humanFileSize = (bytes: number, si = false, dp = 1): string => {
|
|
37
|
+
const thresh = si ? 1000 : 1024
|
|
38
|
+
if (Math.abs(bytes) < thresh) {
|
|
39
|
+
return bytes + ' B'
|
|
40
|
+
}
|
|
41
|
+
const units = si ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
|
|
42
|
+
let u = -1
|
|
43
|
+
const r = 10 ** dp
|
|
44
|
+
let size = bytes
|
|
45
|
+
do {
|
|
46
|
+
size /= thresh
|
|
47
|
+
++u
|
|
48
|
+
} while (Math.round(Math.abs(size) * r) / r >= thresh && u < units.length - 1)
|
|
49
|
+
return size.toFixed(dp) + ' ' + units[u]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const mimeByExtension: Record<string, string> = {
|
|
53
|
+
heic: 'image/heic',
|
|
54
|
+
heif: 'image/heif',
|
|
55
|
+
avif: 'image/avif',
|
|
56
|
+
webp: 'image/webp'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// File.type is empty when the OS has no registered association (common for
|
|
60
|
+
// HEIC on non-Apple platforms) — fall back to the extension map.
|
|
61
|
+
export const fileMime = (file: File): string => {
|
|
62
|
+
if (file.type) return file.type
|
|
63
|
+
const ext = file.name.split('.').pop()?.toLowerCase()
|
|
64
|
+
return (ext && mimeByExtension[ext]) || 'application/octet-stream'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface Deferred<T> {
|
|
68
|
+
promise: Promise<T>
|
|
69
|
+
resolve: (value: T | PromiseLike<T>) => void
|
|
70
|
+
reject: (reason?: unknown) => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const createDeferred = <T = void>(): Deferred<T> => {
|
|
74
|
+
let resolve!: (value: T | PromiseLike<T>) => void
|
|
75
|
+
let reject!: (reason?: unknown) => void
|
|
76
|
+
const promise = new Promise<T>((res, rej) => {
|
|
77
|
+
resolve = res
|
|
78
|
+
reject = rej
|
|
79
|
+
})
|
|
80
|
+
return {
|
|
81
|
+
promise,
|
|
82
|
+
resolve,
|
|
83
|
+
reject
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/net.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Thrown when a request fails at the network/transport layer — DNS failure,
|
|
2
|
+
// connection refused, TLS/CORS rejection, or the device being offline. fetch()
|
|
3
|
+
// rejects only in these cases; a non-2xx HTTP status resolves normally, so
|
|
4
|
+
// this never masks a server error response. The wrapped browser error is kept
|
|
5
|
+
// as `cause` so its message ("Failed to fetch" / "Load failed") and stack
|
|
6
|
+
// remain available for diagnostics. Transport-level failures elsewhere (e.g.
|
|
7
|
+
// an abnormal WebSocket close) extend this class so consumers classify all of
|
|
8
|
+
// them with one instanceof check.
|
|
9
|
+
export class NetworkError extends Error {
|
|
10
|
+
constructor(cause: unknown) {
|
|
11
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause })
|
|
12
|
+
this.name = 'NetworkError'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class HttpError extends Error {
|
|
17
|
+
readonly status: number
|
|
18
|
+
readonly statusText: string
|
|
19
|
+
|
|
20
|
+
constructor(status: number, statusText: string) {
|
|
21
|
+
super(`${status} ${statusText}`)
|
|
22
|
+
this.name = 'HttpError'
|
|
23
|
+
this.status = status
|
|
24
|
+
this.statusText = statusText
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const safeFetch = async (...args: Parameters<typeof fetch>): Promise<Response> => {
|
|
29
|
+
try {
|
|
30
|
+
return await fetch(...args)
|
|
31
|
+
} catch (e) {
|
|
32
|
+
throw new NetworkError(e)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Typed JSON GET/…: NetworkError on transport failure, HttpError on non-2xx.
|
|
37
|
+
export const fetchJson = async <T>(url: string, init?: RequestInit): Promise<T> => {
|
|
38
|
+
const response = await safeFetch(url, init)
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new HttpError(response.status, response.statusText)
|
|
41
|
+
}
|
|
42
|
+
return (await response.json()) as T
|
|
43
|
+
}
|
package/src/timing.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export class TimeOutPromise {
|
|
2
|
+
#timeout: ReturnType<typeof setTimeout>
|
|
3
|
+
#reject: (reason?: unknown) => void
|
|
4
|
+
isResolved = false
|
|
5
|
+
promise: Promise<void>
|
|
6
|
+
|
|
7
|
+
constructor(ms: number) {
|
|
8
|
+
this.#reject = () => {}
|
|
9
|
+
this.#timeout = setTimeout(() => {}, 0)
|
|
10
|
+
this.promise = new Promise<void>((resolve, reject) => {
|
|
11
|
+
this.#reject = reject
|
|
12
|
+
this.#timeout = setTimeout(() => {
|
|
13
|
+
resolve()
|
|
14
|
+
this.isResolved = true
|
|
15
|
+
}, ms)
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
cancel(): void {
|
|
20
|
+
if (!this.isResolved) {
|
|
21
|
+
clearTimeout(this.#timeout)
|
|
22
|
+
this.#reject()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static async wait(ms: number): Promise<void> {
|
|
27
|
+
await new this(ms).promise
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Counter/proceed debouncing: `fire()` waits the delay and returns a `proceed`
|
|
32
|
+
// predicate that is true only if no later fire (or cancel) superseded this one.
|
|
33
|
+
// const proceed = await debouncer.fire()
|
|
34
|
+
// if (proceed()) { ...commit... }
|
|
35
|
+
export class Debouncer {
|
|
36
|
+
#ms: number
|
|
37
|
+
#counter = 0
|
|
38
|
+
|
|
39
|
+
constructor(ms: number) {
|
|
40
|
+
this.#ms = ms
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ms overrides the constructed default for this call only — lets a caller
|
|
44
|
+
// vary the delay per fire (e.g. a shorter debounce when the action is cheap).
|
|
45
|
+
async fire(ms?: number): Promise<() => boolean> {
|
|
46
|
+
this.#counter++
|
|
47
|
+
const c = this.#counter
|
|
48
|
+
const delay = ms ?? this.#ms
|
|
49
|
+
if (delay) {
|
|
50
|
+
await TimeOutPromise.wait(delay)
|
|
51
|
+
}
|
|
52
|
+
return () => c === this.#counter
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Invalidate any in-flight fire(): bumping the counter makes every pending
|
|
56
|
+
// proceed() return false, so an action debounced before cancel() never commits.
|
|
57
|
+
cancel(): void {
|
|
58
|
+
this.#counter++
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Named-timeout registry: scheduling under an existing name replaces the
|
|
63
|
+
// pending timeout. Instance-scoped — each consumer creates its own registry,
|
|
64
|
+
// so equal names in unrelated consumers cannot collide.
|
|
65
|
+
export class Delayed {
|
|
66
|
+
#timeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
|
67
|
+
|
|
68
|
+
go(name: string, fn: () => void, ms: number): void {
|
|
69
|
+
this.cancel(name)
|
|
70
|
+
this.#timeouts.set(
|
|
71
|
+
name,
|
|
72
|
+
setTimeout(() => {
|
|
73
|
+
this.#timeouts.delete(name)
|
|
74
|
+
fn()
|
|
75
|
+
}, ms)
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
cancel(name: string): void {
|
|
80
|
+
const timeout = this.#timeouts.get(name)
|
|
81
|
+
if (timeout !== undefined) {
|
|
82
|
+
clearTimeout(timeout)
|
|
83
|
+
this.#timeouts.delete(name)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
cancelAll(): void {
|
|
88
|
+
this.#timeouts.forEach(t => clearTimeout(t))
|
|
89
|
+
this.#timeouts.clear()
|
|
90
|
+
}
|
|
91
|
+
}
|