@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,111 @@
1
+ export const MODULE_NAME = 'Storage'
2
+
3
+ /**
4
+ * Options for getValues pagination and filtering.
5
+ */
6
+ export interface GetValuesOptions {
7
+ prefix?: string
8
+ limit?: number
9
+ offset?: number
10
+ }
11
+
12
+ /**
13
+ * Result of getValues with pagination metadata.
14
+ */
15
+ export interface GetValuesResult {
16
+ /** Key-value entries for the current page. */
17
+ data: Array<{ key: string; value: unknown }>
18
+ pagination: {
19
+ offset: number
20
+ total: number
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Per-call options for Storage get().
26
+ */
27
+ export interface GetOptions {
28
+ /**
29
+ * When true, bypasses the read cache and forces a network read (the result
30
+ * still refreshes the cache). Concurrent gets for the same key still share
31
+ * one in-flight request. Default: false.
32
+ */
33
+ fresh?: boolean
34
+ }
35
+
36
+ /**
37
+ * Per-call options for Storage set().
38
+ */
39
+ export interface SetOptions {
40
+ /**
41
+ * When true, skips the network write if the serialized value matches the
42
+ * last value known to be stored for this key (from a previous successful
43
+ * set() or get()). Overrides the configured default. Default: true.
44
+ */
45
+ skipIfUnchanged?: boolean
46
+ }
47
+
48
+ /**
49
+ * Module-wide configuration for Storage, applied to both scene-scoped and
50
+ * player-scoped storage via Storage.configure().
51
+ */
52
+ export interface StorageOptions {
53
+ /**
54
+ * Default for set()'s skipIfUnchanged when not passed per call. A skip only
55
+ * suppresses a proven no-op: the exact serialized value was confirmed stored
56
+ * by a previous network round-trip within cacheMaxAgeMs. Default: true.
57
+ */
58
+ skipIfUnchanged?: boolean
59
+ /**
60
+ * When true, get() serves values from the local cache within cacheMaxAgeMs,
61
+ * including confirmed "not found" results, without a network request.
62
+ * Out-of-band writers (e.g. CLI storage commands) may not be visible for up
63
+ * to cacheMaxAgeMs; pass { fresh: true } per call to force a network read.
64
+ * Default: true.
65
+ */
66
+ cacheReads?: boolean
67
+ /**
68
+ * Max cache entries per scope (scene / player) before oldest entries are
69
+ * evicted. Entries hold the serialized value body, so memory scales with
70
+ * value size — this bounds entry count, not bytes. Default: 512.
71
+ */
72
+ cacheMaxEntries?: number
73
+ /**
74
+ * Max age of a cache entry in milliseconds, bounding both read-cache
75
+ * staleness and write-dedup trust; older entries are treated as unknown.
76
+ * The default is deliberately short so out-of-band writers (CLI storage
77
+ * commands, dashboards) become visible within a minute, while still
78
+ * absorbing hot per-frame reads. Default: 60000 (1 minute).
79
+ */
80
+ cacheMaxAgeMs?: number
81
+ }
82
+
83
+ /**
84
+ * Fully-resolved storage configuration shared by scene and player storage.
85
+ * @internal
86
+ */
87
+ export interface StorageConfigState {
88
+ skipIfUnchanged: boolean
89
+ cacheReads: boolean
90
+ cacheMaxEntries: number
91
+ cacheMaxAgeMs: number
92
+ }
93
+
94
+ /**
95
+ * Default values for the resolved storage configuration.
96
+ * @internal
97
+ */
98
+ export const DEFAULT_STORAGE_CONFIG: Readonly<StorageConfigState> = {
99
+ skipIfUnchanged: true,
100
+ cacheReads: true,
101
+ cacheMaxEntries: 512,
102
+ cacheMaxAgeMs: 60 * 1000
103
+ }
104
+
105
+ /**
106
+ * Creates a mutable config object, later mutated by Storage.configure().
107
+ * @internal
108
+ */
109
+ export function createStorageConfig(overrides?: StorageOptions): StorageConfigState {
110
+ return { ...DEFAULT_STORAGE_CONFIG, ...overrides }
111
+ }
@@ -0,0 +1,74 @@
1
+ import { createStorageConfig, StorageOptions } from './constants'
2
+ import { createSceneStorage, ISceneStorage } from './scene'
3
+ import { createPlayerStorage, IPlayerStorage } from './player'
4
+
5
+ // Re-export interfaces and types
6
+ export { GetOptions, GetValuesOptions, GetValuesResult, SetOptions, StorageOptions } from './constants'
7
+ export { ISceneStorage } from './scene'
8
+ export { IPlayerStorage } from './player'
9
+
10
+ /**
11
+ * Storage interface with methods for scene-scoped and player-scoped storage.
12
+ */
13
+ export interface IStorage extends ISceneStorage {
14
+ /** Player-scoped storage for key-value pairs */
15
+ player: IPlayerStorage
16
+
17
+ /**
18
+ * Sets module-wide defaults for both scene-scoped and player-scoped storage.
19
+ * Merges the given partial options into the current configuration.
20
+ * Note: cacheMaxEntries applies per scope (scene and player each keep their
21
+ * own cache) and bounds entry count, not bytes (entries hold serialized
22
+ * value bodies).
23
+ */
24
+ configure(options: StorageOptions): void
25
+ }
26
+
27
+ /**
28
+ * Creates the Storage module with scene-scoped and player-scoped storage.
29
+ */
30
+ const createStorage = (): IStorage => {
31
+ const config = createStorageConfig()
32
+ const sceneStorage = createSceneStorage(config)
33
+ const playerStorage = createPlayerStorage(config)
34
+
35
+ return {
36
+ // Spread scene storage methods at top level
37
+ get: sceneStorage.get,
38
+ set: sceneStorage.set,
39
+ delete: sceneStorage.delete,
40
+ getValues: sceneStorage.getValues,
41
+ // Keep player as nested property
42
+ player: playerStorage,
43
+ configure(options: StorageOptions): void {
44
+ for (const [key, value] of Object.entries(options)) {
45
+ if (value !== undefined) (config as unknown as Record<string, unknown>)[key] = value
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Storage provides methods to store and retrieve key-value data from the
53
+ * Server Side Storage service.
54
+ *
55
+ * - Use Storage.get/set/delete/getValues for scene-scoped storage
56
+ * - Use Storage.player.get/set/delete/getValues for player-scoped storage
57
+ * - Use Storage.configure to change module-wide defaults (e.g. skipIfUnchanged, cacheReads)
58
+ *
59
+ * Reads are cached by default: get() serves values known from a network
60
+ * round-trip within the last cacheMaxAgeMs (including confirmed "not found"
61
+ * results) without hitting the service, and concurrent gets for the same key
62
+ * share one request. Overlapping writes to the same key are serialized so the
63
+ * service commits them in issue order, and rapid writes coalesce to the
64
+ * latest value; every set()/delete() resolves once its value (or the newer
65
+ * value that superseded it) is durably applied. By default (skipIfUnchanged)
66
+ * an unchanged set is skipped, but only when a previous confirmed round-trip
67
+ * proved the exact value is already stored. Out-of-band writers (e.g. CLI storage commands)
68
+ * may not be visible for up to cacheMaxAgeMs; use get(key, { fresh: true })
69
+ * for an authoritative read or Storage.configure({ cacheReads: false }) to
70
+ * disable read caching.
71
+ *
72
+ * This module only works when running on server-side scenes.
73
+ */
74
+ export const Storage: IStorage = createStorage()
@@ -0,0 +1,289 @@
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
+ * Player-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 IPlayerStorage {
20
+ /**
21
+ * Retrieves a value from a player's 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 player and key share one
26
+ * request. Out-of-band writers (e.g. CLI storage commands) may not be visible
27
+ * for up to cacheMaxAgeMs — pass { fresh: true } to force a network read.
28
+ * @param address - The player's wallet address
29
+ * @param key - The key to retrieve
30
+ * @param options - Optional { fresh } to bypass the read cache
31
+ * @returns A promise that resolves to the parsed JSON value, or null if not found
32
+ */
33
+ get<T = unknown>(address: string, key: string, options?: GetOptions): Promise<T | null>
34
+
35
+ /**
36
+ * Stores a value in a player's storage in the Server Side Storage service.
37
+ * @param address - The player's wallet address
38
+ * @param key - The key to store the value under
39
+ * @param value - The value to store (will be JSON serialized)
40
+ * @param options - Optional { skipIfUnchanged } to skip the network write when the value is already stored
41
+ * @returns A promise that resolves to true if successful, false otherwise
42
+ */
43
+ set<T = unknown>(address: string, key: string, value: T, options?: SetOptions): Promise<boolean>
44
+
45
+ /**
46
+ * Deletes a value from a player's storage in the Server Side Storage service.
47
+ * @param address - The player's wallet address
48
+ * @param key - The key to delete
49
+ * @returns A promise that resolves to true if deleted, false if not found
50
+ */
51
+ delete(address: string, key: string): Promise<boolean>
52
+
53
+ /**
54
+ * Returns key-value entries from a player's storage, optionally filtered by prefix.
55
+ * Supports pagination via limit and offset.
56
+ * @param address - The player's wallet address
57
+ * @param options - Optional { prefix, limit, offset } for filtering and pagination.
58
+ * @returns A promise that resolves to { data, pagination: { offset, total } } for pagination UI
59
+ */
60
+ getValues(address: string, options?: GetValuesOptions): Promise<GetValuesResult>
61
+ }
62
+
63
+ /**
64
+ * Creates player-scoped storage that provides methods to interact with
65
+ * player-specific key-value pairs from the Server Side Storage service.
66
+ * This module only works when running on server-side scenes.
67
+ * @internal
68
+ */
69
+ export const createPlayerStorage = (config: StorageConfigState = createStorageConfig()): IPlayerStorage => {
70
+ const cache = createValueCache(config)
71
+ // Each in-flight GET is tracked by a wrapper object whose identity marks
72
+ // ownership: set()/delete() drop the wrapper, detaching the pending GET so
73
+ // its stale response cannot overwrite the newer cache entry.
74
+ const inflightGets = new Map<string, { promise: Promise<unknown> }>()
75
+
76
+ // Ethereum addresses are case-insensitive (checksum casing only), so
77
+ // mixed-case callers must share the same cache entry. The NUL separator
78
+ // cannot appear in an address, making the pair unambiguous.
79
+ const cacheKey = (address: string, key: string) => `${address.toLowerCase()}\u0000${key}`
80
+
81
+ // Writes to the same player key are serialized (and rapid ones coalesced to
82
+ // the latest value) so the service commits them in issue order — overlapping
83
+ // PUTs would otherwise leave both the kept value and the cached value to
84
+ // response-order chance. Keyed by the same case-insensitive cache key.
85
+ const writes = createWriteQueue()
86
+
87
+ async function executeSet(address: string, key: string, ck: string, body: string): Promise<boolean> {
88
+ const baseUrl = await getStorageServerUrl()
89
+ const url = `${baseUrl}/players/${encodeURIComponent(address)}/values/${encodeURIComponent(key)}`
90
+
91
+ const [error] = await wrapSignedFetch({
92
+ url,
93
+ init: {
94
+ method: 'PUT',
95
+ headers: {
96
+ 'content-type': 'application/json'
97
+ },
98
+ body
99
+ }
100
+ })
101
+
102
+ // Either way the entry changed server-side (or may have): detach any
103
+ // overlapping in-flight GET so its stale response is not cached.
104
+ inflightGets.delete(ck)
105
+
106
+ if (error) {
107
+ // The PUT may have reached the server, so the cached body is no
108
+ // longer reliable.
109
+ cache.delete(ck)
110
+ console.error(`Failed to set player storage value '${key}' for '${address}': ${error}`)
111
+ return false
112
+ }
113
+
114
+ cache.set(ck, { body })
115
+ return true
116
+ }
117
+
118
+ async function executeDelete(address: string, key: string, ck: string): Promise<boolean> {
119
+ const baseUrl = await getStorageServerUrl()
120
+ const url = `${baseUrl}/players/${encodeURIComponent(address)}/values/${encodeURIComponent(key)}`
121
+
122
+ const [error, , status] = await wrapSignedFetch({
123
+ url,
124
+ init: {
125
+ method: 'DELETE',
126
+ headers: {}
127
+ }
128
+ })
129
+
130
+ // Detach again: a GET may have started while the DELETE was in flight.
131
+ inflightGets.delete(ck)
132
+
133
+ if (error) {
134
+ // A 404 still confirms the key is absent server-side.
135
+ if (status === 404) cache.setAbsent(ck)
136
+ console.error(`Failed to delete player storage value '${key}' for '${address}': ${error}`)
137
+ return false
138
+ }
139
+
140
+ cache.setAbsent(ck)
141
+ return true
142
+ }
143
+
144
+ return {
145
+ async get<T = unknown>(address: string, key: string, options?: GetOptions): Promise<T | null> {
146
+ assertIsServer(MODULE_NAME)
147
+
148
+ const ck = cacheKey(address, key)
149
+
150
+ if (config.cacheReads && !options?.fresh) {
151
+ const entry = cache.get(ck)
152
+ if (entry?.absent) return null
153
+ // Parse per hit so each caller gets a fresh object (no shared mutation).
154
+ if (entry?.body !== undefined) return JSON.parse(entry.body).value as T
155
+ }
156
+
157
+ // Coalesce concurrent gets (even fresh ones: an in-flight response is
158
+ // milliseconds old, not TTL-stale) into a single network request.
159
+ const joined = inflightGets.get(ck)
160
+ if (joined) return joined.promise as Promise<T | null>
161
+
162
+ const inflight = {} as { promise: Promise<T | null> }
163
+ inflight.promise = (async () => {
164
+ try {
165
+ const baseUrl = await getStorageServerUrl()
166
+ const url = `${baseUrl}/players/${encodeURIComponent(address)}/values/${encodeURIComponent(key)}`
167
+
168
+ const [error, data, status] = await wrapSignedFetch<{ value: T }>({ url })
169
+
170
+ const isOwner = inflightGets.get(ck) === inflight
171
+
172
+ if (error) {
173
+ // A confirmed 404 is a first-class "absent" outcome, not a failure.
174
+ if (status === 404) {
175
+ if (isOwner) cache.setAbsent(ck)
176
+ return null
177
+ }
178
+ console.error(`Failed to get player storage value '${key}' for '${address}': ${error}`)
179
+ return null
180
+ }
181
+
182
+ if (data && data.value !== undefined) {
183
+ // Same serialization shape as set()'s PUT body, so a read followed by
184
+ // an unchanged write can be skipped.
185
+ const body = JSON.stringify({ value: data.value })
186
+ if (isOwner) cache.set(ck, { body })
187
+ return data.value
188
+ }
189
+
190
+ // 200 with a missing value is ambiguous: neither a confirmed value
191
+ // nor a confirmed absence, so cache nothing.
192
+ return null
193
+ } finally {
194
+ if (inflightGets.get(ck) === inflight) inflightGets.delete(ck)
195
+ }
196
+ })()
197
+
198
+ inflightGets.set(ck, inflight)
199
+ return inflight.promise
200
+ },
201
+
202
+ async set<T = unknown>(address: string, key: string, value: T, options?: SetOptions): Promise<boolean> {
203
+ assertIsServer(MODULE_NAME)
204
+
205
+ const ck = cacheKey(address, key)
206
+ const body = JSON.stringify({ value })
207
+ const skipIfUnchanged = options?.skipIfUnchanged ?? config.skipIfUnchanged
208
+
209
+ // Dedup against confirmed state only while no write is pending — a
210
+ // pending write makes the cache momentarily stale; enqueue() coalesces
211
+ // against pending writes instead.
212
+ if (skipIfUnchanged && writes.pending(ck) === undefined && cache.get(ck)?.body === body) {
213
+ return true
214
+ }
215
+
216
+ return writes.enqueue(ck, body, (b) => executeSet(address, key, ck, b as string), skipIfUnchanged)
217
+ },
218
+
219
+ async delete(address: string, key: string): Promise<boolean> {
220
+ assertIsServer(MODULE_NAME)
221
+
222
+ const ck = cacheKey(address, key)
223
+
224
+ // Invalidate immediately — even while the DELETE waits behind other
225
+ // writes, reads must not serve the doomed value, and a stale
226
+ // "unchanged" skip would lose a future write.
227
+ cache.delete(ck)
228
+ inflightGets.delete(ck)
229
+
230
+ return writes.enqueue(ck, null, () => executeDelete(address, key, ck), true)
231
+ },
232
+
233
+ async getValues(address: string, options?: GetValuesOptions): Promise<GetValuesResult> {
234
+ assertIsServer(MODULE_NAME)
235
+
236
+ const { prefix, limit, offset } = options ?? {}
237
+ const baseUrl = await getStorageServerUrl()
238
+ const parts: string[] = []
239
+
240
+ if (!!prefix) {
241
+ parts.push(`prefix=${encodeURIComponent(prefix)}`)
242
+ }
243
+
244
+ if (!!limit) {
245
+ parts.push(`limit=${limit}`)
246
+ }
247
+
248
+ if (!!offset) {
249
+ parts.push(`offset=${offset}`)
250
+ }
251
+
252
+ const query = parts.join('&')
253
+ const url = query
254
+ ? `${baseUrl}/players/${encodeURIComponent(address)}/values?${query}`
255
+ : `${baseUrl}/players/${encodeURIComponent(address)}/values`
256
+
257
+ const [error, response] = await wrapSignedFetch<GetValuesResult>({ url })
258
+
259
+ if (error) {
260
+ console.error(`Failed to get player storage values for '${address}': ${error}`)
261
+ return { data: [], pagination: { offset: 0, total: 0 } }
262
+ }
263
+
264
+ const data = response?.data ?? []
265
+
266
+ // Seed the per-key cache so subsequent get()/set() on returned keys can
267
+ // skip the network. Only keys with no live entry and no pending write
268
+ // are seeded: existing per-key state comes from a confirmed operation
269
+ // that this page snapshot — whose request started earlier — must not
270
+ // clobber with stale data. Absence is never seeded (prefix/pagination
271
+ // make it non-authoritative). A page larger than cacheMaxEntries churns
272
+ // the cache; entries repopulate lazily.
273
+ for (const entry of data) {
274
+ const ck = cacheKey(address, entry.key)
275
+ if (entry.value !== undefined && !writes.isPending(ck) && cache.get(ck) === undefined) {
276
+ cache.set(ck, { body: JSON.stringify({ value: entry.value }) })
277
+ }
278
+ }
279
+
280
+ const requestedOffset = offset ?? 0
281
+ const pagination = {
282
+ offset: response?.pagination?.offset ?? requestedOffset,
283
+ total: response?.pagination?.total ?? data.length
284
+ }
285
+
286
+ return { data, pagination }
287
+ }
288
+ }
289
+ }