hanzio 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +832 -219
- package/dist/api-wrapper/builders.d.ts +15 -3
- package/dist/api-wrapper/create-client.d.ts +1 -1
- package/dist/api-wrapper/describe.d.ts +16 -0
- package/dist/api-wrapper/endpoint.d.ts +18 -0
- package/dist/api-wrapper/errors.d.ts +40 -2
- package/dist/api-wrapper/headers.d.ts +2 -1
- package/dist/api-wrapper/index.d.ts +5 -0
- package/dist/api-wrapper/otel.d.ts +44 -0
- package/dist/api-wrapper/paginate.d.ts +12 -0
- package/dist/api-wrapper/redaction.d.ts +2 -0
- package/dist/api-wrapper/shared.d.ts +16 -2
- package/dist/api-wrapper/testing.d.ts +23 -0
- package/dist/api-wrapper/transport.d.ts +12 -10
- package/dist/api-wrapper/types.d.ts +104 -18
- package/dist/array/index.d.ts +50 -4
- package/dist/cache/createCache.d.ts +31 -0
- package/dist/cache/index.d.ts +45 -2
- package/dist/color/contrast.d.ts +40 -0
- package/dist/color/index.d.ts +2 -0
- package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
- package/dist/cool-console-log/index.d.ts +34 -5
- package/dist/crypto/encoding.d.ts +18 -0
- package/dist/crypto/hash.d.ts +33 -0
- package/dist/crypto/index.d.ts +4 -0
- package/dist/crypto/sealer.d.ts +25 -0
- package/dist/crypto/webhook.d.ts +20 -0
- package/dist/date/index.d.ts +46 -2
- package/dist/emitter/index.d.ts +21 -0
- package/dist/error/index.d.ts +12 -0
- package/dist/format/index.d.ts +102 -0
- package/dist/function/index.d.ts +60 -0
- package/dist/guard/index.d.ts +20 -0
- package/dist/i18n/index.d.ts +37 -0
- package/dist/index-0zvtcbzy.js +224 -0
- package/dist/index-181e6vfw.js +29 -0
- package/dist/index-8r2vdzjg.js +87 -0
- package/dist/index-hy7kbb99.js +157 -0
- package/dist/{src/secrets/loaders.js → index-j1ybktbr.js} +608 -405
- package/dist/index-k9yx6bne.js +89 -0
- package/dist/index-kj1phgrx.js +169 -0
- package/dist/index-mnkcfyx8.js +78 -0
- package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
- package/dist/index-rp5pqghz.js +157 -0
- package/dist/index-srt06xd2.js +15 -0
- package/dist/index-wjyb9bsd.js +263 -0
- package/dist/index.d.ts +7 -7
- package/dist/jwt/index.d.ts +50 -2
- package/dist/math/index.d.ts +2 -2
- package/dist/math/linearRegressionTrend.d.ts +14 -1
- package/dist/math/scale.d.ts +8 -0
- package/dist/object/index.d.ts +19 -0
- package/dist/p-queue/index.d.ts +1 -1
- package/dist/promise/index.d.ts +97 -2
- package/dist/secrets/core.d.ts +47 -0
- package/dist/secrets/deadline.d.ts +6 -0
- package/dist/secrets/environment.d.ts +9 -0
- package/dist/secrets/errors.d.ts +11 -0
- package/dist/secrets/index.d.ts +13 -25
- package/dist/secrets/infisical-cli.d.ts +6 -0
- package/dist/secrets/infisical-config.d.ts +17 -0
- package/dist/secrets/infisical-session.d.ts +4 -0
- package/dist/secrets/infisical.d.ts +8 -13
- package/dist/secrets/loaders.d.ts +5 -12
- package/dist/secrets/vite.d.ts +7 -3
- package/dist/secrets/worker-loader.d.ts +9 -0
- package/dist/secrets/worker.d.ts +3 -0
- package/dist/sitemap/index.d.ts +48 -6
- package/dist/src/api-wrapper/index.js +60 -1100
- package/dist/src/api-wrapper/testing.js +20 -0
- package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
- package/dist/src/crypto/index.js +195 -0
- package/dist/src/format/index.js +211 -0
- package/dist/src/i18n/index.js +36 -0
- package/dist/src/index.js +863 -2809
- package/dist/src/jwt/index.js +138 -92
- package/dist/src/p-queue/index.js +9 -437
- package/dist/src/secrets/index.js +261 -1377
- package/dist/src/secrets/vite.js +6 -17
- package/dist/src/secrets/worker.js +12 -0
- package/dist/src/sitemap/index.js +183 -511
- package/dist/src/storage/index.js +119 -0
- package/dist/src/zod/index.js +347 -174
- package/dist/state/index.d.ts +21 -0
- package/dist/storage/index.d.ts +58 -0
- package/dist/string/index.d.ts +75 -2
- package/dist/typedswitch/index.d.ts +37 -26
- package/dist/types/index.d.ts +11 -0
- package/dist/url/index.d.ts +50 -1
- package/dist/zod/index.d.ts +32 -3
- package/dist/zod/introspect.d.ts +19 -0
- package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
- package/dist/zod/zodMinMaxFilter.d.ts +22 -3
- package/dist/zod/zodToTypeString.d.ts +8 -3
- package/package.json +87 -7
- package/src/api-wrapper/actions-impl.ts +11 -72
- package/src/api-wrapper/builders.ts +84 -52
- package/src/api-wrapper/create-client.ts +246 -96
- package/src/api-wrapper/describe.ts +157 -0
- package/src/api-wrapper/endpoint.ts +26 -0
- package/src/api-wrapper/error-reporting.ts +7 -1
- package/src/api-wrapper/errors.ts +117 -2
- package/src/api-wrapper/headers.ts +19 -1
- package/src/api-wrapper/index.ts +5 -0
- package/src/api-wrapper/otel.ts +139 -0
- package/src/api-wrapper/paginate.ts +28 -0
- package/src/api-wrapper/redaction.ts +43 -0
- package/src/api-wrapper/shared.ts +21 -2
- package/src/api-wrapper/testing.ts +48 -0
- package/src/api-wrapper/transport.ts +68 -74
- package/src/api-wrapper/types.ts +186 -18
- package/src/array/index.ts +195 -42
- package/src/cache/createCache.ts +157 -0
- package/src/cache/index.ts +235 -83
- package/src/color/contrast.ts +163 -0
- package/src/color/index.ts +22 -0
- package/src/{math → color}/metricsSeriesColors.ts +47 -49
- package/src/cool-console-log/index.ts +128 -41
- package/src/crypto/encoding.ts +83 -0
- package/src/crypto/hash.ts +170 -0
- package/src/crypto/index.ts +26 -0
- package/src/crypto/sealer.ts +168 -0
- package/src/crypto/webhook.ts +86 -0
- package/src/date/index.ts +109 -34
- package/src/emitter/index.ts +82 -0
- package/src/error/index.ts +63 -0
- package/src/format/index.ts +389 -0
- package/src/function/index.ts +201 -0
- package/src/guard/index.ts +80 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +11 -7
- package/src/jwt/index.ts +199 -125
- package/src/math/index.ts +5 -10
- package/src/math/linearRegressionTrend.ts +33 -8
- package/src/math/scale.ts +17 -0
- package/src/object/index.ts +99 -0
- package/src/p-queue/index.ts +2 -0
- package/src/promise/index.ts +304 -9
- package/src/secrets/core.ts +348 -0
- package/src/secrets/deadline.ts +69 -0
- package/src/secrets/environment.ts +69 -0
- package/src/secrets/errors.ts +29 -0
- package/src/secrets/index.ts +79 -210
- package/src/secrets/infisical-cli.ts +99 -0
- package/src/secrets/infisical-config.ts +70 -0
- package/src/secrets/infisical-session.ts +42 -0
- package/src/secrets/infisical.ts +214 -66
- package/src/secrets/loaders.ts +24 -48
- package/src/secrets/vite.ts +44 -15
- package/src/secrets/worker-loader.ts +56 -0
- package/src/secrets/worker.ts +4 -0
- package/src/sitemap/index.ts +293 -130
- package/src/state/index.ts +133 -81
- package/src/storage/index.ts +187 -0
- package/src/string/index.ts +261 -23
- package/src/typedswitch/index.ts +134 -103
- package/src/types/index.ts +14 -0
- package/src/url/index.ts +146 -1
- package/src/zod/index.ts +105 -9
- package/src/zod/introspect.ts +103 -0
- package/src/zod/jsonToZodSchemaMapper.ts +44 -21
- package/src/zod/zodMinMaxFilter.ts +37 -13
- package/src/zod/zodToTypeString.ts +184 -181
- package/dist/api-wrapper/index.js +0 -250
- package/dist/gzip/index.d.ts +0 -2
- package/dist/gzip/index.js +0 -16
- package/dist/index.js +0 -1823
- package/dist/jwt/index.js +0 -19
- package/dist/sitemap/index.js +0 -552
- package/dist/src/gzip/index.js +0 -16
- package/dist/src/secrets/infisical.js +0 -1194
- package/dist/zod/index.js +0 -210
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type BytesLike,
|
|
3
|
+
decodeBase64Url,
|
|
4
|
+
encodeBase64Url,
|
|
5
|
+
toBytes,
|
|
6
|
+
utf8Decode,
|
|
7
|
+
utf8Encode
|
|
8
|
+
} from './encoding'
|
|
9
|
+
import { getSubtleCrypto, randomBytes } from './hash'
|
|
10
|
+
|
|
11
|
+
export type SealerOptions = {
|
|
12
|
+
/** High-entropy secret (e.g. `APP_SECRET`). The AES key is derived from it with HKDF-SHA-256. */
|
|
13
|
+
secret: string | BytesLike
|
|
14
|
+
/** HKDF salt. Default: empty */
|
|
15
|
+
salt?: string
|
|
16
|
+
/** HKDF info; use a distinct value per purpose. Default: `'hanzio.sealer.aes-256-gcm.v1'` */
|
|
17
|
+
info?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Sealer = {
|
|
21
|
+
seal(plaintext: string): Promise<string>
|
|
22
|
+
open(sealed: string): Promise<string>
|
|
23
|
+
sealJson(value: unknown): Promise<string>
|
|
24
|
+
openJson<T = unknown>(sealed: string): Promise<T>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SealerErrorCode =
|
|
28
|
+
| 'malformed'
|
|
29
|
+
| 'unsupported_version'
|
|
30
|
+
| 'decrypt_failed'
|
|
31
|
+
|
|
32
|
+
const ERROR_MESSAGES: Record<SealerErrorCode, string> = {
|
|
33
|
+
malformed: 'Malformed sealed value',
|
|
34
|
+
unsupported_version: 'Unsupported sealed value version',
|
|
35
|
+
decrypt_failed: 'Could not open sealed value: wrong key or tampered data'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class SealerError extends Error {
|
|
39
|
+
readonly code: SealerErrorCode
|
|
40
|
+
|
|
41
|
+
constructor(code: SealerErrorCode) {
|
|
42
|
+
super(ERROR_MESSAGES[code])
|
|
43
|
+
this.name = 'SealerError'
|
|
44
|
+
this.code = code
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PREFIX = 'enc:v1:'
|
|
49
|
+
const IV_BYTES = 12
|
|
50
|
+
const TAG_BYTES = 16
|
|
51
|
+
const DEFAULT_INFO = 'hanzio.sealer.aes-256-gcm.v1'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* AES-256-GCM sealing with a random 96-bit IV per value. Envelope:
|
|
55
|
+
* `enc:v1:<b64url(iv)>.<b64url(tag)>.<b64url(ciphertext)>`.
|
|
56
|
+
*/
|
|
57
|
+
export function createSealer(options: SealerOptions): Sealer {
|
|
58
|
+
const { secret, salt = '', info = DEFAULT_INFO } = options
|
|
59
|
+
const secretBytes = toBytes(secret)
|
|
60
|
+
if (secretBytes.length === 0) {
|
|
61
|
+
throw new TypeError('Sealer secret must not be empty')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let keyPromise: Promise<CryptoKey> | undefined
|
|
65
|
+
const getKey = () => {
|
|
66
|
+
if (!keyPromise) {
|
|
67
|
+
keyPromise = deriveKey(secretBytes, salt, info)
|
|
68
|
+
keyPromise.catch(() => {
|
|
69
|
+
keyPromise = undefined
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
return keyPromise
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const seal = async (plaintext: string): Promise<string> => {
|
|
76
|
+
const iv = randomBytes(IV_BYTES)
|
|
77
|
+
const encrypted = new Uint8Array(
|
|
78
|
+
await getSubtleCrypto().encrypt(
|
|
79
|
+
{ name: 'AES-GCM', iv },
|
|
80
|
+
await getKey(),
|
|
81
|
+
utf8Encode(plaintext)
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
const tagStart = encrypted.length - TAG_BYTES
|
|
85
|
+
return `${PREFIX}${encodeBase64Url(iv)}.${encodeBase64Url(encrypted.subarray(tagStart))}.${encodeBase64Url(encrypted.subarray(0, tagStart))}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const open = async (sealed: string): Promise<string> => {
|
|
89
|
+
if (typeof sealed !== 'string' || !sealed.startsWith(PREFIX)) {
|
|
90
|
+
const isOtherVersion =
|
|
91
|
+
typeof sealed === 'string' && /^enc:v\d+:/.test(sealed)
|
|
92
|
+
throw new SealerError(
|
|
93
|
+
isOtherVersion ? 'unsupported_version' : 'malformed'
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const parts = sealed.slice(PREFIX.length).split('.')
|
|
98
|
+
let iv: Uint8Array<ArrayBuffer>
|
|
99
|
+
let tag: Uint8Array
|
|
100
|
+
let ciphertext: Uint8Array
|
|
101
|
+
try {
|
|
102
|
+
if (parts.length !== 3) throw new Error()
|
|
103
|
+
iv = decodeBase64Url(parts[0] as string)
|
|
104
|
+
tag = decodeBase64Url(parts[1] as string)
|
|
105
|
+
ciphertext = decodeBase64Url(parts[2] as string)
|
|
106
|
+
} catch (_error) {
|
|
107
|
+
throw new SealerError('malformed')
|
|
108
|
+
}
|
|
109
|
+
if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
|
|
110
|
+
throw new SealerError('malformed')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const encrypted = new Uint8Array(ciphertext.length + TAG_BYTES)
|
|
114
|
+
encrypted.set(ciphertext)
|
|
115
|
+
encrypted.set(tag, ciphertext.length)
|
|
116
|
+
|
|
117
|
+
const key = await getKey()
|
|
118
|
+
let plaintext: ArrayBuffer
|
|
119
|
+
try {
|
|
120
|
+
plaintext = await getSubtleCrypto().decrypt(
|
|
121
|
+
{ name: 'AES-GCM', iv },
|
|
122
|
+
key,
|
|
123
|
+
encrypted
|
|
124
|
+
)
|
|
125
|
+
} catch (_error) {
|
|
126
|
+
throw new SealerError('decrypt_failed')
|
|
127
|
+
}
|
|
128
|
+
return utf8Decode(plaintext)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
seal,
|
|
133
|
+
open,
|
|
134
|
+
async sealJson(value) {
|
|
135
|
+
const json = JSON.stringify(value)
|
|
136
|
+
if (json === undefined) {
|
|
137
|
+
throw new TypeError('sealJson value is not JSON-serializable')
|
|
138
|
+
}
|
|
139
|
+
return seal(json)
|
|
140
|
+
},
|
|
141
|
+
async openJson<T = unknown>(sealed: string) {
|
|
142
|
+
return JSON.parse(await open(sealed)) as T
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function deriveKey(
|
|
148
|
+
secret: Uint8Array<ArrayBuffer>,
|
|
149
|
+
salt: string,
|
|
150
|
+
info: string
|
|
151
|
+
): Promise<CryptoKey> {
|
|
152
|
+
const subtle = getSubtleCrypto()
|
|
153
|
+
const baseKey = await subtle.importKey('raw', secret, 'HKDF', false, [
|
|
154
|
+
'deriveKey'
|
|
155
|
+
])
|
|
156
|
+
return subtle.deriveKey(
|
|
157
|
+
{
|
|
158
|
+
name: 'HKDF',
|
|
159
|
+
hash: 'SHA-256',
|
|
160
|
+
salt: utf8Encode(salt),
|
|
161
|
+
info: utf8Encode(info)
|
|
162
|
+
},
|
|
163
|
+
baseKey,
|
|
164
|
+
{ name: 'AES-GCM', length: 256 },
|
|
165
|
+
false,
|
|
166
|
+
['encrypt', 'decrypt']
|
|
167
|
+
)
|
|
168
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { decodeBase64 } from './encoding'
|
|
2
|
+
import { hmacSign, safeEqual } from './hash'
|
|
3
|
+
|
|
4
|
+
export type VerifySvixSignatureOptions = {
|
|
5
|
+
/** Signing secret, `whsec_<base64>` (the prefix is optional). */
|
|
6
|
+
secret: string
|
|
7
|
+
/** The raw request body, exactly as received. */
|
|
8
|
+
payload: string
|
|
9
|
+
headers: Headers | Record<string, string | undefined>
|
|
10
|
+
/** Max age/skew of the timestamp header, in seconds. Default: 300 */
|
|
11
|
+
toleranceSec?: number
|
|
12
|
+
/** Current time in milliseconds. Default: `Date.now()` */
|
|
13
|
+
now?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TIMESTAMP = /^\d+$/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Verifies a Svix / Standard Webhooks signature (Resend, Clerk, …): base64
|
|
20
|
+
* HMAC-SHA256 over `${id}.${timestamp}.${payload}`, keyed by the base64 body
|
|
21
|
+
* of the `whsec_` secret. Reads `svix-*` headers, falling back to `webhook-*`.
|
|
22
|
+
* Every `v1,` entry in the space-separated signature header is checked, so
|
|
23
|
+
* secret rotation works. Returns `false` for missing/stale headers or a bad
|
|
24
|
+
* signature; throws on an invalid secret.
|
|
25
|
+
*/
|
|
26
|
+
export async function verifySvixSignature(
|
|
27
|
+
options: VerifySvixSignatureOptions
|
|
28
|
+
): Promise<boolean> {
|
|
29
|
+
const {
|
|
30
|
+
secret,
|
|
31
|
+
payload,
|
|
32
|
+
headers,
|
|
33
|
+
toleranceSec = 300,
|
|
34
|
+
now = Date.now()
|
|
35
|
+
} = options
|
|
36
|
+
|
|
37
|
+
let key: Uint8Array
|
|
38
|
+
try {
|
|
39
|
+
key = decodeBase64(secret.startsWith('whsec_') ? secret.slice(6) : secret)
|
|
40
|
+
} catch (_error) {
|
|
41
|
+
throw new TypeError('Invalid webhook secret: expected whsec_<base64>')
|
|
42
|
+
}
|
|
43
|
+
if (key.length === 0) throw new TypeError('Webhook secret must not be empty')
|
|
44
|
+
|
|
45
|
+
const id = getHeader(headers, 'id')
|
|
46
|
+
const timestamp = getHeader(headers, 'timestamp')
|
|
47
|
+
const signatureHeader = getHeader(headers, 'signature')
|
|
48
|
+
if (!id || !timestamp || !signatureHeader) return false
|
|
49
|
+
|
|
50
|
+
if (!TIMESTAMP.test(timestamp)) return false
|
|
51
|
+
if (Math.abs(now / 1000 - Number(timestamp)) > toleranceSec) return false
|
|
52
|
+
|
|
53
|
+
const expected = await hmacSign(key, `${id}.${timestamp}.${payload}`, {
|
|
54
|
+
encoding: 'base64'
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
return signatureHeader.split(' ').some((entry) => {
|
|
58
|
+
const comma = entry.indexOf(',')
|
|
59
|
+
if (comma === -1 || entry.slice(0, comma) !== 'v1') return false
|
|
60
|
+
return safeEqual(entry.slice(comma + 1), expected)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getHeader(
|
|
65
|
+
headers: VerifySvixSignatureOptions['headers'],
|
|
66
|
+
name: 'id' | 'timestamp' | 'signature'
|
|
67
|
+
): string | undefined {
|
|
68
|
+
for (const prefix of ['svix-', 'webhook-']) {
|
|
69
|
+
const value = readHeader(headers, prefix + name)
|
|
70
|
+
if (value) return value
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readHeader(
|
|
76
|
+
headers: VerifySvixSignatureOptions['headers'],
|
|
77
|
+
name: string
|
|
78
|
+
): string | undefined {
|
|
79
|
+
if (typeof headers.get === 'function') {
|
|
80
|
+
return (headers as Headers).get(name) ?? undefined
|
|
81
|
+
}
|
|
82
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
83
|
+
if (key.toLowerCase() === name) return value
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
package/src/date/index.ts
CHANGED
|
@@ -1,54 +1,129 @@
|
|
|
1
|
+
export type PeriodType = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'YEARLY'
|
|
2
|
+
|
|
3
|
+
const MONTHS_PER_PERIOD = { MONTHLY: 1, QUARTERLY: 3, YEARLY: 12 } as const
|
|
4
|
+
|
|
5
|
+
function ymd(date: Date, utc = false): [number, number, number] {
|
|
6
|
+
return utc
|
|
7
|
+
? [date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()]
|
|
8
|
+
: [date.getFullYear(), date.getMonth(), date.getDate()]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Midnight at the start of the given (overflow-normalized) calendar day. */
|
|
12
|
+
function startOfDay(y: number, m: number, d: number, utc = false): Date {
|
|
13
|
+
return utc ? new Date(Date.UTC(y, m, d)) : new Date(y, m, d)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Add `n` periods to `date`, keeping its time of day (wall-clock time in local
|
|
18
|
+
* mode, so DST shifts don't move it). Month-based adds clamp the day of month:
|
|
19
|
+
* Jan 31 + 1 month → Feb 28 (or 29).
|
|
20
|
+
*/
|
|
21
|
+
export function addPeriod(
|
|
22
|
+
date: Date,
|
|
23
|
+
type: PeriodType,
|
|
24
|
+
n: number,
|
|
25
|
+
options: { utc?: boolean } = {}
|
|
26
|
+
): Date {
|
|
27
|
+
const { utc = false } = options
|
|
28
|
+
let [y, m, d] = ymd(date, utc)
|
|
29
|
+
if (type === 'DAILY' || type === 'WEEKLY') {
|
|
30
|
+
d += n * (type === 'WEEKLY' ? 7 : 1)
|
|
31
|
+
} else {
|
|
32
|
+
m += n * MONTHS_PER_PERIOD[type]
|
|
33
|
+
d = Math.min(d, new Date(Date.UTC(y, m + 1, 0)).getUTCDate())
|
|
34
|
+
}
|
|
35
|
+
const out = new Date(date)
|
|
36
|
+
if (utc) out.setUTCFullYear(y, m, d)
|
|
37
|
+
else out.setFullYear(y, m, d)
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Calendar period containing `referenceDate`, shifted by `offset` periods
|
|
43
|
+
* (`-1` = previous, `1` = next).
|
|
44
|
+
*
|
|
45
|
+
* `start` is midnight on the first day and `end` is midnight at the **start**
|
|
46
|
+
* of the last day, so the range is inclusive for date-only comparisons. For
|
|
47
|
+
* timestamps use `timestamp < addPeriod(end, 'DAILY', 1)` as the exclusive bound.
|
|
48
|
+
*
|
|
49
|
+
* Uses the local timezone unless `utc: true`. Weeks start on Monday (ISO)
|
|
50
|
+
* unless `weekStartsOn: 0` (Sunday).
|
|
51
|
+
*/
|
|
52
|
+
export function getPeriod(
|
|
53
|
+
type: PeriodType,
|
|
54
|
+
referenceDate: Date = new Date(),
|
|
55
|
+
offset = 0,
|
|
56
|
+
options: { utc?: boolean; weekStartsOn?: 0 | 1 } = {}
|
|
57
|
+
): { start: Date; end: Date } {
|
|
58
|
+
const { utc = false, weekStartsOn = 1 } = options
|
|
59
|
+
const [y, m, d] = ymd(referenceDate, utc)
|
|
60
|
+
const weekday = utc ? referenceDate.getUTCDay() : referenceDate.getDay()
|
|
61
|
+
const anchor = {
|
|
62
|
+
DAILY: () => startOfDay(y, m, d, utc),
|
|
63
|
+
WEEKLY: () => startOfDay(y, m, d - ((weekday - weekStartsOn + 7) % 7), utc),
|
|
64
|
+
MONTHLY: () => startOfDay(y, m, 1, utc),
|
|
65
|
+
QUARTERLY: () => startOfDay(y, m - (m % 3), 1, utc),
|
|
66
|
+
YEARLY: () => startOfDay(y, 0, 1, utc)
|
|
67
|
+
}[type]()
|
|
68
|
+
const [ey, em, ed] = ymd(addPeriod(anchor, type, offset + 1, { utc }), utc)
|
|
69
|
+
return {
|
|
70
|
+
start: addPeriod(anchor, type, offset, { utc }),
|
|
71
|
+
end: startOfDay(ey, em, ed - 1, utc)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
1
75
|
/**
|
|
2
76
|
* Calculate the previous period date range for a given period type.
|
|
77
|
+
* Same as `getPeriod(periodType, referenceDate, -1)`.
|
|
3
78
|
*/
|
|
4
79
|
export function getPreviousReportPeriod(
|
|
5
|
-
periodType:
|
|
80
|
+
periodType: PeriodType,
|
|
6
81
|
referenceDate: Date = new Date()
|
|
7
82
|
): { start: Date; end: Date } {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
start: new Date(prevQYear, prevQStart, 1),
|
|
26
|
-
end: new Date(prevQYear, prevQStart + 3, 0)
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
case 'YEARLY': {
|
|
30
|
-
return {
|
|
31
|
-
start: new Date(year - 1, 0, 1),
|
|
32
|
-
end: new Date(year - 1, 11, 31)
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
83
|
+
return getPeriod(periodType, referenceDate, -1)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type FormatPeriodLabelOptions = {
|
|
87
|
+
includeDay?: boolean
|
|
88
|
+
/** Default `'en-GB'`. */
|
|
89
|
+
locale?: string
|
|
90
|
+
/** Joins the two formatted dates. Default `' — '`. Ignored when `collapse` is set. */
|
|
91
|
+
separator?: string
|
|
92
|
+
/**
|
|
93
|
+
* Use `Intl.DateTimeFormat#formatRange`, which drops repeated parts and uses
|
|
94
|
+
* the locale's range dash: "January – March 2025" instead of
|
|
95
|
+
* "January 2025 — March 2025".
|
|
96
|
+
*/
|
|
97
|
+
collapse?: boolean
|
|
98
|
+
/** IANA time zone, e.g. `'UTC'` for periods from `getPeriod(..., { utc: true })`. */
|
|
99
|
+
timeZone?: string
|
|
36
100
|
}
|
|
37
101
|
|
|
38
102
|
/**
|
|
39
103
|
* Format a date range as a human-readable period label.
|
|
104
|
+
* The third argument is either `includeDay` or an options object.
|
|
40
105
|
*/
|
|
41
106
|
export function formatPeriodLabel(
|
|
42
107
|
start: Date | string,
|
|
43
108
|
end: Date | string,
|
|
44
|
-
|
|
109
|
+
options: boolean | FormatPeriodLabelOptions = false
|
|
45
110
|
): string {
|
|
111
|
+
const {
|
|
112
|
+
includeDay = false,
|
|
113
|
+
locale = 'en-GB',
|
|
114
|
+
separator = ' — ',
|
|
115
|
+
collapse = false,
|
|
116
|
+
timeZone
|
|
117
|
+
} = typeof options === 'boolean' ? { includeDay: options } : options
|
|
46
118
|
const s = start instanceof Date ? start : new Date(start)
|
|
47
119
|
const e = end instanceof Date ? end : new Date(end)
|
|
48
|
-
const
|
|
120
|
+
const format = new Intl.DateTimeFormat(locale, {
|
|
49
121
|
year: 'numeric',
|
|
50
122
|
month: 'long',
|
|
51
|
-
...(includeDay && { day: 'numeric' })
|
|
52
|
-
|
|
53
|
-
|
|
123
|
+
...(includeDay && { day: 'numeric' }),
|
|
124
|
+
timeZone
|
|
125
|
+
})
|
|
126
|
+
return collapse
|
|
127
|
+
? format.formatRange(s, e)
|
|
128
|
+
: `${format.format(s)}${separator}${format.format(e)}`
|
|
54
129
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export type Listener<P> = (payload: P) => void
|
|
2
|
+
|
|
3
|
+
/** Payload is optional for events typed `void` or `undefined`. */
|
|
4
|
+
export type EmitArgs<P> = undefined extends P ? [payload?: P] : [payload: P]
|
|
5
|
+
|
|
6
|
+
export type Emitter<Events extends Record<string, unknown>> = {
|
|
7
|
+
/** Adds a listener; returns an unsubscribe function. */
|
|
8
|
+
on<K extends keyof Events>(
|
|
9
|
+
event: K,
|
|
10
|
+
listener: Listener<Events[K]>
|
|
11
|
+
): () => void
|
|
12
|
+
/** Like `on`, but removed before its first call. */
|
|
13
|
+
once<K extends keyof Events>(
|
|
14
|
+
event: K,
|
|
15
|
+
listener: Listener<Events[K]>
|
|
16
|
+
): () => void
|
|
17
|
+
off<K extends keyof Events>(event: K, listener: Listener<Events[K]>): void
|
|
18
|
+
/**
|
|
19
|
+
* Calls every listener, even if some throw, then rethrows the error (or an
|
|
20
|
+
* `AggregateError` if several threw). Listeners added during an emit wait
|
|
21
|
+
* for the next one; listeners removed during an emit are skipped.
|
|
22
|
+
*/
|
|
23
|
+
emit<K extends keyof Events>(event: K, ...args: EmitArgs<Events[K]>): void
|
|
24
|
+
/** Removes all listeners for `event`, or for every event. */
|
|
25
|
+
clear(event?: keyof Events): void
|
|
26
|
+
listenerCount(event: keyof Events): number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Typed event emitter. Adding the same listener twice keeps one entry. */
|
|
30
|
+
export function createEmitter<
|
|
31
|
+
Events extends Record<string, unknown>
|
|
32
|
+
>(): Emitter<Events> {
|
|
33
|
+
// listener -> registered with `once`
|
|
34
|
+
const listeners = new Map<keyof Events, Map<Listener<unknown>, boolean>>()
|
|
35
|
+
|
|
36
|
+
const add = (
|
|
37
|
+
event: keyof Events,
|
|
38
|
+
listener: Listener<never>,
|
|
39
|
+
once: boolean
|
|
40
|
+
) => {
|
|
41
|
+
const map = listeners.get(event) ?? new Map()
|
|
42
|
+
listeners.set(event, map)
|
|
43
|
+
map.set(listener as Listener<unknown>, once)
|
|
44
|
+
return () => {
|
|
45
|
+
map.delete(listener as Listener<unknown>)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
on: (event, listener) => add(event, listener, false),
|
|
51
|
+
once: (event, listener) => add(event, listener, true),
|
|
52
|
+
off(event, listener) {
|
|
53
|
+
listeners.get(event)?.delete(listener as Listener<unknown>)
|
|
54
|
+
},
|
|
55
|
+
emit(event, ...[payload]) {
|
|
56
|
+
const map = listeners.get(event)
|
|
57
|
+
if (!map) return
|
|
58
|
+
const errors: unknown[] = []
|
|
59
|
+
for (const [listener, once] of [...map]) {
|
|
60
|
+
if (!map.has(listener)) continue
|
|
61
|
+
if (once) map.delete(listener)
|
|
62
|
+
try {
|
|
63
|
+
listener(payload)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
errors.push(error)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (errors.length === 1) throw errors[0]
|
|
69
|
+
if (errors.length > 1) {
|
|
70
|
+
throw new AggregateError(
|
|
71
|
+
errors,
|
|
72
|
+
`${errors.length} listeners threw for "${String(event)}"`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
clear(event) {
|
|
77
|
+
if (event === undefined) listeners.clear()
|
|
78
|
+
else listeners.delete(event)
|
|
79
|
+
},
|
|
80
|
+
listenerCount: (event) => listeners.get(event)?.size ?? 0
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Thrown by `must` and `invariant` when no custom error is given. */
|
|
2
|
+
export class InvariantError extends Error {
|
|
3
|
+
override name = 'InvariantError'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** An error's message: Error → `message`, string → itself, `{ message: string }` → that, else `String(error)`. */
|
|
7
|
+
export function getErrorMessage(error: unknown, fallback = ''): string {
|
|
8
|
+
let message: string
|
|
9
|
+
if (typeof error === 'string') message = error
|
|
10
|
+
else if (
|
|
11
|
+
typeof error === 'object' &&
|
|
12
|
+
error !== null &&
|
|
13
|
+
'message' in error &&
|
|
14
|
+
typeof error.message === 'string'
|
|
15
|
+
)
|
|
16
|
+
message = error.message
|
|
17
|
+
else {
|
|
18
|
+
try {
|
|
19
|
+
message = String(error)
|
|
20
|
+
} catch {
|
|
21
|
+
// e.g. Object.create(null) has no toString
|
|
22
|
+
message = ''
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return message || fallback
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Returns Errors unchanged, wraps anything else; a `prefix` always wraps as `${prefix}: ${message}` with `cause`. */
|
|
29
|
+
export function toError(error: unknown, prefix?: string): Error {
|
|
30
|
+
if (error instanceof Error && !prefix) return error
|
|
31
|
+
const message = getErrorMessage(error)
|
|
32
|
+
return new Error(prefix ? `${prefix}: ${message}` : message, {
|
|
33
|
+
cause: error
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fail(
|
|
38
|
+
message: string | (() => Error) | undefined,
|
|
39
|
+
fallback: string
|
|
40
|
+
): never {
|
|
41
|
+
throw typeof message === 'function'
|
|
42
|
+
? message()
|
|
43
|
+
: new InvariantError(message ?? fallback)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Returns `value`, throwing when it is null or undefined. Pass a function to throw your own error. */
|
|
47
|
+
export function must<T>(
|
|
48
|
+
value: T,
|
|
49
|
+
message?: string | (() => Error)
|
|
50
|
+
): NonNullable<T> {
|
|
51
|
+
if (value === null || value === undefined) {
|
|
52
|
+
fail(message, 'Expected value to be defined')
|
|
53
|
+
}
|
|
54
|
+
return value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Throws when `condition` is falsy. Pass a function to throw your own error. */
|
|
58
|
+
export function invariant(
|
|
59
|
+
condition: unknown,
|
|
60
|
+
message?: string | (() => Error)
|
|
61
|
+
): asserts condition {
|
|
62
|
+
if (!condition) fail(message, 'Invariant failed')
|
|
63
|
+
}
|