@bitstillery/mithril 3.0.0-AA

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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/api/mount-redraw.ts +173 -0
  4. package/api/router-server.ts +88 -0
  5. package/api/router.ts +670 -0
  6. package/browser.ts +10 -0
  7. package/htm.ts +7 -0
  8. package/hyperscript.ts +8 -0
  9. package/index.ts +134 -0
  10. package/jsx.d.ts +16 -0
  11. package/mount.ts +10 -0
  12. package/package.json +66 -0
  13. package/pathname/build.ts +40 -0
  14. package/pathname/compileTemplate.ts +49 -0
  15. package/pathname/parse.ts +19 -0
  16. package/querystring/build.ts +22 -0
  17. package/querystring/parse.ts +50 -0
  18. package/redraw.ts +10 -0
  19. package/render/cachedAttrsIsStaticMap.ts +10 -0
  20. package/render/delayedRemoval.ts +1 -0
  21. package/render/domFor.ts +23 -0
  22. package/render/emptyAttrs.ts +2 -0
  23. package/render/fragment.ts +11 -0
  24. package/render/hyperscript.ts +125 -0
  25. package/render/hyperscriptVnode.ts +21 -0
  26. package/render/render.ts +1307 -0
  27. package/render/renderToString.ts +494 -0
  28. package/render/ssrState.ts +366 -0
  29. package/render/trust.ts +6 -0
  30. package/render/vnode.ts +131 -0
  31. package/route.ts +11 -0
  32. package/server/logger.ts +223 -0
  33. package/server/session.ts +87 -0
  34. package/server/ssr.ts +205 -0
  35. package/server/ssrLogger.ts +13 -0
  36. package/server.ts +101 -0
  37. package/signal.ts +320 -0
  38. package/ssrContext.ts +87 -0
  39. package/state.ts +1026 -0
  40. package/store.ts +457 -0
  41. package/util/censor.ts +46 -0
  42. package/util/decodeURIComponentSafe.ts +34 -0
  43. package/util/hasOwn.ts +2 -0
  44. package/util/next_tick.ts +45 -0
  45. package/util/ssr.ts +385 -0
  46. package/util/uri.ts +126 -0
  47. package/version.ts +3 -0
package/store.ts ADDED
@@ -0,0 +1,457 @@
1
+ import {state, State, updateStateRegistry} from './state'
2
+ import {serializeStore, deserializeStore} from './render/ssrState'
3
+ import {ComputedSignal} from './signal'
4
+
5
+ // Helper function to restore computed properties (same as in ssrState.ts)
6
+ function restoreComputedProperties(state: State<any>, initial: any): void {
7
+ if (!initial || typeof initial !== 'object') {
8
+ return
9
+ }
10
+
11
+ function is_object(v: any): boolean {
12
+ return v && typeof v === 'object' && !Array.isArray(v)
13
+ }
14
+
15
+ function restore(obj: any, target: any, prefix: string = ''): void {
16
+ for (const key in obj) {
17
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
18
+ const value = obj[key]
19
+
20
+ if (typeof value === 'function') {
21
+ // Set function property - state proxy will convert to ComputedSignal
22
+ const keys = prefix ? prefix.split('.').filter((k) => k) : []
23
+ let targetState = target
24
+ for (let i = 0; i < keys.length; i++) {
25
+ if (!targetState || !targetState[keys[i]]) {
26
+ // Nested state doesn't exist yet, skip
27
+ return
28
+ }
29
+ targetState = targetState[keys[i]]
30
+ }
31
+ if (targetState) {
32
+ // Clear any existing signal in signalMap so function is re-initialized as ComputedSignal
33
+ if (typeof targetState === 'object' && (targetState as any).__isState) {
34
+ const signalMap = (targetState as any).__signalMap
35
+ if (signalMap && signalMap instanceof Map) {
36
+ signalMap.delete(key)
37
+ }
38
+ }
39
+ targetState[key] = value
40
+ }
41
+ } else if (is_object(value)) {
42
+ // Recursively restore nested computed properties
43
+ const nestedPrefix = prefix ? `${prefix}.${key}` : key
44
+ restore(value, target, nestedPrefix)
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ restore(initial, state)
51
+ }
52
+
53
+ // Utility functions for Store class
54
+ function isState(value: any): boolean {
55
+ return value && typeof value === 'object' && (value as any).__isState === true
56
+ }
57
+
58
+ function is_object(v: any): boolean {
59
+ return v && typeof v === 'object' && !Array.isArray(v)
60
+ }
61
+
62
+ function copy_object<T>(obj: T): T {
63
+ return JSON.parse(JSON.stringify(obj))
64
+ }
65
+
66
+ /**
67
+ * Deep copy object while preserving functions (computed properties)
68
+ * Used for merging templates that may contain computed properties
69
+ */
70
+ function copy_object_preserve_functions<T>(obj: T): T {
71
+ if (obj === null || typeof obj !== 'object') {
72
+ return obj
73
+ }
74
+
75
+ if (Array.isArray(obj)) {
76
+ return obj.map((item) => copy_object_preserve_functions(item)) as T
77
+ }
78
+
79
+ if (typeof obj === 'function') {
80
+ return obj as T
81
+ }
82
+
83
+ const result: any = {}
84
+ for (const key in obj) {
85
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
86
+ const value = (obj as any)[key]
87
+ if (typeof value === 'function') {
88
+ // Preserve functions (computed properties)
89
+ result[key] = value
90
+ } else {
91
+ // Deep copy other values
92
+ result[key] = copy_object_preserve_functions(value)
93
+ }
94
+ }
95
+ }
96
+ return result as T
97
+ }
98
+
99
+ function merge_deep(target: any, ...sources: any[]): any {
100
+ if (!sources.length) return target
101
+ const source = sources.shift()
102
+
103
+ if (is_object(target) && is_object(source)) {
104
+ for (const key in source) {
105
+ if (Array.isArray(source[key]) && Array.isArray(target[key])) {
106
+ // Splice the contents of source[key] into target[key]
107
+ target[key].splice(0, target[key].length, ...source[key])
108
+ } else if (is_object(source[key])) {
109
+ if (!target[key]) Object.assign(target, {[key]: {}})
110
+ merge_deep(target[key], source[key])
111
+ } else {
112
+ Object.assign(target, {[key]: source[key]})
113
+ }
114
+ }
115
+ }
116
+
117
+ return merge_deep(target, ...sources)
118
+ }
119
+
120
+ const DEFAULT_LOOKUP_VERIFY_INTERVAL = 1000 * 10 // 10 seconds
121
+ const DEFAULT_LOOKUP_TTL = 1000 * 60 * 60 * 24 // 1 day
122
+
123
+ // Counter for generating unique store instance names
124
+ let storeInstanceCounter = 0
125
+
126
+ /**
127
+ * Store class - wraps state() with persistence functionality
128
+ * Provides load/save/blueprint methods for localStorage/sessionStorage persistence
129
+ *
130
+ * State types:
131
+ * - saved: localStorage (survives browser restarts)
132
+ * - temporary: not persisted (resets on reload)
133
+ * - tab: sessionStorage (survives page reloads, clears when tab closes)
134
+ * - session: server-side session storage (optional, off by default; requires backend, hydrated via SSR)
135
+ */
136
+ export class Store<T extends Record<string, any> = Record<string, any>> {
137
+ private stateInstance: State<T>
138
+ private templates = {
139
+ saved: {} as Partial<T>,
140
+ temporary: {} as Partial<T>,
141
+ tab: {} as Partial<T>,
142
+ session: {} as Partial<T>,
143
+ }
144
+ private lookup_verify_interval: number | null = null
145
+ private lookup_ttl: number
146
+ private computedPropertiesSetup?: () => void
147
+
148
+ constructor(options: {lookup_ttl?: number} = {lookup_ttl: DEFAULT_LOOKUP_TTL}) {
149
+ this.lookup_ttl = options.lookup_ttl || DEFAULT_LOOKUP_TTL
150
+ // Initialize with empty state, will be loaded later (ADR-0013: defer computeds until ready() is called)
151
+ const instanceName = `store.instance.${storeInstanceCounter++}`
152
+ this.stateInstance = state({} as T, instanceName, {deferComputed: true})
153
+
154
+ if (typeof window !== 'undefined' && !this.lookup_verify_interval) {
155
+ // Check every 10 seconds for outdated lookup paths. This is
156
+ // to keep the lookup store clean.
157
+ this.lookup_verify_interval = window.setInterval(() => {
158
+ this.clean_lookup()
159
+ }, DEFAULT_LOOKUP_VERIFY_INTERVAL)
160
+ }
161
+ }
162
+
163
+ get state(): State<T> {
164
+ return this.stateInstance
165
+ }
166
+
167
+ /**
168
+ * Allow evaluation of computed properties (ADR-0013). Call after load() and app setup
169
+ * (e.g. after $s, context, or route are ready) so computeds that depend on them can run.
170
+ */
171
+ ready(): void {
172
+ ;(this.stateInstance as any).allowComputed?.()
173
+ }
174
+
175
+ /**
176
+ * Merge deep on object `state`, but only the key/values in `blueprint`.
177
+ */
178
+ blueprint(state: T, blueprint: Partial<T>): Partial<T> {
179
+ if (state == null || typeof state !== 'object') {
180
+ return {} as Partial<T>
181
+ }
182
+ const result: any = {}
183
+ for (const key of Object.keys(blueprint)) {
184
+ if (Object.prototype.hasOwnProperty.call(state, key)) {
185
+ const blueprintValue = (blueprint as any)[key]
186
+ const stateValue = (state as any)[key]
187
+ if (!Array.isArray(blueprintValue) && blueprintValue !== null && is_object(blueprintValue)) {
188
+ // (!) Convention: The contents of a state key with the name 'lookup' is
189
+ // always one-one copied from the state, instead of being
190
+ // blueprinted per-key. This is to accomodate key/value
191
+ // lookups, without having to define each key in the
192
+ // state's persistent section.
193
+ if (key === 'lookup') {
194
+ result[key] = copy_object(stateValue)
195
+ } else {
196
+ result[key] = this.blueprint(stateValue, blueprintValue)
197
+ }
198
+ } else {
199
+ result[key] = stateValue
200
+ }
201
+ }
202
+ }
203
+ return result as Partial<T>
204
+ }
205
+
206
+ clean_lookup() {
207
+ // Skip during SSR (server-side rendering in Bun)
208
+ // Check both window existence and __SSR_MODE__ flag for safety
209
+ if (typeof window === 'undefined' || globalThis.__SSR_MODE__) {
210
+ return
211
+ }
212
+
213
+ let store_modified = false
214
+ const lookup = (this.stateInstance as any).lookup
215
+ if (!lookup) return
216
+
217
+ // Build a new lookup object with only valid entries
218
+ const newLookup: Record<string, any> = {}
219
+ // Get keys first to avoid iteration issues when deleting
220
+ // Filter out $ prefix keys added by reactive proxy
221
+ const keys = Object.keys(lookup).filter((k) => !k.startsWith('$') && k !== '__isState' && k !== '__signalMap')
222
+ for (const key of keys) {
223
+ const value = lookup[key]
224
+ // Previously stored values may not have a modified timestamp.
225
+ // Set it now, and let it be cleaned up after the interval.
226
+ if (!value || !is_object(value)) {
227
+ // Skip invalid entries
228
+ store_modified = true
229
+ } else {
230
+ if (!(value as any).modified) {
231
+ ;(value as any).modified = Date.now()
232
+ store_modified = true
233
+ }
234
+ if ((value as any).modified >= Date.now() - this.lookup_ttl) {
235
+ // Keep entries that are not expired
236
+ newLookup[key] = value
237
+ } else {
238
+ console.info(`[store] removing outdated lookup path: ${key}`)
239
+ store_modified = true
240
+ }
241
+ }
242
+ }
243
+ if (store_modified) {
244
+ // Replace lookup with cleaned version
245
+ ;(this.stateInstance as any).lookup = newLookup
246
+ this.save()
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Get key from local storage. If the item does not exist or
252
+ * cannot be retrieved, the default "{}" is returned.
253
+ */
254
+ get(key: string): string {
255
+ if (typeof window === 'undefined') return '{}'
256
+ try {
257
+ return window.localStorage.getItem(key) || '{}'
258
+ } catch {
259
+ return '{}'
260
+ }
261
+ }
262
+
263
+ get_tab_storage(key: string): string {
264
+ if (typeof window === 'undefined') return '{}'
265
+ try {
266
+ return window.sessionStorage.getItem(key) || '{}'
267
+ } catch {
268
+ return '{}'
269
+ }
270
+ }
271
+
272
+ load(saved: Partial<T>, temporary: Partial<T>, tab: Partial<T> = {} as Partial<T>, session: Partial<T> = {} as Partial<T>) {
273
+ const restored_state = {
274
+ tab: this.get_tab_storage('store'),
275
+ store: this.get('store'),
276
+ }
277
+
278
+ this.templates = {
279
+ saved,
280
+ temporary,
281
+ tab,
282
+ session,
283
+ }
284
+
285
+ try {
286
+ restored_state.store = JSON.parse(restored_state.store)
287
+ restored_state.tab = JSON.parse(restored_state.tab)
288
+ } catch (err) {
289
+ console.log(`[store] failed to parse store/tab: ${err}`)
290
+ }
291
+
292
+ const store_state = merge_deep(copy_object(this.templates.saved), copy_object(restored_state.store ?? {}))
293
+ // override with previous identity for a better version bump experience.
294
+ if (restored_state.store && typeof restored_state.store === 'object' && 'identity' in (restored_state.store as object)) {
295
+ store_state.identity = (restored_state.store as any).identity
296
+ }
297
+ let tab_state
298
+
299
+ if (!restored_state.tab) {
300
+ console.log('[store] loading tab state from local store')
301
+ tab_state = merge_deep(copy_object(this.templates.tab), store_state.tab)
302
+ } else {
303
+ console.log('[store] restoring existing tab state')
304
+ tab_state = merge_deep(copy_object(this.templates.tab), copy_object(restored_state.tab))
305
+ }
306
+
307
+ // Always merge tab_state into store_state to ensure it's included in final_state
308
+ merge_deep(store_state, {tab: tab_state})
309
+
310
+ // Merge temporary into store_state
311
+ // Note: copy_object removes functions, but temporary data shouldn't have functions anyway
312
+ // (computed properties are handled separately via mergedInitial)
313
+ const temp_state = merge_deep(store_state, copy_object(temporary))
314
+
315
+ // Merge session into temp_state to create final_state
316
+ // Session state comes from server (SSR), not localStorage
317
+ const final_state = merge_deep(temp_state, copy_object(session))
318
+
319
+ // Merge templates (including computed properties) into "merged initial state"
320
+ // This will be stored in registry so computed properties can be automatically restored
321
+ // Use copy_object_preserve_functions to deep copy while preserving functions
322
+ // Note: tab template structure needs to match final_state structure (nested under 'tab')
323
+ const mergedInitialSaved = copy_object_preserve_functions(saved)
324
+ const mergedInitialTemporary = copy_object_preserve_functions(temporary)
325
+ // Tab template is merged into store_state.tab, so wrap it in {tab: ...}
326
+ const mergedInitialTab = tab && Object.keys(tab).length > 0 ? {tab: copy_object_preserve_functions(tab)} : {}
327
+ // Session template is merged directly (no nesting needed, structure matches final_state)
328
+ const mergedInitialSession = copy_object_preserve_functions(session)
329
+ const mergedInitial = merge_deep(mergedInitialSaved, mergedInitialTemporary, mergedInitialTab, mergedInitialSession)
330
+
331
+ // Update registry entry to store merged templates as "initial" state
332
+ // This allows deserializeAllStates() to automatically restore computed properties
333
+ updateStateRegistry(this.stateInstance, mergedInitial)
334
+
335
+ // Use deserializeStore() instead of custom updateState()
336
+ // This ensures consistency with SSR deserialization mechanism
337
+ deserializeStore(this.stateInstance, final_state)
338
+
339
+ // Restore computed properties from merged templates
340
+ // This ensures computed properties are available immediately after load()
341
+ // Note: mergedInitial contains all templates (saved, temporary, tab, session) with computed properties
342
+ restoreComputedProperties(this.stateInstance, mergedInitial)
343
+
344
+ // Note: setupComputedProperties() callback is no longer needed, but kept for backward compatibility
345
+ if (this.computedPropertiesSetup) {
346
+ this.computedPropertiesSetup()
347
+ }
348
+ // ADR-0013: open deferred-computed gate so computeds can run after load
349
+ this.ready()
350
+ }
351
+
352
+ /**
353
+ * Register a function to set up computed properties after each load()
354
+ * This ensures computed properties are always available, even after reloading from storage
355
+ */
356
+ setupComputedProperties(setupFn: () => void): void {
357
+ this.computedPropertiesSetup = setupFn
358
+ // Call immediately to set up computed properties for current state
359
+ setupFn()
360
+ }
361
+
362
+ /**
363
+ * Persist state to storage. When no options are passed, saves to localStorage (saved) and
364
+ * sessionStorage (tab). Session (server-side) is off by default; pass { session: true } to persist it.
365
+ */
366
+ async save(options?: {saved?: boolean; tab?: boolean; session?: boolean}): Promise<void> {
367
+ // Skip saving during SSR (server-side rendering in Bun)
368
+ // On the server, there's no localStorage/sessionStorage and no need to persist state
369
+ if (globalThis.__SSR_MODE__) {
370
+ return
371
+ }
372
+
373
+ // Default to saving saved/tab when no options; session is opt-in (off by default)
374
+ const saveSaved = options?.saved ?? options === undefined
375
+ const saveTab = options?.tab ?? options === undefined
376
+ const saveSession = options?.session === true
377
+
378
+ // Use SSR serialization which properly handles State objects and skips ComputedSignal properties
379
+ // This is the same mechanism used for SSR, ensuring consistency
380
+ const statePlain = serializeStore(this.stateInstance)
381
+
382
+ // Save to localStorage (saved state)
383
+ if (saveSaved && this.templates.saved) {
384
+ this.set('store', this.blueprint(statePlain, copy_object(this.templates.saved)))
385
+ }
386
+
387
+ // Save to sessionStorage (tab state)
388
+ if (saveTab && this.templates.tab) {
389
+ const tabState = (this.stateInstance as any).tab
390
+ if (tabState) {
391
+ // Get the tab template - unwrap if it's nested under a 'tab' key
392
+ // The template might be: { tab: { sessionId, ... } } or { sessionId, ... }
393
+ // The state is always: { sessionId, ... }
394
+ const tabTemplate = (this.templates.tab as any).tab || this.templates.tab
395
+
396
+ // Check if tab is a State object
397
+ if (isState(tabState)) {
398
+ const tabPlain = serializeStore(tabState)
399
+ // blueprint expects both arguments to have the same structure
400
+ this.set_tab('store', this.blueprint(tabPlain, copy_object(tabTemplate)))
401
+ } else {
402
+ // Plain object tab
403
+ this.set_tab('store', this.blueprint(tabState, copy_object(tabTemplate)))
404
+ }
405
+ } else {
406
+ // No tab state - save empty tab based on template structure
407
+ const tabTemplate = (this.templates.tab as any).tab || this.templates.tab
408
+ if (tabTemplate && Object.keys(tabTemplate).length > 0) {
409
+ this.set_tab('store', this.blueprint({} as any, copy_object(tabTemplate)))
410
+ } else {
411
+ this.set_tab('store', {})
412
+ }
413
+ }
414
+ }
415
+
416
+ // Save to session API (session state) - async by nature
417
+ // Only save session on client side (not during SSR)
418
+ if (
419
+ saveSession &&
420
+ this.templates.session &&
421
+ Object.keys(this.templates.session).length > 0 &&
422
+ typeof window !== 'undefined'
423
+ ) {
424
+ const sessionData = this.blueprint(statePlain, copy_object(this.templates.session))
425
+
426
+ // Call API endpoint with batched session updates
427
+ const endpoint = '/api/session'
428
+ const response = await fetch(endpoint, {
429
+ method: 'POST',
430
+ headers: {'Content-Type': 'application/json'},
431
+ body: JSON.stringify(sessionData),
432
+ })
433
+
434
+ if (!response.ok) {
435
+ throw new Error(`Failed to save session state: ${response.statusText}`)
436
+ }
437
+ }
438
+ }
439
+
440
+ set(key: string, item: object): void {
441
+ if (typeof window === 'undefined') return
442
+ try {
443
+ return window.localStorage.setItem(key, JSON.stringify(item))
444
+ } catch (err) {
445
+ console.error('Cannot use Local Storage; continue without.', err)
446
+ }
447
+ }
448
+
449
+ set_tab(key: string, item: object): void {
450
+ if (typeof window === 'undefined') return
451
+ try {
452
+ return window.sessionStorage.setItem(key, JSON.stringify(item))
453
+ } catch (err) {
454
+ console.error('Cannot use Session Storage; continue without.', err)
455
+ }
456
+ }
457
+ }
package/util/censor.ts ADDED
@@ -0,0 +1,46 @@
1
+ // Note: this is mildly perf-sensitive.
2
+ //
3
+ // It does *not* use `delete` - dynamic `delete`s usually cause objects to bail
4
+ // out into dictionary mode and just generally cause a bunch of optimization
5
+ // issues within engines.
6
+ //
7
+ // Ideally, I would've preferred to do this, if it weren't for the optimization
8
+ // issues:
9
+ //
10
+ // ```ts
11
+ // const hasOwn = require("./hasOwn")
12
+ // const magic = [
13
+ // "key", "oninit", "oncreate", "onbeforeupdate", "onupdate",
14
+ // "onbeforeremove", "onremove",
15
+ // ]
16
+ // export default (attrs, extras) => {
17
+ // const result = Object.assign(Object.create(null), attrs)
18
+ // for (const key of magic) delete result[key]
19
+ // if (extras != null) for (const key of extras) delete result[key]
20
+ // return result
21
+ // }
22
+ // ```
23
+
24
+ import hasOwn from './hasOwn'
25
+
26
+ const magic = /^(?:key|oninit|oncreate|onbeforeupdate|onupdate|onbeforeremove|onremove)$/
27
+
28
+ export default function censor(attrs: Record<string, any>, extras?: string[]): Record<string, any> {
29
+ const result: Record<string, any> = {}
30
+
31
+ if (extras != null) {
32
+ for (const key in attrs) {
33
+ if (hasOwn.call(attrs, key) && !magic.test(key) && extras.indexOf(key) < 0) {
34
+ result[key] = attrs[key]
35
+ }
36
+ }
37
+ } else {
38
+ for (const key in attrs) {
39
+ if (hasOwn.call(attrs, key) && !magic.test(key)) {
40
+ result[key] = attrs[key]
41
+ }
42
+ }
43
+ }
44
+
45
+ return result
46
+ }
@@ -0,0 +1,34 @@
1
+ /*
2
+ Percent encodings encode UTF-8 bytes, so this regexp needs to match that.
3
+ Here's how UTF-8 encodes stuff:
4
+ - `00-7F`: 1-byte, for U+0000-U+007F
5
+ - `C2-DF 80-BF`: 2-byte, for U+0080-U+07FF
6
+ - `E0-EF 80-BF 80-BF`: 3-byte, encodes U+0800-U+FFFF
7
+ - `F0-F4 80-BF 80-BF 80-BF`: 4-byte, encodes U+10000-U+10FFFF
8
+ In this, there's a number of invalid byte sequences:
9
+ - `80-BF`: Continuation byte, invalid as start
10
+ - `C0-C1 80-BF`: Overlong encoding for U+0000-U+007F
11
+ - `E0 80-9F 80-BF`: Overlong encoding for U+0080-U+07FF
12
+ - `ED A0-BF 80-BF`: Encoding for UTF-16 surrogate U+D800-U+DFFF
13
+ - `F0 80-8F 80-BF 80-BF`: Overlong encoding for U+0800-U+FFFF
14
+ - `F4 90-BF`: RFC 3629 restricted UTF-8 to only code points UTF-16 could encode.
15
+ - `F5-FF`: RFC 3629 restricted UTF-8 to only code points UTF-16 could encode.
16
+ So in reality, only the following sequences can encode are valid characters:
17
+ - 00-7F
18
+ - C2-DF 80-BF
19
+ - E0 A0-BF 80-BF
20
+ - E1-EC 80-BF 80-BF
21
+ - ED 80-9F 80-BF
22
+ - EE-EF 80-BF 80-BF
23
+ - F0 90-BF 80-BF 80-BF
24
+ - F1-F3 80-BF 80-BF 80-BF
25
+ - F4 80-8F 80-BF 80-BF
26
+
27
+ The regexp just tries to match this as compactly as possible.
28
+ */
29
+ const validUtf8Encodings =
30
+ /%(?:[0-7]|(?!c[01]|e0%[89]|ed%[ab]|f0%8|f4%[9ab])(?:c|d|(?:e|f[0-4]%[89ab])[\da-f]%[89ab])[\da-f]%[89ab])[\da-f]/gi
31
+
32
+ export default function decodeURIComponentSafe(str: string): string {
33
+ return String(str).replace(validUtf8Encodings, decodeURIComponent)
34
+ }
package/util/hasOwn.ts ADDED
@@ -0,0 +1,2 @@
1
+ // This exists so I'm only saving it once.
2
+ export default {}.hasOwnProperty
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Isomorphic next_tick utility
3
+ *
4
+ * Returns a Promise that resolves after the current execution stack completes.
5
+ *
6
+ * - In browser: Uses queueMicrotask for optimal performance, falls back to Promise.resolve()
7
+ * - In SSR: Resolves immediately since SSR rendering is synchronous and there's no event loop
8
+ *
9
+ * @returns Promise that resolves on the next tick
10
+ */
11
+ // eslint-disable-next-line @typescript-eslint/naming-convention
12
+ async function next_tick(): Promise<void> {
13
+ // Check if we're in SSR mode
14
+ if (typeof globalThis !== 'undefined' && (globalThis as any).__SSR_MODE__) {
15
+ // In SSR mode, resolve immediately since SSR rendering is synchronous
16
+ // and there's no event loop to defer to
17
+ return Promise.resolve()
18
+ }
19
+
20
+ // Browser mode: use queueMicrotask for optimal performance
21
+ if (typeof queueMicrotask !== 'undefined') {
22
+ return new Promise<void>((resolve) => {
23
+ queueMicrotask(resolve)
24
+ })
25
+ }
26
+
27
+ // Fallback: use Promise.resolve() for older environments
28
+ if (typeof Promise !== 'undefined' && Promise.resolve) {
29
+ return Promise.resolve()
30
+ }
31
+
32
+ // Last resort: setTimeout (shouldn't happen in modern environments)
33
+ if (typeof setTimeout !== 'undefined') {
34
+ return new Promise<void>((resolve: () => void) => {
35
+ setTimeout(resolve, 0)
36
+ })
37
+ }
38
+
39
+ // If nothing is available, resolve immediately
40
+ // This should never happen in practice, but TypeScript needs a return
41
+ return Promise.resolve() as Promise<void>
42
+ }
43
+
44
+ export default next_tick
45
+ export {next_tick}