@greendrake/util 0.6.0 → 0.8.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/README.md +9 -1
- package/package.json +2 -2
- package/src/main.ts +3 -2
- package/src/misc.ts +20 -0
- package/src/timing.ts +56 -0
- package/src/types.ts +6 -0
package/README.md
CHANGED
|
@@ -35,9 +35,14 @@ off()
|
|
|
35
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
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
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
|
+
- `coalesced(run)` — returns `pull()`: one run at a time, and at most one more queued behind it however many times it is pulled meanwhile, for work whose every request asks the same question (read this again, rebuild that). Each pull settles with the outcome of the run that covers it — the one it started, or the one queued behind a run already going, never the run in progress, which may have begun before whatever prompted the pull. A run that throws synchronously rejects like any other; the slot is freed and handed to the queued run before the finished run's pulls settle. The outcome is the puller's to handle.
|
|
38
39
|
|
|
39
40
|
```ts
|
|
40
|
-
import { Debouncer, Delayed, TimeOutPromise } from '@greendrake/util'
|
|
41
|
+
import { Debouncer, Delayed, TimeOutPromise, coalesced } from '@greendrake/util'
|
|
42
|
+
|
|
43
|
+
const refresh = coalesced(() => loadView())
|
|
44
|
+
source.on('changed', () => void refresh().catch(report))
|
|
45
|
+
const current = await refresh() // never a view read before this call
|
|
41
46
|
|
|
42
47
|
const debouncer = new Debouncer(300)
|
|
43
48
|
const proceed = await debouncer.fire()
|
|
@@ -173,10 +178,13 @@ const { latitude, longitude } = await getUserLocation()
|
|
|
173
178
|
- `dClone(data)` — deep clone via a JSON round-trip (JSON-representable data only).
|
|
174
179
|
- `isEmpty(o, emptyArraysAreNotEmpty?)` — true for `null`, `undefined`, `''`, `NaN`, an empty array (unless the flag is set) and an empty plain object.
|
|
175
180
|
- `arrayRemove(a, el)` — removes the first occurrence of `el` in place.
|
|
181
|
+
- `arraysEqual(a, b, same = Object.is)` — same length and `same` holds element-wise, in order.
|
|
176
182
|
- `capitalise(s)` — upper-cases the first character.
|
|
177
183
|
- `humanSeconds(seconds)` — compact duration, two most significant units: `3661` → `'1h 1m'`.
|
|
178
184
|
- `humanFileSize(bytes, si = false, dp = 1)` — binary units (`KiB`, `MiB`, …) by default, decimal units (`kB`, `MB`, …) with `si`; `dp` decimal places.
|
|
179
185
|
- `fileMime(file)` — `File.type`, falling back to an extension map (`heic`, `heif`, `avif`, `webp`) when the OS reports none, then `application/octet-stream`.
|
|
186
|
+
- `Declared<Map, Tag, Value>` (type) — the union a host's declaration map discriminates, one member per key: the key under `Tag`, its type under `Value` (absent for a key declared `never`); `{ [Tag]: string; [Value]?: unknown }` while the map is undeclared. For a package exporting an empty interface for its host to augment.
|
|
187
|
+
- `configuration<T>(owner)` — a package's boot-time configuration: `{ configure, get }`, where `get` throws under `owner`'s name until the host has called `configure`.
|
|
180
188
|
- `createDeferred<T = void>()` — a `Deferred<T>`: `{ promise, resolve, reject }`.
|
|
181
189
|
|
|
182
190
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@greendrake/util",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Framework-free, Node-safe utilities: typed event emitter, timing, retry, staleness, comparators, fetch helpers, misc",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Eugene Greendrake <eugene@greendrake.nz>",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"prettier": "@greendrake/dev-config/prettier",
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@greendrake/dev-config": "0.
|
|
41
|
+
"@greendrake/dev-config": "0.7.0",
|
|
42
42
|
"typescript": "6.0.3"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/main.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
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'
|
|
2
|
+
export { randomString, dClone, isEmpty, arrayRemove, arraysEqual, capitalise, humanSeconds, humanFileSize, fileMime, configuration, createDeferred, type Deferred } from './misc'
|
|
3
|
+
export { TimeOutPromise, Debouncer, Delayed, coalesced } from './timing'
|
|
4
4
|
export { Retrier, RetryStopped, type RetrierOptions, type RetrierEvents } from './Retrier'
|
|
5
5
|
export { StalenessDetector, type StalenessEvents } from './StalenessDetector'
|
|
6
6
|
export { composeComparators, byProp, byPreset, type Comparator } from './compare'
|
|
7
7
|
export { NetworkError, HttpError, safeFetch, fetchJson } from './net'
|
|
8
8
|
export { RTL_LANGUAGES, isRTLLocale } from './locale'
|
|
9
|
+
export type { Declared } from './types'
|
package/src/misc.ts
CHANGED
|
@@ -13,6 +13,10 @@ export const arrayRemove = <T>(a: T[], el: T): void => {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// Same length, and `same` holds element-wise in order (the equal lengths make
|
|
17
|
+
// every b[i] an element).
|
|
18
|
+
export const arraysEqual = <T>(a: readonly T[], b: readonly T[], same: (x: T, y: T) => boolean = Object.is): boolean => a.length === b.length && a.every((x, i) => same(x, b[i] as T))
|
|
19
|
+
|
|
16
20
|
export const capitalise = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1) : '')
|
|
17
21
|
|
|
18
22
|
// Compact human duration, two most significant units: 3661s → "1h 1m".
|
|
@@ -70,6 +74,22 @@ export interface Deferred<T> {
|
|
|
70
74
|
reject: (reason?: unknown) => void
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
// A package's boot-time configuration: `configure` for the host to call once,
|
|
78
|
+
// and `get` for the package to read it through — throwing, under `owner`'s
|
|
79
|
+
// name, while the host has not.
|
|
80
|
+
export const configuration = <T>(owner: string): { configure: (value: T) => void; get: () => T } => {
|
|
81
|
+
let current: T | undefined
|
|
82
|
+
return {
|
|
83
|
+
configure: value => {
|
|
84
|
+
current = value
|
|
85
|
+
},
|
|
86
|
+
get: () => {
|
|
87
|
+
if (current === undefined) throw new Error(`${owner}: used before it is configured`)
|
|
88
|
+
return current
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
73
93
|
export const createDeferred = <T = void>(): Deferred<T> => {
|
|
74
94
|
let resolve!: (value: T | PromiseLike<T>) => void
|
|
75
95
|
let reject!: (reason?: unknown) => void
|
package/src/timing.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createDeferred, type Deferred } from './misc'
|
|
2
|
+
|
|
1
3
|
export class TimeOutPromise {
|
|
2
4
|
#timeout: ReturnType<typeof setTimeout>
|
|
3
5
|
#reject: (reason?: unknown) => void
|
|
@@ -59,6 +61,60 @@ export class Debouncer {
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
// One run at a time, and at most one more behind it however many times it is
|
|
65
|
+
// pulled meanwhile — for work whose every request asks the same question (read
|
|
66
|
+
// this again, rebuild that), so the answer to the last request is the answer to
|
|
67
|
+
// all of them. A pull settles with the outcome of the run that covers it: the
|
|
68
|
+
// one it started, or the one queued behind a run already going. Never the run
|
|
69
|
+
// in progress, which may have begun before whatever prompted the pull.
|
|
70
|
+
// const refresh = coalesced(() => load())
|
|
71
|
+
// store.on('changed', () => void refresh().catch(report))
|
|
72
|
+
// The outcome is the puller's: a pull nobody handles rejects unhandled like any
|
|
73
|
+
// other promise. Whatever the outcome, the slot is freed.
|
|
74
|
+
export const coalesced = <T>(run: () => T | Promise<T>): (() => Promise<T>) => {
|
|
75
|
+
let running = false
|
|
76
|
+
// The run queued behind the current one, as every pull covered by it holds it.
|
|
77
|
+
let queued: Deferred<T> | null = null
|
|
78
|
+
|
|
79
|
+
// The slot is passed on before the finished run's pulls are settled, so
|
|
80
|
+
// whoever they wake finds the queued run already under way rather than a
|
|
81
|
+
// slot about to change hands.
|
|
82
|
+
const finish = (): void => {
|
|
83
|
+
running = false
|
|
84
|
+
const next = queued
|
|
85
|
+
queued = null
|
|
86
|
+
if (next) start(next)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const start = (covering: Deferred<T>): void => {
|
|
90
|
+
running = true
|
|
91
|
+
// `new Promise(resolve => resolve(run()))` rather than
|
|
92
|
+
// Promise.resolve(run()): it puts the call inside the promise, so a run
|
|
93
|
+
// that throws synchronously is a rejection like any other instead of
|
|
94
|
+
// escaping with the slot still held, which would wedge every later pull.
|
|
95
|
+
void new Promise<T>(resolve => resolve(run())).then(
|
|
96
|
+
value => {
|
|
97
|
+
finish()
|
|
98
|
+
covering.resolve(value)
|
|
99
|
+
},
|
|
100
|
+
(error: unknown) => {
|
|
101
|
+
finish()
|
|
102
|
+
covering.reject(error)
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
if (running) {
|
|
109
|
+
queued ??= createDeferred<T>()
|
|
110
|
+
return queued.promise
|
|
111
|
+
}
|
|
112
|
+
const covering = createDeferred<T>()
|
|
113
|
+
start(covering)
|
|
114
|
+
return covering.promise
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
62
118
|
// Named-timeout registry: scheduling under an existing name replaces the
|
|
63
119
|
// pending timeout. Instance-scoped — each consumer creates its own registry,
|
|
64
120
|
// so equal names in unrelated consumers cannot collide.
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The union a host's declaration map discriminates, one member per key: the
|
|
2
|
+
// key under `Tag`, and the key's type under `Value` — absent for a key declared
|
|
3
|
+
// `never`. Undeclared (an empty map), any string tags it and its value is
|
|
4
|
+
// unknown. The map is an interface a package exports empty for the host to
|
|
5
|
+
// augment, so the package's own code compiles against the undeclared form.
|
|
6
|
+
export type Declared<Map, Tag extends PropertyKey, Value extends PropertyKey> = [keyof Map] extends [never] ? { [P in Tag]: string } & { [P in Value]?: unknown } : { [K in keyof Map]: { [P in Tag]: K } & ([Map[K]] extends [never] ? unknown : { [P in Value]: Map[K] }) }[keyof Map]
|