@dcl/sdk 7.28.1-34901226656.commit-44656ea → 7.28.1-34955459026.commit-7db2d46

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 (76) 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 +161 -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/players/index.d.ts +0 -1
  29. package/players/index.js +6 -4
  30. package/server/env-var.d.ts +15 -0
  31. package/server/env-var.js +31 -0
  32. package/server/index.d.ts +2 -0
  33. package/server/index.js +3 -0
  34. package/server/storage/constants.d.ts +79 -0
  35. package/server/storage/constants.js +19 -0
  36. package/server/storage/index.d.ts +45 -0
  37. package/server/storage/index.js +51 -0
  38. package/server/storage/player.d.ts +45 -0
  39. package/server/storage/player.js +198 -0
  40. package/server/storage/scene.d.ts +40 -0
  41. package/server/storage/scene.js +188 -0
  42. package/server/storage/value-cache.d.ts +1 -0
  43. package/server/storage/value-cache.js +52 -0
  44. package/server/storage/write-queue.d.ts +1 -0
  45. package/server/storage/write-queue.js +71 -0
  46. package/server/storage-url.d.ts +13 -0
  47. package/server/storage-url.js +42 -0
  48. package/server/utils.d.ts +35 -0
  49. package/server/utils.js +61 -0
  50. package/src/atom.ts +98 -0
  51. package/src/future.ts +38 -0
  52. package/src/internal/transports/rendererTransport.ts +4 -1
  53. package/src/network/binary-message-bus.ts +9 -4
  54. package/src/network/chunking.ts +45 -0
  55. package/src/network/events/implementation.ts +271 -0
  56. package/src/network/events/index.ts +48 -0
  57. package/src/network/events/protocol.ts +94 -0
  58. package/src/network/events/registry.ts +18 -0
  59. package/src/network/index.ts +40 -3
  60. package/src/network/message-bus-sync.ts +176 -112
  61. package/src/network/server/index.ts +301 -0
  62. package/src/network/server/utils.ts +189 -0
  63. package/src/network/state.ts +3 -4
  64. package/src/players/index.ts +5 -4
  65. package/src/server/env-var.ts +36 -0
  66. package/src/server/index.ts +12 -0
  67. package/src/server/storage/constants.ts +111 -0
  68. package/src/server/storage/index.ts +74 -0
  69. package/src/server/storage/player.ts +289 -0
  70. package/src/server/storage/scene.ts +270 -0
  71. package/src/server/storage/value-cache.ts +92 -0
  72. package/src/server/storage/write-queue.ts +131 -0
  73. package/src/server/storage-url.ts +49 -0
  74. package/src/server/utils.ts +76 -0
  75. package/src/testing/runtime.ts +3 -0
  76. package/testing/runtime.js +4 -1
@@ -0,0 +1,270 @@
1
+ import { getStorageServerUrl } from '../storage-url'
2
+ import { assertIsServer, wrapSignedFetch } from '../utils'
3
+ import {
4
+ createStorageConfig,
5
+ GetOptions,
6
+ GetValuesOptions,
7
+ GetValuesResult,
8
+ MODULE_NAME,
9
+ SetOptions,
10
+ StorageConfigState
11
+ } from './constants'
12
+ import { createValueCache } from './value-cache'
13
+ import { createWriteQueue } from './write-queue'
14
+
15
+ /**
16
+ * Scene-scoped storage interface for key-value pairs from the Server Side Storage service.
17
+ * This is NOT filesystem storage - data is stored in the remote storage service.
18
+ */
19
+ export interface ISceneStorage {
20
+ /**
21
+ * Retrieves a value from scene storage by key from the Server Side Storage service.
22
+ *
23
+ * By default (cacheReads), values read or written during the last cacheMaxAgeMs
24
+ * are served from a local cache without a network request, including confirmed
25
+ * "not found" results. Concurrent gets for the same key share one request.
26
+ * Out-of-band writers (e.g. CLI storage commands) may not be visible for up to
27
+ * cacheMaxAgeMs — pass { fresh: true } to force a network read.
28
+ * @param key - The key to retrieve
29
+ * @param options - Optional { fresh } to bypass the read cache
30
+ * @returns A promise that resolves to the parsed JSON value, or null if not found
31
+ */
32
+ get<T = unknown>(key: string, options?: GetOptions): Promise<T | null>
33
+
34
+ /**
35
+ * Stores a value in scene storage in the Server Side Storage service.
36
+ * @param key - The key to store the value under
37
+ * @param value - The value to store (will be JSON serialized)
38
+ * @param options - Optional { skipIfUnchanged } to skip the network write when the value is already stored
39
+ */
40
+ set<T = unknown>(key: string, value: T, options?: SetOptions): Promise<boolean>
41
+
42
+ /**
43
+ * Deletes a value from scene storage in the Server Side Storage service.
44
+ * @param key - The key to delete
45
+ * @returns A promise that resolves to true if deleted, false if not found
46
+ */
47
+ delete(key: string): Promise<boolean>
48
+
49
+ /**
50
+ * Returns key-value entries from scene storage, optionally filtered by prefix.
51
+ * Supports pagination via limit and offset.
52
+ * @param options - Optional { prefix, limit, offset } for filtering and pagination.
53
+ * @returns A promise that resolves to { data, pagination: { offset, total } } for pagination UI
54
+ */
55
+ getValues(options?: GetValuesOptions): Promise<GetValuesResult>
56
+ }
57
+
58
+ /**
59
+ * Creates scene-scoped storage that provides methods to interact with
60
+ * scene-specific key-value pairs from the Server Side Storage service.
61
+ * This module only works when running on server-side scenes.
62
+ * @internal
63
+ */
64
+ export const createSceneStorage = (config: StorageConfigState = createStorageConfig()): ISceneStorage => {
65
+ const cache = createValueCache(config)
66
+ // Each in-flight GET is tracked by a wrapper object whose identity marks
67
+ // ownership: set()/delete() drop the wrapper, detaching the pending GET so
68
+ // its stale response cannot overwrite the newer cache entry.
69
+ const inflightGets = new Map<string, { promise: Promise<unknown> }>()
70
+ // Writes to the same key are serialized (and rapid ones coalesced to the
71
+ // latest value) so the service commits them in issue order — overlapping
72
+ // PUTs would otherwise leave both the kept value and the cached value to
73
+ // response-order chance.
74
+ const writes = createWriteQueue()
75
+
76
+ async function executeSet(key: string, body: string): Promise<boolean> {
77
+ const baseUrl = await getStorageServerUrl()
78
+ const url = `${baseUrl}/values/${encodeURIComponent(key)}`
79
+
80
+ const [error] = await wrapSignedFetch({
81
+ url,
82
+ init: {
83
+ method: 'PUT',
84
+ headers: {
85
+ 'content-type': 'application/json'
86
+ },
87
+ body
88
+ }
89
+ })
90
+
91
+ // Either way the entry changed server-side (or may have): detach any
92
+ // overlapping in-flight GET so its stale response is not cached.
93
+ inflightGets.delete(key)
94
+
95
+ if (error) {
96
+ // The PUT may have reached the server, so the cached body is no
97
+ // longer reliable.
98
+ cache.delete(key)
99
+ console.error(`Failed to set storage value '${key}': ${error}`)
100
+ return false
101
+ }
102
+
103
+ cache.set(key, { body })
104
+ return true
105
+ }
106
+
107
+ async function executeDelete(key: string): Promise<boolean> {
108
+ const baseUrl = await getStorageServerUrl()
109
+ const url = `${baseUrl}/values/${encodeURIComponent(key)}`
110
+
111
+ const [error, , status] = await wrapSignedFetch({
112
+ url,
113
+ init: {
114
+ method: 'DELETE',
115
+ headers: {}
116
+ }
117
+ })
118
+
119
+ // Detach again: a GET may have started while the DELETE was in flight.
120
+ inflightGets.delete(key)
121
+
122
+ if (error) {
123
+ // A 404 still confirms the key is absent server-side.
124
+ if (status === 404) cache.setAbsent(key)
125
+ console.error(`Failed to delete storage value '${key}': ${error}`)
126
+ return false
127
+ }
128
+
129
+ cache.setAbsent(key)
130
+ return true
131
+ }
132
+
133
+ return {
134
+ async get<T = unknown>(key: string, options?: GetOptions): Promise<T | null> {
135
+ assertIsServer(MODULE_NAME)
136
+
137
+ if (config.cacheReads && !options?.fresh) {
138
+ const entry = cache.get(key)
139
+ if (entry?.absent) return null
140
+ // Parse per hit so each caller gets a fresh object (no shared mutation).
141
+ if (entry?.body !== undefined) return JSON.parse(entry.body).value as T
142
+ }
143
+
144
+ // Coalesce concurrent gets (even fresh ones: an in-flight response is
145
+ // milliseconds old, not TTL-stale) into a single network request.
146
+ const joined = inflightGets.get(key)
147
+ if (joined) return joined.promise as Promise<T | null>
148
+
149
+ const inflight = {} as { promise: Promise<T | null> }
150
+ inflight.promise = (async () => {
151
+ try {
152
+ const baseUrl = await getStorageServerUrl()
153
+ const url = `${baseUrl}/values/${encodeURIComponent(key)}`
154
+
155
+ const [error, data, status] = await wrapSignedFetch<{ value: T }>({ url })
156
+
157
+ const isOwner = inflightGets.get(key) === inflight
158
+
159
+ if (error) {
160
+ // A confirmed 404 is a first-class "absent" outcome, not a failure.
161
+ if (status === 404) {
162
+ if (isOwner) cache.setAbsent(key)
163
+ return null
164
+ }
165
+ console.error(`Failed to get storage value '${key}': ${error}`)
166
+ return null
167
+ }
168
+
169
+ if (data && data.value !== undefined) {
170
+ // Same serialization shape as set()'s PUT body, so a read followed by
171
+ // an unchanged write can be skipped.
172
+ const body = JSON.stringify({ value: data.value })
173
+ if (isOwner) cache.set(key, { body })
174
+ return data.value
175
+ }
176
+
177
+ // 200 with a missing value is ambiguous: neither a confirmed value
178
+ // nor a confirmed absence, so cache nothing.
179
+ return null
180
+ } finally {
181
+ if (inflightGets.get(key) === inflight) inflightGets.delete(key)
182
+ }
183
+ })()
184
+
185
+ inflightGets.set(key, inflight)
186
+ return inflight.promise
187
+ },
188
+
189
+ async set<T = unknown>(key: string, value: T, options?: SetOptions): Promise<boolean> {
190
+ assertIsServer(MODULE_NAME)
191
+
192
+ const body = JSON.stringify({ value })
193
+ const skipIfUnchanged = options?.skipIfUnchanged ?? config.skipIfUnchanged
194
+
195
+ // Dedup against confirmed state only while no write is pending — a
196
+ // pending write makes the cache momentarily stale; enqueue() coalesces
197
+ // against pending writes instead.
198
+ if (skipIfUnchanged && writes.pending(key) === undefined && cache.get(key)?.body === body) {
199
+ return true
200
+ }
201
+
202
+ return writes.enqueue(key, body, (b) => executeSet(key, b as string), skipIfUnchanged)
203
+ },
204
+
205
+ async delete(key: string): Promise<boolean> {
206
+ assertIsServer(MODULE_NAME)
207
+
208
+ // Invalidate immediately — even while the DELETE waits behind other
209
+ // writes, reads must not serve the doomed value, and a stale
210
+ // "unchanged" skip would lose a future write.
211
+ cache.delete(key)
212
+ inflightGets.delete(key)
213
+
214
+ return writes.enqueue(key, null, () => executeDelete(key), true)
215
+ },
216
+
217
+ async getValues(options?: GetValuesOptions): Promise<GetValuesResult> {
218
+ assertIsServer(MODULE_NAME)
219
+
220
+ const { prefix, limit, offset } = options ?? {}
221
+ const baseUrl = await getStorageServerUrl()
222
+ const parts: string[] = []
223
+
224
+ if (!!prefix) {
225
+ parts.push(`prefix=${encodeURIComponent(prefix)}`)
226
+ }
227
+
228
+ if (!!limit) {
229
+ parts.push(`limit=${limit}`)
230
+ }
231
+
232
+ if (!!offset) {
233
+ parts.push(`offset=${offset}`)
234
+ }
235
+
236
+ const query = parts.join('&')
237
+ const url = query ? `${baseUrl}/values?${query}` : `${baseUrl}/values`
238
+
239
+ const [error, response] = await wrapSignedFetch<GetValuesResult>({ url })
240
+
241
+ if (error) {
242
+ console.error(`Failed to get storage values: ${error}`)
243
+ return { data: [], pagination: { offset: 0, total: 0 } }
244
+ }
245
+
246
+ const data = response?.data ?? []
247
+
248
+ // Seed the per-key cache so subsequent get()/set() on returned keys can
249
+ // skip the network. Only keys with no live entry and no pending write
250
+ // are seeded: existing per-key state comes from a confirmed operation
251
+ // that this page snapshot — whose request started earlier — must not
252
+ // clobber with stale data. Absence is never seeded (prefix/pagination
253
+ // make it non-authoritative). A page larger than cacheMaxEntries churns
254
+ // the cache; entries repopulate lazily.
255
+ for (const entry of data) {
256
+ if (entry.value !== undefined && !writes.isPending(entry.key) && cache.get(entry.key) === undefined) {
257
+ cache.set(entry.key, { body: JSON.stringify({ value: entry.value }) })
258
+ }
259
+ }
260
+
261
+ const requestedOffset = offset ?? 0
262
+ const pagination = {
263
+ offset: response?.pagination?.offset ?? requestedOffset,
264
+ total: response?.pagination?.total ?? data.length
265
+ }
266
+
267
+ return { data, pagination }
268
+ }
269
+ }
270
+ }
@@ -0,0 +1,92 @@
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
+ }
33
+
34
+ /**
35
+ * Creates the bounded value cache shared by a storage scope.
36
+ * @internal
37
+ */
38
+ export function createValueCache(config: StorageConfigState): ValueCache {
39
+ const entries = new Map<string, CacheEntry & { storedAt: number }>()
40
+
41
+ function insert(key: string, entry: CacheEntry): void {
42
+ // Delete + re-insert moves refreshed keys to the end of the Map's
43
+ // insertion order, so eviction below drops the least-recently-written.
44
+ entries.delete(key)
45
+ entries.set(key, { ...entry, storedAt: Date.now() })
46
+
47
+ // Guard against misconfiguration: a negative bound would loop forever on
48
+ // an empty map, and a NaN bound would silently disable eviction.
49
+ const maxEntries = Number.isFinite(config.cacheMaxEntries)
50
+ ? Math.max(0, config.cacheMaxEntries)
51
+ : DEFAULT_STORAGE_CONFIG.cacheMaxEntries
52
+
53
+ while (entries.size > maxEntries) {
54
+ entries.delete(entries.keys().next().value!)
55
+ }
56
+ }
57
+
58
+ return {
59
+ get(key: string): CacheEntry | undefined {
60
+ const entry = entries.get(key)
61
+ if (!entry) return undefined
62
+
63
+ // Guard against misconfiguration, mirroring cacheMaxEntries: a NaN
64
+ // bound would silently disable expiry (NaN comparisons are false).
65
+ // Negative values need no guard — they just expire everything.
66
+ const maxAgeMs = Number.isFinite(config.cacheMaxAgeMs)
67
+ ? config.cacheMaxAgeMs
68
+ : DEFAULT_STORAGE_CONFIG.cacheMaxAgeMs
69
+
70
+ // Lazy max-age expiry: storedAt is never refreshed on hits, so the age
71
+ // bounds the time since the last actual network confirmation.
72
+ if (Date.now() - entry.storedAt > maxAgeMs) {
73
+ entries.delete(key)
74
+ return undefined
75
+ }
76
+
77
+ return entry
78
+ },
79
+
80
+ set(key: string, entry: { body: string }): void {
81
+ insert(key, entry)
82
+ },
83
+
84
+ setAbsent(key: string): void {
85
+ insert(key, { absent: true })
86
+ },
87
+
88
+ delete(key: string): void {
89
+ entries.delete(key)
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * A pending write operation. `body` is the serialized PUT payload, or null
3
+ * for a DELETE. Callers coalesced into the op share its promise.
4
+ * @internal
5
+ */
6
+ interface PendingOp {
7
+ body: string | null
8
+ execute: (body: string | null) => Promise<boolean>
9
+ promise: Promise<boolean>
10
+ resolve: (result: boolean) => void
11
+ }
12
+
13
+ interface KeyState {
14
+ /** The op currently on the network. */
15
+ active: PendingOp
16
+ /** At most one queued op; later writes replace its payload (latest wins). */
17
+ queued?: PendingOp
18
+ }
19
+
20
+ /**
21
+ * Serializes writes per key so the service commits them in issue order.
22
+ * Overlapping PUTs from the single scene server would otherwise race: the
23
+ * server keeps whichever request it processes last, while the local cache
24
+ * keeps whichever response arrives last — either can disagree with the last
25
+ * set() issued. With at most one in-flight op per key and a single queued
26
+ * "latest value" slot, the server's final state always matches the last
27
+ * write issued, and N rapid writes collapse into at most 2 network calls.
28
+ * @internal
29
+ */
30
+ export interface WriteQueue {
31
+ /**
32
+ * Body of the latest issued write for the key (the queued op if present,
33
+ * else the in-flight one): a string for a PUT, null for a DELETE,
34
+ * undefined when no write is pending.
35
+ */
36
+ pending(key: string): string | null | undefined
37
+ /** True while any write for the key is in flight or queued. */
38
+ isPending(key: string): boolean
39
+ /**
40
+ * Issues a write. If one is in flight, the new op is queued — replacing any
41
+ * already-queued op, whose callers then follow this op's outcome (their
42
+ * value was superseded before it could ever be observed). An op identical
43
+ * to the queued one joins it; `joinActive` additionally allows joining an
44
+ * identical in-flight op (only valid for dedup-tolerant callers, since that
45
+ * op was issued before this call).
46
+ */
47
+ enqueue(
48
+ key: string,
49
+ body: string | null,
50
+ execute: (body: string | null) => Promise<boolean>,
51
+ joinActive: boolean
52
+ ): Promise<boolean>
53
+ }
54
+
55
+ /**
56
+ * Creates the per-key write serializer shared by a storage scope.
57
+ * @internal
58
+ */
59
+ export function createWriteQueue(): WriteQueue {
60
+ const keys = new Map<string, KeyState>()
61
+
62
+ function makeOp(body: string | null, execute: PendingOp['execute']): PendingOp {
63
+ let resolve!: (result: boolean) => void
64
+ const promise = new Promise<boolean>((r) => (resolve = r))
65
+ return { body, execute, promise, resolve }
66
+ }
67
+
68
+ async function drain(key: string, state: KeyState): Promise<void> {
69
+ for (;;) {
70
+ const op = state.active
71
+ let result = false
72
+ try {
73
+ result = await op.execute(op.body)
74
+ } catch {
75
+ // Executors report failures via their boolean result; a throw is
76
+ // unexpected but must not wedge the queue.
77
+ }
78
+ op.resolve(result)
79
+
80
+ if (state.queued) {
81
+ state.active = state.queued
82
+ state.queued = undefined
83
+ } else {
84
+ keys.delete(key)
85
+ return
86
+ }
87
+ }
88
+ }
89
+
90
+ return {
91
+ pending(key: string): string | null | undefined {
92
+ const state = keys.get(key)
93
+ if (!state) return undefined
94
+ return (state.queued ?? state.active).body
95
+ },
96
+
97
+ isPending(key: string): boolean {
98
+ return keys.has(key)
99
+ },
100
+
101
+ enqueue(key: string, body: string | null, execute: PendingOp['execute'], joinActive: boolean): Promise<boolean> {
102
+ const state = keys.get(key)
103
+
104
+ if (!state) {
105
+ const op = makeOp(body, execute)
106
+ const newState: KeyState = { active: op }
107
+ keys.set(key, newState)
108
+ void drain(key, newState)
109
+ return op.promise
110
+ }
111
+
112
+ if (state.queued) {
113
+ // A queued op has not started, so it is issued "after" this caller
114
+ // either way: join it when identical, supersede it otherwise.
115
+ if (state.queued.body !== body) {
116
+ state.queued.body = body
117
+ state.queued.execute = execute
118
+ }
119
+ return state.queued.promise
120
+ }
121
+
122
+ if (joinActive && state.active.body === body) {
123
+ return state.active.promise
124
+ }
125
+
126
+ const op = makeOp(body, execute)
127
+ state.queued = op
128
+ return op.promise
129
+ }
130
+ }
131
+ }
@@ -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,76 @@
1
+ import { signedFetch, SignedFetchRequest } from '~system/SignedFetch'
2
+ import { isServer } from '../network'
3
+
4
+ /**
5
+ * Validates that the code is running on a server-side scene.
6
+ * Throws an error if called from a client-side context.
7
+ *
8
+ * @param moduleName - The name of the module for the error message
9
+ * @throws Error if not running on a server-side scene
10
+ */
11
+ export function assertIsServer(moduleName: string): void {
12
+ if (!isServer()) {
13
+ throw new Error(`${moduleName} is only available on server-side scenes`)
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Result type for operations that can fail.
19
+ * Returns a tuple of [error, null] on failure or [null, data] on success.
20
+ */
21
+ export type Result<T, E = string> = [E, null] | [null, T]
22
+
23
+ /**
24
+ * Extended result type that includes HTTP status code information.
25
+ */
26
+ export type FetchResult<T> = [string, null, number?] | [null, T, number]
27
+
28
+ /**
29
+ * Wraps a promise to catch errors and return a Result tuple.
30
+ * This allows for cleaner error handling without try-catch blocks.
31
+ *
32
+ * @param promise - The promise to wrap
33
+ * @returns A tuple of [error, null] on failure or [null, data] on success
34
+ */
35
+ export async function tryCatch<T, E = Error>(promise: Promise<T>): Promise<Result<T, E>> {
36
+ try {
37
+ const data = await promise
38
+ return [null, data]
39
+ } catch (error) {
40
+ return [error as E, null]
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Wraps signedFetch with automatic error handling and JSON parsing.
46
+ * Returns a FetchResult tuple with parsed JSON data or error message and status code.
47
+ *
48
+ * @param signedFetchBody - The signedFetch request configuration
49
+ * @returns A tuple of [error, null, statusCode?] on failure or [null, data, statusCode] on success
50
+ */
51
+ export async function wrapSignedFetch<T = unknown>(signedFetchBody: SignedFetchRequest): Promise<FetchResult<T>> {
52
+ const [error, response] = await tryCatch(signedFetch(signedFetchBody))
53
+
54
+ if (error) {
55
+ console.error(`Error in ${signedFetchBody.url} endpoint`, { error })
56
+ return [error.message, null, undefined]
57
+ }
58
+
59
+ if (!response.ok) {
60
+ const errorMessage = `${response.status} ${response.statusText}`
61
+ console.error(`Error in ${signedFetchBody.url} endpoint`, { response })
62
+ return [errorMessage, null, response.status]
63
+ }
64
+
65
+ let body: T
66
+ try {
67
+ // JSON.parse throws synchronously, so it can't be wrapped with tryCatch (the
68
+ // throw would happen while evaluating the argument, rejecting this promise).
69
+ body = JSON.parse(response.body || '{}')
70
+ } catch {
71
+ console.error(`Failed to parse response from ${signedFetchBody.url}`)
72
+ return ['Failed to parse response', null, response.status]
73
+ }
74
+
75
+ return [null, (body ?? {}) as T, response.status]
76
+ }
@@ -50,6 +50,7 @@ export function createTestRuntime(testingModule: TestingModule, engine: IEngine)
50
50
  // continue to run until it reaches a yield point
51
51
  function scheduleValue(value: any, env: RunnerEnvironment) {
52
52
  if (value && typeof value === 'object' && typeof value.then === 'function') {
53
+ console.log('⏱️ yield promise')
53
54
  // if the value is a promise, schedule it to be awaited after the current frame is finished
54
55
  nextTickFuture.push(async () => {
55
56
  try {
@@ -59,12 +60,14 @@ export function createTestRuntime(testingModule: TestingModule, engine: IEngine)
59
60
  }
60
61
  })
61
62
  } else if (typeof value === 'function') {
63
+ console.log('⏱️ yield function')
62
64
  // if the value is a function, schedule it to be called on the next frame
63
65
  nextTickFuture.push(() => {
64
66
  scheduleValue(value(), env)
65
67
  })
66
68
  return
67
69
  } else if (typeof value === 'undefined' || value === null) {
70
+ console.log('⏱️ yield')
68
71
  // if the value is undefined or null, continue processing the generator the next frame
69
72
  nextTickFuture.push(() => {
70
73
  consumeGenerator(env)