hanzio 1.2.0 → 2.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.
Files changed (155) hide show
  1. package/README.md +786 -239
  2. package/dist/api-wrapper/builders.d.ts +4 -3
  3. package/dist/api-wrapper/create-client.d.ts +1 -1
  4. package/dist/api-wrapper/describe.d.ts +16 -0
  5. package/dist/api-wrapper/endpoint.d.ts +18 -0
  6. package/dist/api-wrapper/errors.d.ts +39 -2
  7. package/dist/api-wrapper/index.d.ts +5 -0
  8. package/dist/api-wrapper/otel.d.ts +44 -0
  9. package/dist/api-wrapper/paginate.d.ts +12 -0
  10. package/dist/api-wrapper/redaction.d.ts +2 -0
  11. package/dist/api-wrapper/shared.d.ts +16 -2
  12. package/dist/api-wrapper/testing.d.ts +23 -0
  13. package/dist/api-wrapper/transport.d.ts +4 -5
  14. package/dist/api-wrapper/types.d.ts +103 -18
  15. package/dist/array/index.d.ts +50 -4
  16. package/dist/cache/createCache.d.ts +31 -0
  17. package/dist/cache/index.d.ts +19 -2
  18. package/dist/color/contrast.d.ts +40 -0
  19. package/dist/color/index.d.ts +2 -0
  20. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  21. package/dist/cool-console-log/index.d.ts +34 -5
  22. package/dist/crypto/encoding.d.ts +18 -0
  23. package/dist/crypto/hash.d.ts +33 -0
  24. package/dist/crypto/index.d.ts +4 -0
  25. package/dist/crypto/sealer.d.ts +25 -0
  26. package/dist/crypto/webhook.d.ts +20 -0
  27. package/dist/date/index.d.ts +46 -2
  28. package/dist/emitter/index.d.ts +21 -0
  29. package/dist/error/index.d.ts +12 -0
  30. package/dist/format/index.d.ts +102 -0
  31. package/dist/function/index.d.ts +60 -0
  32. package/dist/guard/index.d.ts +20 -0
  33. package/dist/i18n/index.d.ts +37 -0
  34. package/dist/index-0zvtcbzy.js +224 -0
  35. package/dist/index-8r2vdzjg.js +87 -0
  36. package/dist/index-hy7kbb99.js +157 -0
  37. package/dist/index-hyt6c56h.js +290 -0
  38. package/dist/{src/secrets/infisical.js → index-j1ybktbr.js} +500 -538
  39. package/dist/index-k9yx6bne.js +89 -0
  40. package/dist/index-kj1phgrx.js +169 -0
  41. package/dist/index-ktynw3vs.js +37 -0
  42. package/dist/index-mnkcfyx8.js +78 -0
  43. package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
  44. package/dist/index-rp5pqghz.js +157 -0
  45. package/dist/index-w882zry3.js +29 -0
  46. package/dist/index.d.ts +7 -7
  47. package/dist/jwt/index.d.ts +50 -2
  48. package/dist/math/index.d.ts +2 -2
  49. package/dist/math/linearRegressionTrend.d.ts +14 -1
  50. package/dist/math/scale.d.ts +8 -0
  51. package/dist/object/index.d.ts +19 -0
  52. package/dist/p-queue/index.d.ts +1 -1
  53. package/dist/promise/index.d.ts +97 -2
  54. package/dist/secrets/core.d.ts +7 -0
  55. package/dist/secrets/index.d.ts +3 -1
  56. package/dist/secrets/public-config.d.ts +7 -0
  57. package/dist/secrets/vite.d.ts +1 -0
  58. package/dist/secrets/worker.d.ts +1 -0
  59. package/dist/sitemap/index.d.ts +48 -6
  60. package/dist/src/api-wrapper/index.js +60 -1130
  61. package/dist/src/api-wrapper/testing.js +20 -0
  62. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  63. package/dist/src/crypto/index.js +195 -0
  64. package/dist/src/format/index.js +211 -0
  65. package/dist/src/i18n/index.js +36 -0
  66. package/dist/src/index.js +739 -3145
  67. package/dist/src/jwt/index.js +138 -92
  68. package/dist/src/p-queue/index.js +9 -437
  69. package/dist/src/secrets/index.js +50 -1428
  70. package/dist/src/secrets/vite.js +10 -39
  71. package/dist/src/secrets/worker.js +14 -273
  72. package/dist/src/sitemap/index.js +183 -511
  73. package/dist/src/storage/index.js +119 -0
  74. package/dist/src/zod/index.js +340 -167
  75. package/dist/state/index.d.ts +21 -0
  76. package/dist/storage/index.d.ts +58 -0
  77. package/dist/string/index.d.ts +75 -2
  78. package/dist/typedswitch/index.d.ts +31 -24
  79. package/dist/types/index.d.ts +11 -0
  80. package/dist/url/index.d.ts +50 -1
  81. package/dist/zod/index.d.ts +32 -3
  82. package/dist/zod/introspect.d.ts +19 -0
  83. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  84. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  85. package/dist/zod/zodToTypeString.d.ts +8 -3
  86. package/package.json +78 -7
  87. package/src/api-wrapper/actions-impl.ts +11 -72
  88. package/src/api-wrapper/builders.ts +32 -25
  89. package/src/api-wrapper/create-client.ts +171 -41
  90. package/src/api-wrapper/describe.ts +157 -0
  91. package/src/api-wrapper/endpoint.ts +26 -0
  92. package/src/api-wrapper/error-reporting.ts +7 -1
  93. package/src/api-wrapper/errors.ts +107 -2
  94. package/src/api-wrapper/headers.ts +11 -6
  95. package/src/api-wrapper/index.ts +5 -0
  96. package/src/api-wrapper/otel.ts +139 -0
  97. package/src/api-wrapper/paginate.ts +28 -0
  98. package/src/api-wrapper/redaction.ts +42 -0
  99. package/src/api-wrapper/shared.ts +21 -2
  100. package/src/api-wrapper/testing.ts +48 -0
  101. package/src/api-wrapper/transport.ts +41 -43
  102. package/src/api-wrapper/types.ts +185 -18
  103. package/src/array/index.ts +195 -42
  104. package/src/cache/createCache.ts +157 -0
  105. package/src/cache/index.ts +132 -147
  106. package/src/color/contrast.ts +163 -0
  107. package/src/color/index.ts +22 -0
  108. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  109. package/src/cool-console-log/index.ts +128 -41
  110. package/src/crypto/encoding.ts +83 -0
  111. package/src/crypto/hash.ts +170 -0
  112. package/src/crypto/index.ts +26 -0
  113. package/src/crypto/sealer.ts +168 -0
  114. package/src/crypto/webhook.ts +86 -0
  115. package/src/date/index.ts +109 -34
  116. package/src/emitter/index.ts +82 -0
  117. package/src/error/index.ts +63 -0
  118. package/src/format/index.ts +389 -0
  119. package/src/function/index.ts +201 -0
  120. package/src/guard/index.ts +80 -0
  121. package/src/i18n/index.ts +125 -0
  122. package/src/index.ts +11 -7
  123. package/src/jwt/index.ts +199 -125
  124. package/src/math/index.ts +5 -10
  125. package/src/math/linearRegressionTrend.ts +33 -8
  126. package/src/math/scale.ts +17 -0
  127. package/src/object/index.ts +99 -0
  128. package/src/p-queue/index.ts +2 -0
  129. package/src/promise/index.ts +304 -9
  130. package/src/secrets/core.ts +72 -16
  131. package/src/secrets/index.ts +16 -4
  132. package/src/secrets/public-config.ts +47 -0
  133. package/src/secrets/vite.ts +2 -0
  134. package/src/secrets/worker.ts +2 -0
  135. package/src/sitemap/index.ts +293 -130
  136. package/src/state/index.ts +133 -81
  137. package/src/storage/index.ts +187 -0
  138. package/src/string/index.ts +261 -23
  139. package/src/typedswitch/index.ts +58 -38
  140. package/src/types/index.ts +14 -0
  141. package/src/url/index.ts +146 -1
  142. package/src/zod/index.ts +105 -9
  143. package/src/zod/introspect.ts +103 -0
  144. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  145. package/src/zod/zodMinMaxFilter.ts +37 -13
  146. package/src/zod/zodToTypeString.ts +184 -181
  147. package/dist/api-wrapper/index.js +0 -250
  148. package/dist/gzip/index.d.ts +0 -2
  149. package/dist/gzip/index.js +0 -16
  150. package/dist/index.js +0 -1823
  151. package/dist/jwt/index.js +0 -19
  152. package/dist/sitemap/index.js +0 -552
  153. package/dist/src/gzip/index.js +0 -16
  154. package/dist/src/secrets/loaders.js +0 -1529
  155. 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: 'MONTHLY' | 'QUARTERLY' | 'YEARLY',
80
+ periodType: PeriodType,
6
81
  referenceDate: Date = new Date()
7
82
  ): { start: Date; end: Date } {
8
- const month = referenceDate.getMonth()
9
- const year = referenceDate.getFullYear()
10
-
11
- switch (periodType) {
12
- case 'MONTHLY': {
13
- const prevMonth = month === 0 ? 11 : month - 1
14
- const prevYear = month === 0 ? year - 1 : year
15
- return {
16
- start: new Date(prevYear, prevMonth, 1),
17
- end: new Date(year, month, 0)
18
- }
19
- }
20
- case 'QUARTERLY': {
21
- const qStart = Math.floor(month / 3) * 3
22
- const prevQStart = qStart === 0 ? 9 : qStart - 3
23
- const prevQYear = qStart === 0 ? year - 1 : year
24
- return {
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
- includeDay = false
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 opts: Intl.DateTimeFormatOptions = {
120
+ const format = new Intl.DateTimeFormat(locale, {
49
121
  year: 'numeric',
50
122
  month: 'long',
51
- ...(includeDay && { day: 'numeric' })
52
- }
53
- return `${s.toLocaleDateString('en-GB', opts)} — ${e.toLocaleDateString('en-GB', opts)}`
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
+ }