@dcl/sdk 7.29.1-36591934525.commit-48aa9b5 → 7.29.1-36625469671.commit-2d5ffac

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 (80) hide show
  1. package/atom.d.ts +19 -0
  2. package/atom.js +83 -0
  3. package/future.d.ts +8 -0
  4. package/future.js +26 -0
  5. package/internal/transports/rendererTransport.js +5 -2
  6. package/network/binary-message-bus.d.ts +6 -3
  7. package/network/binary-message-bus.js +9 -5
  8. package/network/chunking.d.ts +5 -0
  9. package/network/chunking.js +38 -0
  10. package/network/events/implementation.d.ts +93 -0
  11. package/network/events/implementation.js +221 -0
  12. package/network/events/index.d.ts +42 -0
  13. package/network/events/index.js +43 -0
  14. package/network/events/protocol.d.ts +27 -0
  15. package/network/events/protocol.js +66 -0
  16. package/network/events/registry.d.ts +8 -0
  17. package/network/events/registry.js +3 -0
  18. package/network/index.d.ts +8 -2
  19. package/network/index.js +16 -3
  20. package/network/message-bus-sync.d.ts +16 -3
  21. package/network/message-bus-sync.js +164 -103
  22. package/network/server/index.d.ts +14 -0
  23. package/network/server/index.js +219 -0
  24. package/network/server/utils.d.ts +18 -0
  25. package/network/server/utils.js +135 -0
  26. package/network/state.js +3 -5
  27. package/package.json +6 -6
  28. package/server/env-var.d.ts +15 -0
  29. package/server/env-var.js +31 -0
  30. package/server/index.d.ts +2 -0
  31. package/server/index.js +3 -0
  32. package/server/storage/constants.d.ts +80 -0
  33. package/server/storage/constants.js +19 -0
  34. package/server/storage/error-message.d.ts +1 -0
  35. package/server/storage/error-message.js +9 -0
  36. package/server/storage/index.d.ts +54 -0
  37. package/server/storage/index.js +60 -0
  38. package/server/storage/player.d.ts +60 -0
  39. package/server/storage/player.js +244 -0
  40. package/server/storage/scene.d.ts +55 -0
  41. package/server/storage/scene.js +232 -0
  42. package/server/storage/serialize.d.ts +1 -0
  43. package/server/storage/serialize.js +135 -0
  44. package/server/storage/value-cache.d.ts +1 -0
  45. package/server/storage/value-cache.js +67 -0
  46. package/server/storage/write-queue.d.ts +1 -0
  47. package/server/storage/write-queue.js +113 -0
  48. package/server/storage-url.d.ts +13 -0
  49. package/server/storage-url.js +42 -0
  50. package/server/utils.d.ts +35 -0
  51. package/server/utils.js +69 -0
  52. package/src/atom.ts +98 -0
  53. package/src/future.ts +38 -0
  54. package/src/internal/transports/rendererTransport.ts +4 -1
  55. package/src/network/binary-message-bus.ts +9 -4
  56. package/src/network/chunking.ts +45 -0
  57. package/src/network/events/implementation.ts +271 -0
  58. package/src/network/events/index.ts +48 -0
  59. package/src/network/events/protocol.ts +94 -0
  60. package/src/network/events/registry.ts +18 -0
  61. package/src/network/index.ts +40 -3
  62. package/src/network/message-bus-sync.ts +180 -112
  63. package/src/network/server/index.ts +301 -0
  64. package/src/network/server/utils.ts +189 -0
  65. package/src/network/state.ts +3 -4
  66. package/src/server/env-var.ts +36 -0
  67. package/src/server/index.ts +12 -0
  68. package/src/server/storage/constants.ts +112 -0
  69. package/src/server/storage/error-message.ts +8 -0
  70. package/src/server/storage/index.ts +83 -0
  71. package/src/server/storage/player.ts +345 -0
  72. package/src/server/storage/scene.ts +324 -0
  73. package/src/server/storage/serialize.ts +132 -0
  74. package/src/server/storage/value-cache.ts +119 -0
  75. package/src/server/storage/write-queue.ts +174 -0
  76. package/src/server/storage-url.ts +49 -0
  77. package/src/server/utils.ts +83 -0
  78. package/explorer-ui/index.d.ts +0 -118
  79. package/explorer-ui/index.js +0 -235
  80. package/src/explorer-ui/index.ts +0 -398
@@ -0,0 +1,132 @@
1
+ const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/
2
+ const UNSTORABLE_TEXT = /\u0000/
3
+ const MAX_KEY_LENGTH = 255
4
+
5
+ /**
6
+ * JSON.stringify always writes NUL as `\u0000` and an unpaired surrogate as a `\udXXX` escape (ES2019),
7
+ * while paired surrogates stay raw; an escaped backslash in front means the text is literal.
8
+ */
9
+ const ESCAPED_UNSTORABLE_TEXT = /(?<!\\)(?:\\\\)*\\u(?:0000|d[89a-f][0-9a-f]{2})/i
10
+
11
+ /** Raised from inside the replacer, so it is not mistaken for an engine error. */
12
+ class UnstorableValueError extends TypeError {}
13
+
14
+ type Reason = (holder: Record<string, unknown>, property: string, item: unknown) => string | undefined
15
+
16
+ /**
17
+ * The `{ value }` PUT body. Rejects, at any depth, what JSON would drop or change: functions,
18
+ * symbols, non-finite numbers, `undefined` array elements and holes, built-ins with no JSON form
19
+ * (Map, Set, typed arrays, RegExp, Error, ...), and text the service cannot store. An `undefined`
20
+ * object property is dropped, as it reads back the same.
21
+ * @internal
22
+ */
23
+ export function serializeStorageValue(value: unknown, callSite: string): string {
24
+ const body = stringify(value, callSite, fastReason)
25
+ // Text is checked once on the output; only a rejection pays for locating the offending item.
26
+ if (ESCAPED_UNSTORABLE_TEXT.test(body)) {
27
+ stringify(value, callSite, unstorable)
28
+ throw new TypeError(`${callSite}: value must not contain an unpaired surrogate or a NUL character.`)
29
+ }
30
+ if (body === '{}') {
31
+ throw new TypeError(`${callSite}: value must be JSON-serializable. Use delete() to remove a key.`)
32
+ }
33
+ return body
34
+ }
35
+
36
+ function stringify(value: unknown, callSite: string, reason: Reason): string {
37
+ const envelope = { value }
38
+ try {
39
+ return JSON.stringify(envelope, function (this: Record<string, unknown>, property, item) {
40
+ const why = reason(this, property, item)
41
+ if (why) {
42
+ const where = this === envelope ? 'the value' : `"${property}"`
43
+ throw new UnstorableValueError(
44
+ `${callSite}: value must be JSON-serializable, but ${where} ${why}. Use delete() to remove a key.`
45
+ )
46
+ }
47
+ return item
48
+ })
49
+ } catch (error) {
50
+ if (error instanceof UnstorableValueError) throw error
51
+ const cause = error instanceof Error ? error.message.split('\n')[0] : String(error)
52
+ throw new TypeError(`${callSite}: value must be JSON-serializable (${cause}). Use delete() to remove a key.`)
53
+ }
54
+ }
55
+
56
+ /** Everything but the text checks, with plain objects and arrays skipping the built-in check. */
57
+ function fastReason(holder: Record<string, unknown>, property: string, item: unknown): string | undefined {
58
+ switch (typeof item) {
59
+ case 'function':
60
+ case 'symbol':
61
+ return `is a ${typeof item}`
62
+ case 'number':
63
+ return Number.isFinite(item) ? undefined : `is the non-finite number ${item}`
64
+ case 'undefined':
65
+ if (Array.isArray(holder)) return 'is undefined or a hole in an array'
66
+ return holder[property] !== undefined ? 'has a toJSON() that returns undefined' : undefined
67
+ case 'object': {
68
+ if (item === null || Array.isArray(item)) return undefined
69
+ const prototype = Object.getPrototypeOf(item)
70
+ return prototype === Object.prototype || prototype === null ? undefined : builtInReason(item)
71
+ }
72
+ default:
73
+ return undefined
74
+ }
75
+ }
76
+
77
+ /** Every check, per item: used to name the item a text rejection comes from. */
78
+ function unstorable(holder: Record<string, unknown>, property: string, item: unknown): string | undefined {
79
+ const fast = fastReason(holder, property, item)
80
+ if (fast) return fast
81
+ if (typeof item === 'string' && (LONE_SURROGATE.test(item) || UNSTORABLE_TEXT.test(item))) {
82
+ return 'contains an unpaired surrogate or a NUL character'
83
+ }
84
+ if (!Array.isArray(holder) && (LONE_SURROGATE.test(property) || UNSTORABLE_TEXT.test(property))) {
85
+ return 'is a key with an unpaired surrogate or a NUL character'
86
+ }
87
+ return undefined
88
+ }
89
+
90
+ function builtInReason(item: unknown): string | undefined {
91
+ const builtIn = builtInName(item)
92
+ return builtIn ? `is ${/^[AEIO]/.test(builtIn) ? 'an' : 'a'} ${builtIn}, which has no JSON form` : undefined
93
+ }
94
+
95
+ function builtInName(item: unknown): string | undefined {
96
+ if (item === null || typeof item !== 'object') return undefined
97
+ if (item instanceof Map) return 'Map'
98
+ if (item instanceof Set) return 'Set'
99
+ if (item instanceof WeakMap) return 'WeakMap'
100
+ if (item instanceof WeakSet) return 'WeakSet'
101
+ if (item instanceof RegExp) return 'RegExp'
102
+ if (item instanceof Error) return 'Error'
103
+ if (item instanceof Promise) return 'Promise'
104
+ if (item instanceof ArrayBuffer) return 'ArrayBuffer'
105
+ if (ArrayBuffer.isView(item)) return item.constructor?.name ?? 'typed array'
106
+ return undefined
107
+ }
108
+
109
+ /**
110
+ * Keys and player addresses go into the request path, where `.`, `..` and an empty segment are
111
+ * resolved away by URL parsing, so they would address a different resource.
112
+ * @internal
113
+ */
114
+ export function assertStorageKey(key: unknown, callSite: string): void {
115
+ if (typeof key !== 'string' || key === '' || key === '.' || key === '..') {
116
+ throw new TypeError(`${callSite}: key must be a non-empty string other than "." and "..".`)
117
+ }
118
+ if (LONE_SURROGATE.test(key) || UNSTORABLE_TEXT.test(key)) {
119
+ throw new TypeError(`${callSite}: key must not contain an unpaired surrogate or a NUL character.`)
120
+ }
121
+ // Counted in characters, as the service counts them.
122
+ if ([...key].length > MAX_KEY_LENGTH) {
123
+ throw new TypeError(`${callSite}: key must be at most ${MAX_KEY_LENGTH} characters.`)
124
+ }
125
+ }
126
+
127
+ /** @internal */
128
+ export function assertPlayerAddress(address: unknown, callSite: string): void {
129
+ if (typeof address !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(address)) {
130
+ throw new TypeError(`${callSite}: address must be a 0x-prefixed 20-byte hex address.`)
131
+ }
132
+ }
@@ -0,0 +1,119 @@
1
+ import { DEFAULT_STORAGE_CONFIG, StorageConfigState } from './constants'
2
+
3
+ /**
4
+ * A cached fact about a key's server-side state.
5
+ * @internal
6
+ */
7
+ export interface CacheEntry {
8
+ /**
9
+ * Serialized `{ value }` body; absent for negative entries. Parsed per read
10
+ * hit so callers never share object references, and compared verbatim for
11
+ * write dedup (exact equality — no hash-collision risk).
12
+ */
13
+ body?: string
14
+ /** True when the server confirmed the key does not exist (GET 404 or successful DELETE). */
15
+ absent?: boolean
16
+ }
17
+
18
+ /**
19
+ * Bounded, lazily-expiring cache of key states, backing both write dedup
20
+ * (skip storage writes whose value is already known to be stored) and read
21
+ * caching (serve get() without a network request within the TTL).
22
+ * @internal
23
+ */
24
+ export interface ValueCache {
25
+ /** Returns the entry if present and fresh; lazily evicts expired entries. */
26
+ get(key: string): CacheEntry | undefined
27
+ /** Stores or refreshes a known value (serialized body); overwrites negative entries. */
28
+ set(key: string, entry: { body: string }): void
29
+ /** Stores a confirmed-absent (negative) entry, replacing any value entry. */
30
+ setAbsent(key: string): void
31
+ delete(key: string): void
32
+ /** Starts recording mutated keys, so a read issued earlier can tell its snapshot went stale. */
33
+ watch(): CacheWatcher
34
+ }
35
+
36
+ /**
37
+ * Keys mutated since the watch started. Always stop() it, or it keeps collecting.
38
+ * @internal
39
+ */
40
+ export interface CacheWatcher {
41
+ mutated(key: string): boolean
42
+ stop(): void
43
+ }
44
+
45
+ /**
46
+ * Creates the bounded value cache shared by a storage scope.
47
+ * @internal
48
+ */
49
+ export function createValueCache(config: StorageConfigState): ValueCache {
50
+ const entries = new Map<string, CacheEntry & { storedAt: number }>()
51
+ const watchers = new Set<Set<string>>()
52
+
53
+ function noteMutation(key: string): void {
54
+ for (const seen of watchers) seen.add(key)
55
+ }
56
+
57
+ function insert(key: string, entry: CacheEntry): void {
58
+ noteMutation(key)
59
+ // Delete + re-insert moves refreshed keys to the end of the Map's
60
+ // insertion order, so eviction below drops the least-recently-written.
61
+ entries.delete(key)
62
+ entries.set(key, { ...entry, storedAt: Date.now() })
63
+
64
+ // Guard against misconfiguration: a negative bound would loop forever on
65
+ // an empty map, and a NaN bound would silently disable eviction.
66
+ const maxEntries = !Number.isNaN(config.cacheMaxEntries)
67
+ ? Math.max(0, config.cacheMaxEntries)
68
+ : DEFAULT_STORAGE_CONFIG.cacheMaxEntries
69
+
70
+ while (entries.size > maxEntries) {
71
+ entries.delete(entries.keys().next().value!)
72
+ }
73
+ }
74
+
75
+ return {
76
+ get(key: string): CacheEntry | undefined {
77
+ const entry = entries.get(key)
78
+ if (!entry) return undefined
79
+
80
+ // Guard against misconfiguration, mirroring cacheMaxEntries: a NaN
81
+ // bound would silently disable expiry (NaN comparisons are false).
82
+ // Negative values need no guard — they just expire everything.
83
+ const maxAgeMs = !Number.isNaN(config.cacheMaxAgeMs) ? config.cacheMaxAgeMs : DEFAULT_STORAGE_CONFIG.cacheMaxAgeMs
84
+
85
+ // Lazy max-age expiry: storedAt is never refreshed on hits, so the age
86
+ // bounds the time since the last actual network confirmation.
87
+ if (Date.now() - entry.storedAt > maxAgeMs) {
88
+ entries.delete(key)
89
+ return undefined
90
+ }
91
+
92
+ return entry
93
+ },
94
+
95
+ set(key: string, entry: { body: string }): void {
96
+ insert(key, entry)
97
+ },
98
+
99
+ setAbsent(key: string): void {
100
+ insert(key, { absent: true })
101
+ },
102
+
103
+ delete(key: string): void {
104
+ noteMutation(key)
105
+ entries.delete(key)
106
+ },
107
+
108
+ watch(): CacheWatcher {
109
+ const seen = new Set<string>()
110
+ watchers.add(seen)
111
+ return {
112
+ mutated: (key: string) => seen.has(key),
113
+ stop: () => {
114
+ watchers.delete(seen)
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Rejection of a superseded delete when the write that replaced it did not apply.
3
+ * @internal
4
+ */
5
+ export class SupersedingWriteFailed extends Error {
6
+ constructor() {
7
+ super('a write that replaced it did not apply')
8
+ this.name = 'SupersedingWriteFailed'
9
+ }
10
+ }
11
+
12
+ /**
13
+ * A pending write operation. `body` is the serialized PUT payload, or null
14
+ * for a DELETE. Callers that join the op share its promise.
15
+ */
16
+ interface PendingOp {
17
+ body: string | null
18
+ execute: (body: string | null) => Promise<boolean>
19
+ promise: Promise<boolean>
20
+ resolve: (result: boolean) => void
21
+ reject: (error: unknown) => void
22
+ }
23
+
24
+ interface KeyState {
25
+ /** The op currently on the network. */
26
+ active: PendingOp
27
+ /** At most one queued op; a later write replaces it (latest wins). */
28
+ queued?: PendingOp
29
+ }
30
+
31
+ /**
32
+ * Serializes writes per key so the service commits them in issue order.
33
+ * Overlapping PUTs from the single scene server would otherwise race: the
34
+ * server keeps whichever request it processes last, while the local cache
35
+ * keeps whichever response arrives last — either can disagree with the last
36
+ * set() issued. With at most one in-flight op per key and a single queued
37
+ * "latest value" slot, the server's final state always matches the last
38
+ * write issued, and N rapid writes collapse into at most 2 network calls.
39
+ * @internal
40
+ */
41
+ export interface WriteQueue {
42
+ /**
43
+ * Body of the latest issued write for the key (the queued op if present,
44
+ * else the in-flight one): a string for a PUT, null for a DELETE,
45
+ * undefined when no write is pending.
46
+ */
47
+ pending(key: string): string | null | undefined
48
+ /** True while any write for the key is in flight or queued. */
49
+ isPending(key: string): boolean
50
+ /**
51
+ * Resolves once the in-flight op settles and, if one was queued at call time,
52
+ * the op that runs next. At most two, so writes cannot starve a waiter. Never rejects.
53
+ */
54
+ settled(key: string): Promise<void>
55
+ /**
56
+ * Issues a write, queued behind any in-flight one and replacing any queued one
57
+ * (see settleSuperseded). An identical queued op is joined; `joinActive` also
58
+ * joins an identical in-flight op, which only dedup-tolerant callers may do.
59
+ * Rejects with the executor's error; see settleSuperseded for replaced ops.
60
+ */
61
+ enqueue(
62
+ key: string,
63
+ body: string | null,
64
+ execute: (body: string | null) => Promise<boolean>,
65
+ joinActive: boolean
66
+ ): Promise<boolean>
67
+ }
68
+
69
+ /**
70
+ * Creates the per-key write serializer shared by a storage scope.
71
+ * @internal
72
+ */
73
+ export function createWriteQueue(): WriteQueue {
74
+ const keys = new Map<string, KeyState>()
75
+
76
+ function makeOp(body: string | null, execute: PendingOp['execute']): PendingOp {
77
+ let resolve!: (result: boolean) => void
78
+ let reject!: (error: unknown) => void
79
+ const promise = new Promise<boolean>((res, rej) => {
80
+ resolve = res
81
+ reject = rej
82
+ })
83
+ return { body, execute, promise, resolve, reject }
84
+ }
85
+
86
+ /**
87
+ * A replaced op resolves true when its replacement lands. On failure a set
88
+ * resolves false and a delete rejects, in its own terms.
89
+ */
90
+ function settleSuperseded(superseded: PendingOp, by: PendingOp): void {
91
+ by.promise.then(
92
+ (result) => {
93
+ const applied = by.body === null || result
94
+ if (superseded.body !== null) superseded.resolve(applied)
95
+ else if (applied) superseded.resolve(true)
96
+ else superseded.reject(new SupersedingWriteFailed())
97
+ },
98
+ (error) => {
99
+ if (superseded.body === null) superseded.reject(error)
100
+ else superseded.resolve(false)
101
+ }
102
+ )
103
+ }
104
+
105
+ async function drain(key: string, state: KeyState): Promise<void> {
106
+ for (;;) {
107
+ const op = state.active
108
+ try {
109
+ op.resolve(await op.execute(op.body))
110
+ } catch (error) {
111
+ op.reject(error)
112
+ }
113
+
114
+ if (state.queued) {
115
+ state.active = state.queued
116
+ state.queued = undefined
117
+ } else {
118
+ keys.delete(key)
119
+ return
120
+ }
121
+ }
122
+ }
123
+
124
+ return {
125
+ pending(key: string): string | null | undefined {
126
+ const state = keys.get(key)
127
+ if (!state) return undefined
128
+ return (state.queued ?? state.active).body
129
+ },
130
+
131
+ isPending(key: string): boolean {
132
+ return keys.has(key)
133
+ },
134
+
135
+ async settled(key: string): Promise<void> {
136
+ const state = keys.get(key)
137
+ if (!state) return
138
+ const hadQueued = state.queued !== undefined
139
+ await state.active.promise.catch(() => undefined)
140
+ // drain() promotes the queued op (or its replacement) in place before this resumes.
141
+ if (hadQueued) await state.active.promise.catch(() => undefined)
142
+ },
143
+
144
+ enqueue(key: string, body: string | null, execute: PendingOp['execute'], joinActive: boolean): Promise<boolean> {
145
+ const state = keys.get(key)
146
+
147
+ if (!state) {
148
+ const op = makeOp(body, execute)
149
+ const newState: KeyState = { active: op }
150
+ keys.set(key, newState)
151
+ void drain(key, newState)
152
+ return op.promise
153
+ }
154
+
155
+ if (state.queued) {
156
+ // A queued op has not started, so it is issued "after" this caller
157
+ // either way: join it when identical, supersede it otherwise.
158
+ if (state.queued.body === body) return state.queued.promise
159
+ const op = makeOp(body, execute)
160
+ settleSuperseded(state.queued, op)
161
+ state.queued = op
162
+ return op.promise
163
+ }
164
+
165
+ if (joinActive && state.active.body === body) {
166
+ return state.active.promise
167
+ }
168
+
169
+ const op = makeOp(body, execute)
170
+ state.queued = op
171
+ return op.promise
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,49 @@
1
+ import { getRealm } from '~system/Runtime'
2
+
3
+ const STORAGE_SERVER_ORG = 'https://storage.decentraland.org'
4
+ const STORAGE_SERVER_ZONE = 'https://storage.decentraland.zone'
5
+
6
+ async function resolveStorageServerUrl(): Promise<string> {
7
+ const { realmInfo } = await getRealm({})
8
+
9
+ if (!realmInfo) {
10
+ throw new Error('Unable to retrieve realm information')
11
+ }
12
+
13
+ // Local development / preview mode
14
+ if (realmInfo.isPreview) {
15
+ return realmInfo.baseUrl
16
+ }
17
+
18
+ // Staging / testing environment
19
+ if (realmInfo.baseUrl.includes('.zone')) {
20
+ return STORAGE_SERVER_ZONE
21
+ }
22
+
23
+ // Production environment
24
+ return STORAGE_SERVER_ORG
25
+ }
26
+
27
+ let memoizedUrl: Promise<string> | null = null
28
+
29
+ /**
30
+ * Determines the correct storage server URL based on the current realm.
31
+ *
32
+ * - If `isPreview` is true, uses the realm's baseUrl (localhost)
33
+ * - If the realm's baseUrl contains `.zone`, uses storage.decentraland.zone
34
+ * - Otherwise, uses storage.decentraland.org (production)
35
+ *
36
+ * The realm never changes mid-session, so the result is memoized; a failed
37
+ * resolution is not memoized so transient getRealm errors can be retried.
38
+ *
39
+ * @returns The storage server base URL
40
+ */
41
+ export function getStorageServerUrl(): Promise<string> {
42
+ if (!memoizedUrl) {
43
+ memoizedUrl = resolveStorageServerUrl()
44
+ memoizedUrl.catch(() => {
45
+ memoizedUrl = null
46
+ })
47
+ }
48
+ return memoizedUrl
49
+ }
@@ -0,0 +1,83 @@
1
+ import { signedFetch, SignedFetchRequest } from '~system/SignedFetch'
2
+ import { isServer } from '../network'
3
+ import { errorMessage } from './storage/error-message'
4
+
5
+ /**
6
+ * Validates that the code is running on a server-side scene.
7
+ * Throws an error if called from a client-side context.
8
+ *
9
+ * @param moduleName - The name of the module for the error message
10
+ * @throws Error if not running on a server-side scene
11
+ */
12
+ export function assertIsServer(moduleName: string): void {
13
+ if (!isServer()) {
14
+ throw new Error(`${moduleName} is only available on server-side scenes`)
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Result type for operations that can fail.
20
+ * Returns a tuple of [error, null] on failure or [null, data] on success.
21
+ */
22
+ export type Result<T, E = string> = [E, null] | [null, T]
23
+
24
+ /**
25
+ * Extended result type that includes HTTP status code information.
26
+ */
27
+ export type FetchResult<T> = [string, null, number?] | [null, T, number]
28
+
29
+ /**
30
+ * Wraps a promise to catch errors and return a Result tuple.
31
+ * This allows for cleaner error handling without try-catch blocks.
32
+ *
33
+ * @param promise - The promise to wrap
34
+ * @returns A tuple of [error, null] on failure or [null, data] on success
35
+ */
36
+ export async function tryCatch<T, E = Error>(promise: Promise<T>): Promise<Result<T, E>> {
37
+ try {
38
+ const data = await promise
39
+ return [null, data]
40
+ } catch (error) {
41
+ return [error as E, null]
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Wraps signedFetch with automatic error handling and JSON parsing. Never rejects.
47
+ * Returns a FetchResult tuple with parsed JSON data or error message and status code.
48
+ *
49
+ * @param signedFetchBody - The signedFetch request configuration
50
+ * @returns A tuple of [error, null, statusCode?] on failure or [null, data, statusCode] on success
51
+ */
52
+ export async function wrapSignedFetch<T = unknown>(signedFetchBody: SignedFetchRequest): Promise<FetchResult<T>> {
53
+ let response: Awaited<ReturnType<typeof signedFetch>> | undefined
54
+ try {
55
+ response = await signedFetch(signedFetchBody)
56
+ } catch (error) {
57
+ console.error(`Error in ${signedFetchBody.url} endpoint`, { error })
58
+ return [errorMessage(error, 'signedFetch failed'), null, undefined]
59
+ }
60
+
61
+ if (!response) {
62
+ console.error(`Error in ${signedFetchBody.url} endpoint: no response`)
63
+ return ['signedFetch returned no response', null, undefined]
64
+ }
65
+
66
+ if (!response.ok) {
67
+ const errorMessage = `${response.status} ${response.statusText}`
68
+ console.error(`Error in ${signedFetchBody.url} endpoint`, { response })
69
+ return [errorMessage, null, response.status]
70
+ }
71
+
72
+ let body: T
73
+ try {
74
+ // JSON.parse throws synchronously, so it can't be wrapped with tryCatch (the
75
+ // throw would happen while evaluating the argument, rejecting this promise).
76
+ body = JSON.parse(response.body || '{}')
77
+ } catch {
78
+ console.error(`Failed to parse response from ${signedFetchBody.url}`)
79
+ return ['Failed to parse response', null, response.status]
80
+ }
81
+
82
+ return [null, (body ?? {}) as T, response.status]
83
+ }
@@ -1,118 +0,0 @@
1
- /// <reference types="@dcl/js-runtime" />
2
- import { ExplorerUi, GrowOnlyValueSetComponentDefinition, PBExplorerItemPurchaseResult, PBExplorerUiEventsResult } from '@dcl/ecs';
3
- import { OpenExplorerUiResult } from '~system/RestrictedActions';
4
- export { ExplorerUi, OpenExplorerUiResult };
5
- /** @public */
6
- export type CorrelatedEvent = {
7
- timestamp: number;
8
- requestId: number;
9
- };
10
- /**
11
- * A named stream of events that this helper can collect and stop on.
12
- * @public
13
- */
14
- export type Channel<N extends string = string, T extends CorrelatedEvent = CorrelatedEvent> = {
15
- readonly name: N;
16
- readonly component: GrowOnlyValueSetComponentDefinition<T>;
17
- };
18
- /** @public */
19
- export type AnyChannel = Channel<string, any>;
20
- /**
21
- * Wraps a grow-only result component as a {@link Channel}. The name must be unique within a scene.
22
- * @public
23
- */
24
- export declare function channel<N extends string, T extends CorrelatedEvent>(name: N, component: GrowOnlyValueSetComponentDefinition<T>): Channel<N, T>;
25
- /** Panel lifecycle events: opened and closed. @public */
26
- export declare const ExplorerUiEvents: Channel<'explorerUi', PBExplorerUiEventsResult>;
27
- /** Outcome of an `EU_ITEM_PURCHASE` flow: purchased, dismissed or failed. @public */
28
- export declare const ItemPurchase: Channel<'itemPurchase', PBExplorerItemPurchaseResult>;
29
- /**
30
- * The `$case` names of a channel's event union.
31
- * @public
32
- */
33
- export type CaseOf<C extends AnyChannel> = C extends Channel<any, infer T> ? Extract<T[keyof T], {
34
- $case: string;
35
- }>['$case'] : never;
36
- /** @public */
37
- export type Variant<C extends AnyChannel = AnyChannel, K extends string = string> = {
38
- readonly channel: C;
39
- readonly $case: K;
40
- };
41
- /** @public */
42
- export declare function variant<C extends AnyChannel, K extends CaseOf<C>>(ch: C, $case: K): Variant<C, K>;
43
- /**
44
- * What ends the wait before the panel closes: any event on a channel, or one `$case` of it.
45
- * @public
46
- */
47
- export type StopCondition = AnyChannel | Variant;
48
- /** @public */
49
- export type Collected<C> = C extends Channel<infer N, infer T> ? {
50
- channel: N;
51
- event: T;
52
- } : never;
53
- /**
54
- * How the wait ended.
55
- * - `closed`: the panel this call opened was closed.
56
- * - `matched`: `until` fired first, so the panel may still be on screen.
57
- * - `notOpened`: this call did not open anything. `openResult` says why.
58
- * - `timedOut`: `timeoutMs` elapsed first.
59
- *
60
- * `events` always holds everything collected, the event that stopped the wait included.
61
- * @public
62
- */
63
- export type WaitOutcome<C extends readonly AnyChannel[]> = {
64
- $case: 'closed';
65
- events: readonly Collected<C[number]>[];
66
- } | {
67
- $case: 'matched';
68
- events: readonly Collected<C[number]>[];
69
- } | {
70
- $case: 'notOpened';
71
- openResult: OpenExplorerUiResult;
72
- } | {
73
- $case: 'timedOut';
74
- events: readonly Collected<C[number]>[];
75
- };
76
- /** @public */
77
- export type WaitOptions<C extends readonly AnyChannel[]> = {
78
- /** Channels whose events land in `events`. Defaults to none. */
79
- collect?: C;
80
- /** Stops the wait early. The panel closing always stops it regardless. */
81
- until?: StopCondition;
82
- /**
83
- * Bounds the wait, in milliseconds. On expiry the promise resolves with `$case: 'timedOut'`.
84
- * No default, because a panel can stay open for minutes. Without it, an explorer that opens
85
- * a panel and then emits no `closed` leaves the promise pending for the scene's whole life.
86
- */
87
- timeoutMs?: number;
88
- };
89
- /** @public */
90
- export type OpenExplorerUiRequest = {
91
- ui: Exclude<ExplorerUi, ExplorerUi.EU_ITEM_PURCHASE>;
92
- itemPurchase?: undefined;
93
- } | {
94
- ui: ExplorerUi.EU_ITEM_PURCHASE;
95
- itemPurchase: {
96
- urn: string;
97
- };
98
- };
99
- /**
100
- * Signature of {@link openExplorerUiAndWait}. The second overload takes a bare `ui`, so a
101
- * computed panel value needs no narrowing.
102
- * @public
103
- */
104
- export interface OpenExplorerUiAndWait {
105
- <const C extends readonly AnyChannel[] = []>(request: OpenExplorerUiRequest, options?: WaitOptions<C>): Promise<WaitOutcome<C>>;
106
- (ui: Exclude<ExplorerUi, ExplorerUi.EU_ITEM_PURCHASE>): Promise<WaitOutcome<[]>>;
107
- }
108
- /**
109
- * Opens an explorer panel and waits for the session it started. The wait ends when the panel
110
- * closes. See {@link WaitOptions} to collect events, to stop earlier or to bound the wait.
111
- *
112
- * Every lifecycle outcome resolves, see {@link WaitOutcome}. The promise rejects only when an
113
- * event cannot be attributed to a call at all: an explorer too old to echo `request_id`
114
- * paired with a panel that can coexist with another one.
115
- *
116
- * @public
117
- */
118
- export declare const openExplorerUiAndWait: OpenExplorerUiAndWait;