@webspatial/core-sdk 1.5.0 → 1.6.1

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.
@@ -9,8 +9,13 @@ import {
9
9
  isValidWorldScalingType,
10
10
  isValidWorldAlignmentType,
11
11
  isValidBaseplateVisibilityType,
12
+ PWAManifest,
13
+ XRSpatialSceneConfig,
14
+ XRSpatialSceneDefaults,
12
15
  } from './types/types'
13
16
  import { SpatialSceneCreationOptionsInternal } from './types/internal'
17
+ import { deepCloneJSON } from './utils'
18
+ import { pointToPhysical, physicalToPoint } from './physicalMetrics'
14
19
 
15
20
  const defaultSceneConfig: SpatialSceneCreationOptions = {
16
21
  defaultSize: {
@@ -21,17 +26,80 @@ const defaultSceneConfig: SpatialSceneCreationOptions = {
21
26
 
22
27
  const defaultSceneConfigVolume: SpatialSceneCreationOptions = {
23
28
  defaultSize: {
24
- width: 0.94,
25
- height: 0.94,
26
- depth: 0.94,
29
+ width: '0.94m',
30
+ height: '0.94m',
31
+ depth: '0.94m',
27
32
  },
28
33
  }
29
34
 
35
+ let xr_window_defaults: SpatialSceneCreationOptions = {
36
+ ...defaultSceneConfig,
37
+ }
38
+ let xr_volume_defaults: SpatialSceneCreationOptions = {
39
+ ...defaultSceneConfigVolume,
40
+ }
41
+
30
42
  const INTERNAL_SCHEMA_PREFIX = 'webspatial://'
31
43
 
44
+ /**
45
+ * Deep-merge two plain object trees (no arrays, no special classes).
46
+ * - Creates a shallow clone of base, then recursively merges properties from over.
47
+ * - When both sides at a key are plain objects, merges recursively; otherwise, replaces with over.
48
+ * - Ignores arrays (treated as replace).
49
+ * Intended for small configuration objects like manifest overrides.
50
+ */
51
+ function deepMergePlain<
52
+ T extends Record<string, any>,
53
+ U extends Record<string, any> | undefined,
54
+ >(base: T, over: U): T & (U extends undefined ? {} : U) {
55
+ if (!over) return { ...(base || {}) } as any
56
+ const out: any = { ...(base || {}) }
57
+ for (const k of Object.keys(over)) {
58
+ const bv = out[k]
59
+ const ov = (over as any)[k]
60
+ if (
61
+ ov &&
62
+ typeof ov === 'object' &&
63
+ !Array.isArray(ov) &&
64
+ bv &&
65
+ typeof bv === 'object' &&
66
+ !Array.isArray(bv)
67
+ ) {
68
+ out[k] = deepMergePlain(bv, ov)
69
+ } else {
70
+ out[k] = ov
71
+ }
72
+ }
73
+ return out
74
+ }
75
+
76
+ /**
77
+ * Normalize XRSpatialSceneDefaults (manifest shape) into SpatialSceneCreationOptions (runtime shape).
78
+ * - Only remap default_size -> defaultSize when present.
79
+ * - Leaves other keys (resizability, worldScaling, etc.) unchanged.
80
+ * - Units are left as-is; downstream formatting is handled by formatSceneConfig.
81
+ */
82
+ function normalizeXRDefaultsToSceneOptions(
83
+ src: XRSpatialSceneDefaults | Record<string, any>,
84
+ ): SpatialSceneCreationOptions {
85
+ const out: any = { ...(src || {}) }
86
+ const ds =
87
+ (src as any).defaultSize !== undefined
88
+ ? (src as any).defaultSize
89
+ : (src as any).default_size
90
+ if (ds !== undefined) {
91
+ out.defaultSize = ds
92
+ }
93
+ if ('default_size' in out) {
94
+ delete out.default_size
95
+ }
96
+ return out
97
+ }
98
+
32
99
  class SceneManager {
33
100
  private originalOpen: any
34
101
  private static instance: SceneManager
102
+ private manifestReady: Promise<void> | null = null
35
103
  static getInstance() {
36
104
  if (!SceneManager.instance) {
37
105
  SceneManager.instance = new SceneManager()
@@ -40,16 +108,26 @@ class SceneManager {
40
108
  }
41
109
 
42
110
  init(window: WindowProxy) {
111
+ this.manifestReady = this.setupManifest()
43
112
  this.originalOpen = window.open.bind(window)
44
113
  ;(window as any).open = this.open
45
114
  }
46
115
 
47
- private configMap: Record<string, SpatialSceneCreationOptionsInternal> = {} // name=>config
116
+ // Stores the latest formatted config used by the platform (per scene name).
117
+ // This object contains normalized values and is safe for internal consumption.
118
+ private configMap: Record<string, SpatialSceneCreationOptionsInternal> = {}
119
+ // Stores the raw callback return value (per scene name) to feed into the next initScene call as `pre`.
120
+ // We keep this unformatted so developers receive exactly what they last returned.
121
+ private callbackReturnMap: Record<string, SpatialSceneCreationOptions> = {}
48
122
  private getConfig(name?: string) {
49
123
  if (name === undefined || !this.configMap[name]) return undefined
50
124
  return this.configMap[name]
51
125
  }
52
126
 
127
+ waitManifest(): Promise<void> {
128
+ return this.manifestReady ?? Promise.resolve()
129
+ }
130
+
53
131
  // Ensure URL is absolute; only convert when a relative path is provided
54
132
  // - Keep external and special schemes untouched (http, https, data, blob, about, file, mailto, etc.)
55
133
  // - Handle protocol-relative URLs (//example.com/path)
@@ -73,15 +151,48 @@ class SceneManager {
73
151
  }
74
152
  }
75
153
 
154
+ private async setupManifest() {
155
+ const manifest = await this.getPWAManifest()
156
+ try {
157
+ const xr = manifest?.xr_spatial_scene
158
+ if (!xr || typeof xr !== 'object') return
159
+ const { overrides, ...topLevel } = xr as XRSpatialSceneConfig
160
+ // Merge top-level defaults with per-scene overrides.
161
+ const windowRaw = deepMergePlain(topLevel, overrides?.window_scene)
162
+ const volumeRaw = deepMergePlain(topLevel, overrides?.volume_scene)
163
+ const windowNext = normalizeXRDefaultsToSceneOptions(windowRaw)
164
+
165
+ const volumeNext = normalizeXRDefaultsToSceneOptions(volumeRaw)
166
+ if (windowNext && Object.keys(windowNext).length > 0) {
167
+ xr_window_defaults = windowNext
168
+ }
169
+ if (volumeNext && Object.keys(volumeNext).length > 0) {
170
+ xr_volume_defaults = volumeNext
171
+ }
172
+ } catch (error: any) {
173
+ console.warn(
174
+ 'SceneManager.setupManifest failed; using built-in defaults.',
175
+ error?.message || error,
176
+ )
177
+ }
178
+ }
179
+
76
180
  private open = (url?: string, target?: string, features?: string) => {
77
181
  // bypass internal
78
182
  if (url?.startsWith(INTERNAL_SCHEMA_PREFIX)) {
79
- if (url.includes('createSpatialized2DElement')) {
80
- const token = window.webSpatial?.genToken?.()
183
+ if (
184
+ url.includes('createSpatialized2DElement') ||
185
+ url.includes('createAttachment')
186
+ ) {
187
+ const token = //@ts-ignore
188
+ (window.webSpatial || window.__webspatialShell__)?.genToken?.()
81
189
  if (token) {
190
+ const command = url.includes('createAttachment')
191
+ ? 'createAttachment'
192
+ : 'createSpatialized2DElement'
82
193
  const host = window.location.host
83
194
  const protocol = window.location.protocol
84
- const finalURL = `${protocol}//${host}/${token}/?command=createSpatialized2DElement`
195
+ const finalURL = `${protocol}//${host}/${token}/?command=${command}`
85
196
  const rid = new URL(url).searchParams.get('rid')
86
197
  const final = new URL(finalURL)
87
198
  if (rid) final.searchParams.set('rid', rid)
@@ -100,7 +211,16 @@ class SceneManager {
100
211
  return newWindow
101
212
  }
102
213
 
103
- const cfg = target ? this.getConfig(target) : undefined
214
+ let cfg = target ? this.getConfig(target) : undefined
215
+
216
+ if (cfg === undefined) {
217
+ // if no config, use default window config
218
+ const preFormatted = deepCloneJSON(getSceneDefaultConfig('window'))
219
+
220
+ const [ans] = formatSceneConfig(preFormatted, 'window')
221
+ cfg = { ...ans, type: 'window' }
222
+ }
223
+
104
224
  const cmd = new createSpatialSceneCommand(url!, cfg, target, features)
105
225
  const result = cmd.executeSync()
106
226
 
@@ -120,25 +240,137 @@ class SceneManager {
120
240
  options?: { type: SpatialSceneType },
121
241
  ) {
122
242
  const sceneType = options?.type ?? 'window'
123
- const defaultConfig = getSceneDefaultConfig(sceneType)
124
- const rawReturnVal = callback({ ...defaultConfig })
125
- const [formattedConfig, errors] = formatSceneConfig(rawReturnVal, sceneType)
243
+ const defaultConfigRaw = getSceneDefaultConfig(sceneType)
244
+ const previousOrDefault =
245
+ this.callbackReturnMap[name] ??
246
+ ((): SpatialSceneCreationOptions => {
247
+ // Clone default config to avoid mutating shared defaults during formatting.
248
+ const cloned = deepCloneJSON(defaultConfigRaw)
249
+ return cloned
250
+ })()
251
+ const rawReturnVal = callback(previousOrDefault)
252
+ const sanitizedReturnVal = sanitizeSceneOptionsUnits(
253
+ deepCloneJSON(rawReturnVal),
254
+ )
255
+ const clonedForFormat = deepCloneJSON(sanitizedReturnVal)
256
+ // Merge normalized user return with scene-type defaults before final formatting.
257
+ // This ensures missing fields fall back to the appropriate xr_window_defaults / xr_volume_defaults.
258
+ const baseDefaults = deepCloneJSON(getSceneDefaultConfig(sceneType))
259
+ const mergedForFormat = deepMergePlain(baseDefaults, clonedForFormat)
260
+
261
+ const [formattedConfig, errors] = formatSceneConfig(
262
+ mergedForFormat,
263
+ sceneType,
264
+ )
265
+
126
266
  if (errors.length > 0) {
127
267
  console.warn(`initScene ${name} with errors: ${errors.join(', ')}`)
128
268
  }
269
+ this.callbackReturnMap[name] = sanitizedReturnVal
129
270
  this.configMap[name] = {
130
271
  ...formattedConfig,
131
272
  type: sceneType,
132
273
  }
133
274
  }
275
+ /**
276
+ * Resolve and load a PWA manifest as JSON:
277
+ * 1) Determine href:
278
+ * - Prefer explicit manifestUrl if provided;
279
+ * - Fallback to <link rel="manifest">, preferring the raw attribute over computed href.
280
+ * 2) Normalize href to absolute using ensureAbsoluteUrl (respects <base href>).
281
+ * 3) Handle data URLs inline:
282
+ * - data:...;base64,... → atob then JSON.parse
283
+ * - data:...,... → decodeURIComponent then JSON.parse
284
+ * 4) Fetch with credentials same-origin first; if that fails (e.g., CORS), attempt unauthenticated fetch.
285
+ * 5) Parse as JSON; if response body is text, parse the text as JSON.
286
+ */
287
+ async getPWAManifest(manifestUrl?: string): Promise<PWAManifest | undefined> {
288
+ let href: string | undefined = manifestUrl
289
+ if (!href) {
290
+ const el = document.querySelector(
291
+ 'link[rel="manifest"]',
292
+ ) as HTMLLinkElement | null
293
+ href = el?.getAttribute('href') || el?.href
294
+ }
295
+ if (!href) return
296
+ href = this.ensureAbsoluteUrl(href)
297
+ if (!href) return
298
+ if (href.startsWith('data:')) {
299
+ // Inline data URL manifest: data:[<mediatype>][;base64],<data>
300
+ try {
301
+ const comma = href.indexOf(',')
302
+ if (comma < 0) return
303
+ const meta = href.slice(5, comma)
304
+ const data = href.slice(comma + 1)
305
+ const isBase64 = /;base64/i.test(meta)
306
+ const decoded = isBase64 ? atob(data) : decodeURIComponent(data)
307
+ return JSON.parse(decoded)
308
+ } catch {
309
+ return
310
+ }
311
+ }
312
+ try {
313
+ // Same-origin fetch with credentials first.
314
+ const res = await fetch(href, { credentials: 'same-origin' })
315
+ if (!res.ok) throw new Error(String(res.status))
316
+ try {
317
+ return await res.json()
318
+ } catch {
319
+ const t = await res.text()
320
+ return JSON.parse(t)
321
+ }
322
+ } catch {
323
+ try {
324
+ // Fallback: unauthenticated fetch (may help when same-origin credentials fail due to CORS).
325
+ const res = await fetch(href)
326
+ if (!res.ok) return
327
+ try {
328
+ return await res.json()
329
+ } catch {
330
+ const t = await res.text()
331
+ return JSON.parse(t)
332
+ }
333
+ } catch {
334
+ return
335
+ }
336
+ }
337
+ }
338
+ }
339
+
340
+ function sanitizeSceneOptionsUnits(
341
+ val: SpatialSceneCreationOptions,
342
+ ): SpatialSceneCreationOptions {
343
+ if (val?.defaultSize) {
344
+ const keys = ['width', 'height', 'depth'] as const
345
+ for (const k of keys) {
346
+ if (
347
+ k in (val.defaultSize as any) &&
348
+ !isValidSceneUnit((val.defaultSize as any)[k])
349
+ ) {
350
+ delete (val.defaultSize as any)[k]
351
+ }
352
+ }
353
+ }
354
+ if (val?.resizability) {
355
+ const keys = ['minWidth', 'minHeight', 'maxWidth', 'maxHeight'] as const
356
+ for (const k of keys) {
357
+ if (
358
+ k in (val.resizability as any) &&
359
+ !isValidSceneUnit((val.resizability as any)[k])
360
+ ) {
361
+ delete (val.resizability as any)[k]
362
+ }
363
+ }
364
+ }
365
+ return val
134
366
  }
135
367
 
136
368
  function pxToMeter(px: number): number {
137
- return px / 1360
369
+ return pointToPhysical(px)
138
370
  }
139
371
 
140
372
  function meterToPx(meter: number): number {
141
- return meter * 1360
373
+ return physicalToPoint(meter)
142
374
  }
143
375
 
144
376
  function formatToNumber(
@@ -215,12 +447,11 @@ export function formatSceneConfig(
215
447
  ;(config.defaultSize as any)[k] = formatToNumber(
216
448
  (config.defaultSize as any)[k],
217
449
  isWindow ? 'px' : 'm',
218
- isWindow ? 'px' : 'm',
450
+ 'px',
219
451
  )
220
452
  } else {
221
- ;(config.defaultSize as any)[k] = (
222
- defaultSceneConfig.defaultSize as any
223
- )[k]
453
+ // delete invalid unit
454
+ delete (config.defaultSize as any)[k]
224
455
  errors.push(`defaultSize.${k}`)
225
456
  }
226
457
  }
@@ -235,10 +466,11 @@ export function formatSceneConfig(
235
466
  ;(config.resizability as any)[k] = formatToNumber(
236
467
  (config.resizability as any)[k],
237
468
  'px',
238
- isWindow ? 'px' : 'm',
469
+ 'px',
239
470
  )
240
471
  } else {
241
- ;(config.resizability as any)[k] = undefined
472
+ // delete invalid unit
473
+ delete (config.resizability as any)[k]
242
474
  errors.push(`resizability.${k}`)
243
475
  }
244
476
  }
@@ -277,6 +509,13 @@ export function initScene(
277
509
  return SceneManager.getInstance().initScene(name, callback, options)
278
510
  }
279
511
 
512
+ export function __getSceneConfigSnapshotForTest(
513
+ name: string,
514
+ ): SpatialSceneCreationOptionsInternal | undefined {
515
+ const mgr = SceneManager.getInstance() as any
516
+ return mgr?.configMap?.[name]
517
+ }
518
+
280
519
  export function hijackWindowOpen(window: WindowProxy) {
281
520
  SceneManager.getInstance().init(window)
282
521
  }
@@ -284,23 +523,22 @@ export function hijackWindowOpen(window: WindowProxy) {
284
523
  export function hijackWindowATag(openedWindow: WindowProxy) {
285
524
  openedWindow!.document.onclick = function (e) {
286
525
  let element = e.target as HTMLElement | null
287
- let found = false
288
526
 
289
527
  // Look for <a> element in the clicked elements parents and if found override navigation behavior if needed
290
- while (!found) {
291
- if (element && element.tagName == 'A') {
528
+ while (element) {
529
+ if (element.tagName == 'A') {
292
530
  // When using libraries like react route's <Link> it sets an onclick event, when this happens we should do nothing and let that occur
293
531
 
294
532
  // if onClick is set for the element, the raw onclick will be noop() trapped so the onclick check is no longer trustable
295
533
  // we handle all the scenarios
296
534
 
297
- if (handleATag(e)) {
535
+ if (handleATag(e, element as HTMLAnchorElement)) {
298
536
  return false // prevent default action and stop event propagation
299
537
  }
300
538
 
301
539
  return true
302
540
  }
303
- if (element && element.parentElement) {
541
+ if (element.parentElement) {
304
542
  element = element.parentElement
305
543
  } else {
306
544
  break
@@ -309,23 +547,24 @@ export function hijackWindowATag(openedWindow: WindowProxy) {
309
547
  }
310
548
  }
311
549
 
312
- function handleATag(event: MouseEvent) {
313
- const targetElement = event.target as HTMLElement
314
- if (targetElement.tagName === 'A') {
315
- const link = targetElement as HTMLAnchorElement
316
- const target = link.target
317
- const url = link.href
318
-
319
- if (target && target !== '_self') {
320
- event.preventDefault()
321
- window.open(url, target)
322
- return true
323
- }
550
+ function handleATag(event: MouseEvent, link: HTMLAnchorElement) {
551
+ // Respect clicks that have already been cancelled (e.g. by app/router handlers).
552
+ if (event.defaultPrevented) return false
553
+ // Use the anchor found during bubbling so nested clicks like <a><img /></a> are handled correctly.
554
+ const target = link.target
555
+ const url = link.href
556
+
557
+ if (target && target !== '_self') {
558
+ event.preventDefault()
559
+ window.open(url, target)
560
+ return true
324
561
  }
325
562
  }
326
563
 
327
564
  function getSceneDefaultConfig(sceneType: SpatialSceneType) {
328
- return sceneType === 'window' ? defaultSceneConfig : defaultSceneConfigVolume
565
+ return sceneType === 'window'
566
+ ? xr_window_defaults || defaultSceneConfig
567
+ : xr_volume_defaults || defaultSceneConfigVolume
329
568
  }
330
569
 
331
570
  async function injectScenePolyfill() {
@@ -348,13 +587,16 @@ async function injectScenePolyfill() {
348
587
  }
349
588
 
350
589
  onContentLoaded(async () => {
351
- let provideDefaultSceneConfig = getSceneDefaultConfig(
352
- window.xrCurrentSceneType ?? 'window',
353
- )
354
- let cfg = provideDefaultSceneConfig
590
+ await SceneManager.getInstance().waitManifest()
591
+ const sceneType = window.xrCurrentSceneType ?? 'window'
592
+ const rawDefault = getSceneDefaultConfig(sceneType)
593
+ // Provide a formatted 'pre' to the callback for consistent units and types.
594
+ const pre = deepCloneJSON(rawDefault)
595
+
596
+ let cfg = pre
355
597
  if (typeof window.xrCurrentSceneDefaults === 'function') {
356
598
  try {
357
- cfg = await window.xrCurrentSceneDefaults?.(provideDefaultSceneConfig)
599
+ cfg = await window.xrCurrentSceneDefaults?.(pre)
358
600
  } catch (error) {
359
601
  console.error(error)
360
602
  }
@@ -366,17 +608,19 @@ async function injectScenePolyfill() {
366
608
  }, 1000)
367
609
  })
368
610
 
369
- const sceneType = window.xrCurrentSceneType ?? 'window'
370
- const [formattedConfig, errors] = formatSceneConfig(cfg, sceneType)
611
+ // Merge callback return with base defaults to ensure missing fields are filled.
612
+ const mergedCfg = deepMergePlain(deepCloneJSON(rawDefault), cfg)
613
+ const [formattedConfig, errors] = formatSceneConfig(mergedCfg, sceneType)
371
614
  if (errors.length > 0) {
372
615
  console.warn(
373
616
  `window.xrCurrentSceneDefaults with errors: ${errors.join(', ')}`,
374
617
  )
375
618
  }
376
- await SpatialScene.getInstance().updateSceneCreationConfig({
619
+ const finalCfg = {
377
620
  ...formattedConfig,
378
621
  type: sceneType,
379
- })
622
+ }
623
+ await SpatialScene.getInstance().updateSceneCreationConfig(finalCfg)
380
624
  })
381
625
  }
382
626
 
@@ -26,6 +26,9 @@ declare global {
26
26
  webSpatial?: {
27
27
  genToken?: () => string
28
28
  }
29
+ __webspatialShell__?: {
30
+ genToken?: () => string
31
+ }
29
32
 
30
33
  // Will be removed in favor of __WebSpatialData
31
34
  WebSpatailNativeVersion: string
@@ -95,10 +95,20 @@ export interface Spatialized2DElementProperties
95
95
  scrollEdgeInsetsMarginRight: number
96
96
  }
97
97
 
98
+ export interface ModelSource {
99
+ src: string
100
+ type?: string
101
+ }
102
+
98
103
  export interface SpatializedStatic3DElementProperties
99
104
  extends SpatializedElementProperties {
100
105
  modelURL: string
106
+ sources?: ModelSource[]
101
107
  modelTransform?: number[]
108
+ autoplay?: boolean
109
+ loop?: boolean
110
+ animationPaused?: boolean
111
+ playbackRate?: number
102
112
  }
103
113
 
104
114
  export interface SpatialSceneCreationOptions {
@@ -411,3 +421,64 @@ export interface AttachmentEntityUpdateOptions {
411
421
  position?: [number, number, number]
412
422
  size?: { width: number; height: number }
413
423
  }
424
+
425
+ // manifest
426
+
427
+ export type SceneUnitPx = `${number}px`
428
+ export type SceneUnitM = `${number}m`
429
+ export type SceneUnit = number | SceneUnitPx | SceneUnitM
430
+
431
+ export interface XRSceneSize {
432
+ width: SceneUnit
433
+ height: SceneUnit
434
+ depth?: SceneUnit
435
+ }
436
+
437
+ export interface XRSceneResizability {
438
+ minWidth?: SceneUnit
439
+ minHeight?: SceneUnit
440
+ maxWidth?: SceneUnit
441
+ maxHeight?: SceneUnit
442
+ }
443
+
444
+ export interface XRMainSceneConfig extends XRSpatialSceneDefaults {
445
+ type?: SpatialSceneType
446
+ }
447
+
448
+ export interface XRSpatialSceneDefaults {
449
+ default_size?: XRSceneSize
450
+ resizability?: XRSceneResizability
451
+ worldScaling?: WorldScalingType
452
+ worldAlignment?: WorldAlignmentType
453
+ baseplateVisibility?: BaseplateVisibilityType
454
+ }
455
+
456
+ export interface XRSpatialSceneOverrides {
457
+ window_scene?: XRSpatialSceneDefaults
458
+ volume_scene?: XRSpatialSceneDefaults
459
+ }
460
+
461
+ export interface XRSpatialSceneConfig extends XRSpatialSceneDefaults {
462
+ overrides?: XRSpatialSceneOverrides
463
+ }
464
+
465
+ export interface XRPrdConfig {
466
+ xr_main_scene?: XRMainSceneConfig
467
+ xr_spatial_scene?: XRSpatialSceneConfig
468
+ }
469
+
470
+ export interface PWAManifest extends XRPrdConfig {
471
+ name?: string
472
+ short_name?: string
473
+ start_url?: string
474
+ display?: string
475
+ icons?: Array<{
476
+ src: string
477
+ sizes?: string
478
+ type?: string
479
+ purpose?: string
480
+ }>
481
+ id?: string
482
+ scope?: string
483
+ [key: string]: any
484
+ }
package/src/utils.ts CHANGED
@@ -59,3 +59,24 @@ export function composeSRT(position: Vec3, rotation: Vec3, scale: Vec3) {
59
59
  m = m.scale(sx, sy, sz)
60
60
  return m
61
61
  }
62
+
63
+ /**
64
+ * Deep-clone a plain JSON-serializable object by value.
65
+ *
66
+ * Notes:
67
+ * - Only use for data composed of primitives, arrays, and plain objects.
68
+ * - Functions, Dates, Maps/Sets, DOM nodes, and circular structures are not supported.
69
+ */
70
+ export function deepCloneJSON<T>(value: T): T {
71
+ const sc = (globalThis as any).structuredClone as
72
+ | ((v: any) => any)
73
+ | undefined
74
+ if (typeof sc === 'function') {
75
+ try {
76
+ return sc(value)
77
+ } catch {
78
+ // fall through to JSON method
79
+ }
80
+ }
81
+ return JSON.parse(JSON.stringify(value))
82
+ }