@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 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
+ }