@helix3/helix-sdk 0.1.1-helix3.20
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.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/camera.d.ts +14 -0
- package/dist/camera.js +72 -0
- package/dist/camera.js.map +1 -0
- package/dist/index.d.ts +131 -0
- package/dist/index.js +704 -0
- package/dist/index.js.map +1 -0
- package/dist/multiplayer-contract/credential.d.ts +42 -0
- package/dist/multiplayer-contract/credential.js +5 -0
- package/dist/multiplayer-contract/credential.js.map +1 -0
- package/dist/multiplayer-contract/index.d.ts +14 -0
- package/dist/multiplayer-contract/index.js +30 -0
- package/dist/multiplayer-contract/index.js.map +1 -0
- package/dist/multiplayer-contract/messages.d.ts +284 -0
- package/dist/multiplayer-contract/messages.js +111 -0
- package/dist/multiplayer-contract/messages.js.map +1 -0
- package/dist/multiplayer-contract/room.d.ts +126 -0
- package/dist/multiplayer-contract/room.js +17 -0
- package/dist/multiplayer-contract/room.js.map +1 -0
- package/dist/multiplayer-contract/state.d.ts +61 -0
- package/dist/multiplayer-contract/state.js +15 -0
- package/dist/multiplayer-contract/state.js.map +1 -0
- package/dist/multiplayer.d.ts +133 -0
- package/dist/multiplayer.js +334 -0
- package/dist/multiplayer.js.map +1 -0
- package/dist/protocol.d.ts +219 -0
- package/dist/protocol.js +25 -0
- package/dist/protocol.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import { isShellMessage, PROTOCOL_VERSION, } from './protocol';
|
|
2
|
+
import { HelixMultiplayer } from './multiplayer';
|
|
3
|
+
import { captureCanvas } from './camera';
|
|
4
|
+
export { cropRectForAspect, captureCanvas } from './camera';
|
|
5
|
+
// The multiplayer wire contract. Re-exported here so `@hypersoniclabs/helix-sdk` root consumers (and
|
|
6
|
+
// classic node-resolution type imports, e.g. the backend minting RoomCredentialClaims) get it; bundler
|
|
7
|
+
// consumers that want only the contract import the lighter '@hypersoniclabs/helix-sdk/multiplayer-contract'.
|
|
8
|
+
export * from './multiplayer-contract';
|
|
9
|
+
// Statuses where the player ends up owning the thing.
|
|
10
|
+
const COMPLETED_STATUSES = new Set([
|
|
11
|
+
'Granted',
|
|
12
|
+
'Claimed',
|
|
13
|
+
'AlreadyOwned',
|
|
14
|
+
]);
|
|
15
|
+
const INIT_TIMEOUT_MS = 8000; // re-post helix:ready up to this long before falling back to standalone — a cold shell (Next route compile) can take >3s to mount its frame + reply
|
|
16
|
+
const READY_RETRY_MS = 200; // re-post helix:ready this often until the shell answers (handshake race — see waitForInit)
|
|
17
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
18
|
+
const DEBUG_LOG_MAX = 200; // bounded ring of recent log entries (overlay backfill + pre-init forward flush)
|
|
19
|
+
// Reads/grants should resolve fast; a purchase may sit on the confirm popup, so
|
|
20
|
+
// it uses the longer LOGIN_TIMEOUT_MS instead.
|
|
21
|
+
const REQUEST_TIMEOUT_MS = 30 * 1000;
|
|
22
|
+
// A photo upload (encode + multipart POST to R2) is slower than a read; give it
|
|
23
|
+
// its own budget, shorter than the confirm-popup login window.
|
|
24
|
+
const UPLOAD_TIMEOUT_MS = 60 * 1000;
|
|
25
|
+
class HelixSdk {
|
|
26
|
+
shellOrigin = null;
|
|
27
|
+
session = null;
|
|
28
|
+
world = null;
|
|
29
|
+
initialized = false;
|
|
30
|
+
listeners = new Set();
|
|
31
|
+
debugEnabled = false;
|
|
32
|
+
debugRing = [];
|
|
33
|
+
debugListeners = new Set();
|
|
34
|
+
consoleHooked = false;
|
|
35
|
+
pendingLogins = new Map();
|
|
36
|
+
pendingRequests = new Map();
|
|
37
|
+
balanceListeners = new Set();
|
|
38
|
+
inventoryListeners = new Set();
|
|
39
|
+
avatarListeners = new Set();
|
|
40
|
+
// Performs the shell handshake. Call once at world start, before any other
|
|
41
|
+
// Helix API. Resolves with embedded=false if no shell answers (local dev).
|
|
42
|
+
async init() {
|
|
43
|
+
if (this.initialized)
|
|
44
|
+
return this.snapshot();
|
|
45
|
+
window.addEventListener('message', this.onMessage);
|
|
46
|
+
window.addEventListener('keydown', this.onKeyDown);
|
|
47
|
+
if (window.parent !== window) {
|
|
48
|
+
await this.waitForInit();
|
|
49
|
+
}
|
|
50
|
+
this.initialized = true;
|
|
51
|
+
return this.snapshot();
|
|
52
|
+
}
|
|
53
|
+
// Multiplayer (pillar D): Helix.multiplayer.joinRoom(). Reads the world_session token + world id off
|
|
54
|
+
// this singleton; the heavy Colyseus client is dynamically imported only on join.
|
|
55
|
+
multiplayer = new HelixMultiplayer({
|
|
56
|
+
isInitialized: () => this.initialized,
|
|
57
|
+
getToken: () => this.session?.token ?? null,
|
|
58
|
+
getWorldId: () => this.world?.id ?? null,
|
|
59
|
+
});
|
|
60
|
+
// Debug surface. A world's own console lives in its cross-origin iframe — invisible to DevTools / an
|
|
61
|
+
// automation harness attached to the shell, and to teammates without that frame selected. debug.log feeds
|
|
62
|
+
// a bounded ring an in-world overlay can render (onLog/recent), and — when the shell enabled debug
|
|
63
|
+
// (?debug) — is mirrored to the shell frame's console via helix:log. enableDebug() also hooks console.*
|
|
64
|
+
// so ALL world output is captured, not just explicit debug.log calls. enabled() is a fn (the flag flips
|
|
65
|
+
// at init, after the field initializer runs).
|
|
66
|
+
debug = {
|
|
67
|
+
enabled: () => this.debugEnabled,
|
|
68
|
+
log: (...args) => this.captureLog('info', args),
|
|
69
|
+
onLog: (cb) => {
|
|
70
|
+
this.debugListeners.add(cb);
|
|
71
|
+
return () => this.debugListeners.delete(cb);
|
|
72
|
+
},
|
|
73
|
+
recent: () => this.debugRing.slice(),
|
|
74
|
+
};
|
|
75
|
+
auth = {
|
|
76
|
+
getUser: async () => {
|
|
77
|
+
this.assertInitialized();
|
|
78
|
+
return this.session?.user ?? null;
|
|
79
|
+
},
|
|
80
|
+
isAuthenticated: () => {
|
|
81
|
+
this.assertInitialized();
|
|
82
|
+
return this.session !== null;
|
|
83
|
+
},
|
|
84
|
+
// Asks the shell to raise its login overlay. Resolves with the user after
|
|
85
|
+
// a successful login; rejects if the player dismisses it or no shell is
|
|
86
|
+
// present. World state is untouched either way — no reload happens.
|
|
87
|
+
requestLogin: () => {
|
|
88
|
+
this.assertInitialized();
|
|
89
|
+
if (this.session)
|
|
90
|
+
return Promise.resolve(this.session.user);
|
|
91
|
+
if (!this.shellOrigin) {
|
|
92
|
+
return Promise.reject(new Error('Helix: not embedded in a HELIX shell — login unavailable'));
|
|
93
|
+
}
|
|
94
|
+
const requestId = Math.random().toString(36).slice(2);
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
this.pendingLogins.delete(requestId);
|
|
98
|
+
reject(new Error('Helix: login request timed out'));
|
|
99
|
+
}, LOGIN_TIMEOUT_MS);
|
|
100
|
+
this.pendingLogins.set(requestId, {
|
|
101
|
+
resolve: (u) => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
resolve(u);
|
|
104
|
+
},
|
|
105
|
+
reject: (e) => {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
reject(e);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
this.post({ type: 'helix:request-login', requestId }, this.shellOrigin);
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
// Fires on login and logout (user becomes null). Returns an unsubscribe fn.
|
|
114
|
+
onAuthChanged: (listener) => {
|
|
115
|
+
this.listeners.add(listener);
|
|
116
|
+
return () => this.listeners.delete(listener);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
notify = {
|
|
120
|
+
show: (notification) => {
|
|
121
|
+
this.assertInitialized();
|
|
122
|
+
return this.postToShell({ type: 'helix:notify', notification });
|
|
123
|
+
},
|
|
124
|
+
success: (title, message) => this.notify.show({ kind: 'success', title, message }),
|
|
125
|
+
failure: (title, message) => this.notify.show({ kind: 'failure', title, message }),
|
|
126
|
+
notification: (title, message) => this.notify.show({ kind: 'notification', title, message }),
|
|
127
|
+
message: (title, message) => this.notify.show({ kind: 'message', title, message }),
|
|
128
|
+
};
|
|
129
|
+
prompts = {
|
|
130
|
+
set: (prompt) => {
|
|
131
|
+
this.assertInitialized();
|
|
132
|
+
return this.postToShell({ type: 'helix:set-interaction-prompt', prompt });
|
|
133
|
+
},
|
|
134
|
+
clear: (promptId) => {
|
|
135
|
+
this.assertInitialized();
|
|
136
|
+
return this.postToShell({ type: 'helix:clear-interaction-prompt', promptId });
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
device = {
|
|
140
|
+
openTablet: () => this.openDevice('tablet'),
|
|
141
|
+
openPhone: () => this.openDevice('phone'),
|
|
142
|
+
};
|
|
143
|
+
// World-scoped session token for direct platform API calls (used by later
|
|
144
|
+
// SDK modules; exposed for advanced cases). Null when unauthenticated.
|
|
145
|
+
getSessionToken() {
|
|
146
|
+
return this.session?.token ?? null;
|
|
147
|
+
}
|
|
148
|
+
// ─────────────────────────── wallet ───────────────────────────
|
|
149
|
+
wallet = {
|
|
150
|
+
// The player's current LIX + Coins balances.
|
|
151
|
+
getBalance: async () => {
|
|
152
|
+
this.assertInitialized();
|
|
153
|
+
if (!this.shellOrigin)
|
|
154
|
+
return { lix: 0, coins: 0 };
|
|
155
|
+
return this.request('wallet.getBalance');
|
|
156
|
+
},
|
|
157
|
+
// Fires after the wallet changes (e.g. a purchase settles). Returns an
|
|
158
|
+
// unsubscribe fn.
|
|
159
|
+
onBalanceChanged: (cb) => {
|
|
160
|
+
this.balanceListeners.add(cb);
|
|
161
|
+
return () => this.balanceListeners.delete(cb);
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
// ────────────────────────── marketplace ──────────────────────────
|
|
165
|
+
marketplace = {
|
|
166
|
+
// Request a server-settled purchase of a marketplace (catalog) item. The
|
|
167
|
+
// shell raises the confirm popup and settles; the world reacts to the result.
|
|
168
|
+
purchaseItem: (itemId) => this.purchase(`item:${itemId}`),
|
|
169
|
+
// Request a server-settled purchase of a product registered on the world.
|
|
170
|
+
purchaseProduct: (productId) => this.purchase(productId),
|
|
171
|
+
// Browse marketplace listings.
|
|
172
|
+
getListings: async (query) => {
|
|
173
|
+
this.assertInitialized();
|
|
174
|
+
if (!this.shellOrigin)
|
|
175
|
+
return [];
|
|
176
|
+
return this.request('marketplace.getListings', query ?? {});
|
|
177
|
+
},
|
|
178
|
+
// Product/listing + the player's live balance + eligibility, in one call —
|
|
179
|
+
// for building a custom confirm UI (the built-in popup uses this internally).
|
|
180
|
+
getPurchaseContext: async (ref) => {
|
|
181
|
+
this.assertInitialized();
|
|
182
|
+
if (!this.shellOrigin)
|
|
183
|
+
return null;
|
|
184
|
+
return this.request('iwp.getContext', { ref });
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
analytics = {
|
|
188
|
+
track: (eventName, input = {}) => this.trackAnalytics(eventName, input),
|
|
189
|
+
};
|
|
190
|
+
// ─────────────────────────── inventory ───────────────────────────
|
|
191
|
+
inventory = {
|
|
192
|
+
// Whether the current player owns at least one of an item. Works across
|
|
193
|
+
// worlds and creators (universal items) — the basis for VIP / season passes.
|
|
194
|
+
hasItem: async (itemId) => {
|
|
195
|
+
this.assertInitialized();
|
|
196
|
+
if (!this.shellOrigin)
|
|
197
|
+
return false;
|
|
198
|
+
return this.request('inventory.hasItem', { itemId });
|
|
199
|
+
},
|
|
200
|
+
getQuantity: async (itemId) => {
|
|
201
|
+
this.assertInitialized();
|
|
202
|
+
if (!this.shellOrigin)
|
|
203
|
+
return 0;
|
|
204
|
+
return this.request('inventory.getQuantity', { itemId });
|
|
205
|
+
},
|
|
206
|
+
// All items the current player owns.
|
|
207
|
+
getMyItems: async () => {
|
|
208
|
+
this.assertInitialized();
|
|
209
|
+
if (!this.shellOrigin)
|
|
210
|
+
return [];
|
|
211
|
+
return this.request('inventory.getMyItems');
|
|
212
|
+
},
|
|
213
|
+
// Equip an owned cosmetic item (visual).
|
|
214
|
+
equipItem: async (itemId) => {
|
|
215
|
+
this.assertInitialized();
|
|
216
|
+
if (!this.shellOrigin)
|
|
217
|
+
return;
|
|
218
|
+
await this.request('inventory.equipItem', { itemId });
|
|
219
|
+
},
|
|
220
|
+
onInventoryChanged: (cb) => {
|
|
221
|
+
this.inventoryListeners.add(cb);
|
|
222
|
+
return () => this.inventoryListeners.delete(cb);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
// Durable, per-world key–value storage (the Roblox DataStore analog). The
|
|
226
|
+
// right place for anything that must survive a session: player progress,
|
|
227
|
+
// settings, world state. Scoped to THIS world; namespace per-player data
|
|
228
|
+
// yourself (e.g. `player:${userId}`). Value-bearing writes should be made
|
|
229
|
+
// server-authoritatively. Free for every world (quotas + write rate limits
|
|
230
|
+
// apply). See https://docs3.helixgame.com/docs/platform-api/data-store
|
|
231
|
+
dataStore = {
|
|
232
|
+
// Read a durable value by key. Returns null if unset (or in preview mode,
|
|
233
|
+
// when the world is opened directly with no shell).
|
|
234
|
+
get: async (key) => {
|
|
235
|
+
this.assertInitialized();
|
|
236
|
+
if (!this.shellOrigin)
|
|
237
|
+
return null;
|
|
238
|
+
return this.request('dataStore.get', { key });
|
|
239
|
+
},
|
|
240
|
+
// Create or overwrite a durable value. No-op in preview mode (read-only).
|
|
241
|
+
// Subject to the world's free-tier quotas and write rate limit; a rejected
|
|
242
|
+
// write surfaces as a thrown error (e.g. RateLimited / quota exceeded).
|
|
243
|
+
set: async (key, value) => {
|
|
244
|
+
this.assertInitialized();
|
|
245
|
+
if (!this.shellOrigin)
|
|
246
|
+
return;
|
|
247
|
+
await this.request('dataStore.set', { key, value });
|
|
248
|
+
},
|
|
249
|
+
// Remove a durable value by key. No-op in preview mode.
|
|
250
|
+
delete: async (key) => {
|
|
251
|
+
this.assertInitialized();
|
|
252
|
+
if (!this.shellOrigin)
|
|
253
|
+
return;
|
|
254
|
+
await this.request('dataStore.delete', { key });
|
|
255
|
+
},
|
|
256
|
+
// List stored keys, optionally filtered by prefix (e.g. `player:`). Returns
|
|
257
|
+
// keys only (fetch values with get), mirroring Roblox ListKeysAsync.
|
|
258
|
+
list: async (prefix) => {
|
|
259
|
+
this.assertInitialized();
|
|
260
|
+
if (!this.shellOrigin)
|
|
261
|
+
return [];
|
|
262
|
+
return this.request('dataStore.list', { prefix });
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
// ─────────────────────────── camera ───────────────────────────
|
|
266
|
+
//
|
|
267
|
+
// The universal world camera's cloud side. Photo-mode UI + camera control live
|
|
268
|
+
// in the engine (which owns three.js + the render loop); the engine hands the
|
|
269
|
+
// captured pixels here to persist them to the player's account, where they show
|
|
270
|
+
// up in the phone's Gallery on any device (account-scoped, cross-device).
|
|
271
|
+
//
|
|
272
|
+
// `capture` is a pure DOM helper a world/engine can use to turn its rendered
|
|
273
|
+
// canvas into an encoded, aspect-cropped Blob; `savePhoto` is the shell-mediated
|
|
274
|
+
// upload. See https://docs3.helixgame.com/docs/platform-api/camera
|
|
275
|
+
camera = {
|
|
276
|
+
// True when running inside a HELIX shell (so a save can actually reach the
|
|
277
|
+
// account). False in local/preview — capture still works, cloud save no-ops.
|
|
278
|
+
available: () => {
|
|
279
|
+
this.assertInitialized();
|
|
280
|
+
return this.shellOrigin !== null;
|
|
281
|
+
},
|
|
282
|
+
// Encode a (already-rendered) canvas to a Blob, centre-cropped to `aspect`.
|
|
283
|
+
// Pure capture — no upload. The caller must render into the canvas in the
|
|
284
|
+
// same tick (WebGL clears its buffer between frames). Re-exported as
|
|
285
|
+
// `captureCanvas` for non-camera use.
|
|
286
|
+
capture: (canvas, opts) => captureCanvas(canvas, opts),
|
|
287
|
+
// Persist a captured photo to the player's account; it appears in the phone
|
|
288
|
+
// Gallery on every device, tagged with the world it was taken in (the shell
|
|
289
|
+
// stamps the world name authoritatively). Resolves null in preview mode
|
|
290
|
+
// (no shell) — never a fake success. Requires the `camera.capture` scope in
|
|
291
|
+
// the world manifest; a missing scope rejects.
|
|
292
|
+
savePhoto: async (image, meta) => {
|
|
293
|
+
this.assertInitialized();
|
|
294
|
+
if (!this.shellOrigin)
|
|
295
|
+
return null;
|
|
296
|
+
const { bytes, contentType } = await toBytes(image, meta?.contentType);
|
|
297
|
+
return this.request('camera.savePhoto', { bytes, contentType, caption: meta?.caption, aspect: meta?.aspect ?? 'free' }, UPLOAD_TIMEOUT_MS);
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
// ─────────────────────────── avatar ───────────────────────────
|
|
301
|
+
avatar = {
|
|
302
|
+
openCreator: async () => {
|
|
303
|
+
this.assertInitialized();
|
|
304
|
+
if (!this.shellOrigin)
|
|
305
|
+
return false;
|
|
306
|
+
return this.request('avatar.openCreator');
|
|
307
|
+
},
|
|
308
|
+
getLoadout: async () => {
|
|
309
|
+
this.assertInitialized();
|
|
310
|
+
if (!this.shellOrigin)
|
|
311
|
+
return this.fetchAvatarLoadout();
|
|
312
|
+
try {
|
|
313
|
+
return await this.request('avatar.getLoadout');
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return this.fetchAvatarLoadout();
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
updateLoadout: async (patch) => {
|
|
320
|
+
this.assertInitialized();
|
|
321
|
+
if (!this.shellOrigin)
|
|
322
|
+
return this.fetchAvatarLoadout(patch);
|
|
323
|
+
try {
|
|
324
|
+
const loadout = await this.request('avatar.updateLoadout', patch);
|
|
325
|
+
this.emitAvatarChanged();
|
|
326
|
+
return loadout;
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
const loadout = await this.fetchAvatarLoadout(patch);
|
|
330
|
+
if (loadout)
|
|
331
|
+
this.emitAvatarChanged();
|
|
332
|
+
return loadout;
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
// The local player's equipped "universal avatar" — the single avatar a world
|
|
336
|
+
// renders for them (equipped → auto-equip first owned → default). Resolved by
|
|
337
|
+
// a DIRECT call to the platform's public equipped-avatar endpoint — no shell
|
|
338
|
+
// round-trip (the generic helix:request bridge is not implemented by the
|
|
339
|
+
// shells). The session supplies the player's own user id; the API base is
|
|
340
|
+
// Helix.multiplayer.configure({ apiBaseUrl }) else the session token's `iss`.
|
|
341
|
+
// Returns null for guests / outside a shell / on ANY resolution failure — the
|
|
342
|
+
// world falls back to its default body, never breaks.
|
|
343
|
+
getEquipped: async () => {
|
|
344
|
+
this.assertInitialized();
|
|
345
|
+
const user = this.session?.user;
|
|
346
|
+
const token = this.session?.token ?? null;
|
|
347
|
+
if (!user || token === null)
|
|
348
|
+
return null;
|
|
349
|
+
const apiBase = this.multiplayer.resolveApiBase(token);
|
|
350
|
+
if (apiBase === null)
|
|
351
|
+
return null;
|
|
352
|
+
try {
|
|
353
|
+
const res = await fetch(`${apiBase}/api/v1/universal-items/avatar/equipped/users/${encodeURIComponent(user.id)}`);
|
|
354
|
+
if (!res.ok)
|
|
355
|
+
return null;
|
|
356
|
+
const dto = (await res.json());
|
|
357
|
+
if (!dto || (dto.source !== 'equipped' && dto.source !== 'auto' && dto.source !== 'default'))
|
|
358
|
+
return null;
|
|
359
|
+
return {
|
|
360
|
+
source: dto.source,
|
|
361
|
+
inventoryItemId: typeof dto.inventoryItemId === 'string' ? dto.inventoryItemId : null,
|
|
362
|
+
itemId: typeof dto.itemId === 'string' ? dto.itemId : null,
|
|
363
|
+
glbUrl: typeof dto.glbUrl === 'string' ? dto.glbUrl : null,
|
|
364
|
+
skeleton: typeof dto.skeleton === 'string' ? dto.skeleton : null,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
onAvatarChanged: (cb) => {
|
|
372
|
+
this.avatarListeners.add(cb);
|
|
373
|
+
return () => this.avatarListeners.delete(cb);
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
// Shared purchase path. The SDK mints a stable idempotency key per attempt
|
|
377
|
+
// (reused by the shell across confirm-popup retries) and asks the shell to
|
|
378
|
+
// settle. Not embedded (local dev) → Unauthorized, never a fake success.
|
|
379
|
+
purchase(ref) {
|
|
380
|
+
this.assertInitialized();
|
|
381
|
+
if (!this.shellOrigin) {
|
|
382
|
+
return Promise.resolve({ status: 'Unauthorized', completed: false });
|
|
383
|
+
}
|
|
384
|
+
const idempotencyKey = this.newId();
|
|
385
|
+
return this.request('marketplace.purchase', { ref, idempotencyKey }, LOGIN_TIMEOUT_MS).then((raw) => ({
|
|
386
|
+
...raw,
|
|
387
|
+
completed: COMPLETED_STATUSES.has(raw.status),
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
async trackAnalytics(eventName, input) {
|
|
391
|
+
this.assertInitialized();
|
|
392
|
+
const token = this.session?.token ?? null;
|
|
393
|
+
const apiBase = this.multiplayer.resolveApiBase(token);
|
|
394
|
+
if (!apiBase)
|
|
395
|
+
return;
|
|
396
|
+
const body = {
|
|
397
|
+
event_name: eventName,
|
|
398
|
+
event_id: input.eventId ?? `sdk:${this.newId()}`,
|
|
399
|
+
occurred_at: new Date().toISOString(),
|
|
400
|
+
user_id: this.session?.user.id,
|
|
401
|
+
anonymous_id: this.session?.user.id ? undefined : `sdk:${this.newId()}`,
|
|
402
|
+
session_id: token ? `world:${this.session?.user.id ?? 'guest'}` : undefined,
|
|
403
|
+
platform: 'web',
|
|
404
|
+
source: 'client',
|
|
405
|
+
world_id: this.world?.id,
|
|
406
|
+
instance_id: input.instanceId ?? undefined,
|
|
407
|
+
item_id: input.itemId ?? undefined,
|
|
408
|
+
invite_id: input.inviteId ?? undefined,
|
|
409
|
+
referrer: input.referrer ?? undefined,
|
|
410
|
+
properties: this.safeAnalyticsProperties(input.properties),
|
|
411
|
+
};
|
|
412
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
413
|
+
if (token)
|
|
414
|
+
headers.Authorization = `Bearer ${token}`;
|
|
415
|
+
const res = await fetch(`${apiBase}/api/v1/analytics/events`, {
|
|
416
|
+
method: 'POST',
|
|
417
|
+
headers,
|
|
418
|
+
body: JSON.stringify(body),
|
|
419
|
+
keepalive: true,
|
|
420
|
+
});
|
|
421
|
+
if (!res.ok)
|
|
422
|
+
throw new Error(`Helix.analytics: collector responded ${res.status}`);
|
|
423
|
+
}
|
|
424
|
+
safeAnalyticsProperties(properties) {
|
|
425
|
+
if (!properties)
|
|
426
|
+
return undefined;
|
|
427
|
+
const out = {};
|
|
428
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
429
|
+
if (value === undefined || /password|token|secret|authorization|cookie|payment|card|cvv|private_message|raw_chat|raw_voice|email/i.test(key)) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
out[key] = typeof value === 'string' && value.length > 256 ? `${value.slice(0, 253)}...` : value;
|
|
433
|
+
}
|
|
434
|
+
return Object.keys(out).length ? out : undefined;
|
|
435
|
+
}
|
|
436
|
+
openDevice(form) {
|
|
437
|
+
this.assertInitialized();
|
|
438
|
+
return this.postToShell({ type: 'helix:open-device', form });
|
|
439
|
+
}
|
|
440
|
+
onKeyDown = (event) => {
|
|
441
|
+
if (!this.initialized)
|
|
442
|
+
return;
|
|
443
|
+
if (event.key !== 'Escape' || event.repeat || event.defaultPrevented)
|
|
444
|
+
return;
|
|
445
|
+
const target = event.target;
|
|
446
|
+
const tag = target?.tagName?.toLowerCase();
|
|
447
|
+
const isTyping = target?.isContentEditable ||
|
|
448
|
+
tag === 'input' ||
|
|
449
|
+
tag === 'textarea' ||
|
|
450
|
+
tag === 'select';
|
|
451
|
+
if (isTyping)
|
|
452
|
+
return;
|
|
453
|
+
if (this.device.openTablet())
|
|
454
|
+
event.preventDefault();
|
|
455
|
+
};
|
|
456
|
+
// Generic shell-mediated request/response over postMessage.
|
|
457
|
+
request(method, payload, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
458
|
+
this.assertInitialized();
|
|
459
|
+
if (!this.shellOrigin) {
|
|
460
|
+
return Promise.reject(new Error('Helix: not embedded in a HELIX shell'));
|
|
461
|
+
}
|
|
462
|
+
const requestId = this.newId();
|
|
463
|
+
return new Promise((resolve, reject) => {
|
|
464
|
+
const timer = setTimeout(() => {
|
|
465
|
+
this.pendingRequests.delete(requestId);
|
|
466
|
+
reject(new Error(`Helix: ${method} timed out`));
|
|
467
|
+
}, timeoutMs);
|
|
468
|
+
this.pendingRequests.set(requestId, {
|
|
469
|
+
resolve: (v) => {
|
|
470
|
+
clearTimeout(timer);
|
|
471
|
+
resolve(v);
|
|
472
|
+
},
|
|
473
|
+
reject: (e) => {
|
|
474
|
+
clearTimeout(timer);
|
|
475
|
+
reject(e);
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
this.post({ type: 'helix:request', requestId, method, payload }, this.shellOrigin);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
newId() {
|
|
482
|
+
const c = globalThis.crypto;
|
|
483
|
+
if (c?.randomUUID)
|
|
484
|
+
return c.randomUUID();
|
|
485
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
486
|
+
}
|
|
487
|
+
async fetchAvatarLoadout(patch) {
|
|
488
|
+
const token = this.session?.token ?? null;
|
|
489
|
+
if (token === null)
|
|
490
|
+
return null;
|
|
491
|
+
const apiBase = this.multiplayer.resolveApiBase(token);
|
|
492
|
+
if (apiBase === null)
|
|
493
|
+
return null;
|
|
494
|
+
try {
|
|
495
|
+
const res = await fetch(`${apiBase}/api/v1/universal-items/avatar/loadout/me`, {
|
|
496
|
+
method: patch ? 'PATCH' : 'GET',
|
|
497
|
+
headers: {
|
|
498
|
+
Authorization: `Bearer ${token}`,
|
|
499
|
+
...(patch ? { 'Content-Type': 'application/json' } : {}),
|
|
500
|
+
},
|
|
501
|
+
body: patch ? JSON.stringify(patch) : undefined,
|
|
502
|
+
});
|
|
503
|
+
if (!res.ok)
|
|
504
|
+
return null;
|
|
505
|
+
return (await res.json());
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
emitAvatarChanged() {
|
|
512
|
+
for (const listener of this.avatarListeners)
|
|
513
|
+
listener();
|
|
514
|
+
}
|
|
515
|
+
snapshot() {
|
|
516
|
+
return {
|
|
517
|
+
embedded: this.shellOrigin !== null,
|
|
518
|
+
world: this.world,
|
|
519
|
+
user: this.session?.user ?? null,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
assertInitialized() {
|
|
523
|
+
if (!this.initialized) {
|
|
524
|
+
throw new Error('Helix: call Helix.init() before using the SDK');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// Handshake: announce readiness and wait for the shell's helix:init reply. We RE-POST helix:ready until the
|
|
528
|
+
// shell answers (or we time out) because the shell may attach its message listener AFTER our first post — it
|
|
529
|
+
// registers in a post-hydration effect, and a lightweight world (no heavy assets, cached) can fire helix:ready
|
|
530
|
+
// before then, so a single post would be lost and we'd fall back to embedded=false. Retrying closes that race;
|
|
531
|
+
// the moment the shell answers (shellOrigin set) we stop, so a shell that's already listening sees just one post.
|
|
532
|
+
waitForInit() {
|
|
533
|
+
return new Promise((resolve) => {
|
|
534
|
+
const deadline = Date.now() + INIT_TIMEOUT_MS;
|
|
535
|
+
const ready = () => this.post({ type: 'helix:ready', protocolVersion: PROTOCOL_VERSION }, '*');
|
|
536
|
+
ready();
|
|
537
|
+
const check = () => {
|
|
538
|
+
if (this.shellOrigin || Date.now() >= deadline) {
|
|
539
|
+
resolve();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
ready();
|
|
543
|
+
setTimeout(check, READY_RETRY_MS);
|
|
544
|
+
};
|
|
545
|
+
setTimeout(check, READY_RETRY_MS);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
onMessage = (event) => {
|
|
549
|
+
if (!isShellMessage(event.data))
|
|
550
|
+
return;
|
|
551
|
+
// First valid helix:init pins the shell origin; everything after must match it.
|
|
552
|
+
if (this.shellOrigin && event.origin !== this.shellOrigin)
|
|
553
|
+
return;
|
|
554
|
+
const msg = event.data;
|
|
555
|
+
switch (msg.type) {
|
|
556
|
+
case 'helix:init':
|
|
557
|
+
if (this.shellOrigin)
|
|
558
|
+
return;
|
|
559
|
+
this.shellOrigin = event.origin;
|
|
560
|
+
this.world = msg.world;
|
|
561
|
+
this.setSession(msg.session);
|
|
562
|
+
if (msg.debug)
|
|
563
|
+
this.enableDebug();
|
|
564
|
+
break;
|
|
565
|
+
case 'helix:session':
|
|
566
|
+
this.setSession(msg.session);
|
|
567
|
+
break;
|
|
568
|
+
case 'helix:login-result': {
|
|
569
|
+
const pending = this.pendingLogins.get(msg.requestId);
|
|
570
|
+
if (!pending)
|
|
571
|
+
return;
|
|
572
|
+
this.pendingLogins.delete(msg.requestId);
|
|
573
|
+
if (msg.ok && this.session)
|
|
574
|
+
pending.resolve(this.session.user);
|
|
575
|
+
else
|
|
576
|
+
pending.reject(new Error(`Helix: login ${msg.reason ?? 'failed'}`));
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
case 'helix:response': {
|
|
580
|
+
const pending = this.pendingRequests.get(msg.requestId);
|
|
581
|
+
if (!pending)
|
|
582
|
+
return;
|
|
583
|
+
this.pendingRequests.delete(msg.requestId);
|
|
584
|
+
if (msg.ok)
|
|
585
|
+
pending.resolve(msg.result);
|
|
586
|
+
else
|
|
587
|
+
pending.reject(new Error(msg.error ?? 'Helix: request failed'));
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
case 'helix:event': {
|
|
591
|
+
if (msg.event === 'balance-changed') {
|
|
592
|
+
const b = msg.data ?? { lix: 0, coins: 0 };
|
|
593
|
+
for (const l of this.balanceListeners)
|
|
594
|
+
l(b);
|
|
595
|
+
}
|
|
596
|
+
else if (msg.event === 'inventory-changed') {
|
|
597
|
+
for (const l of this.inventoryListeners)
|
|
598
|
+
l();
|
|
599
|
+
}
|
|
600
|
+
else if (msg.event === 'avatar-changed' || msg.event === 'equipment-changed') {
|
|
601
|
+
this.emitAvatarChanged();
|
|
602
|
+
}
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
setSession(session) {
|
|
608
|
+
const before = this.session?.user.id ?? null;
|
|
609
|
+
this.session = session;
|
|
610
|
+
const after = session?.user.id ?? null;
|
|
611
|
+
if (before !== after) {
|
|
612
|
+
for (const listener of this.listeners)
|
|
613
|
+
listener(session?.user ?? null);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
enableDebug() {
|
|
617
|
+
if (this.debugEnabled)
|
|
618
|
+
return;
|
|
619
|
+
this.debugEnabled = true;
|
|
620
|
+
this.hookConsole();
|
|
621
|
+
// Flush anything buffered before the shell turned debug on (e.g. early lifecycle logs).
|
|
622
|
+
if (this.shellOrigin)
|
|
623
|
+
for (const entry of this.debugRing)
|
|
624
|
+
this.post({ type: 'helix:log', entry }, this.shellOrigin);
|
|
625
|
+
}
|
|
626
|
+
// Capture ALL console output once debug is on (three.js warnings, SDK errors, uncaught logs) — not just
|
|
627
|
+
// explicit debug.log calls. Pass-through preserves normal console behaviour in the iframe.
|
|
628
|
+
hookConsole() {
|
|
629
|
+
if (this.consoleHooked || typeof console === 'undefined')
|
|
630
|
+
return;
|
|
631
|
+
this.consoleHooked = true;
|
|
632
|
+
const levels = ['log', 'info', 'warn', 'error', 'debug'];
|
|
633
|
+
const c = console;
|
|
634
|
+
for (const level of levels) {
|
|
635
|
+
const orig = c[level]?.bind(console);
|
|
636
|
+
if (!orig)
|
|
637
|
+
continue;
|
|
638
|
+
c[level] = (...args) => {
|
|
639
|
+
this.captureLog(level, args);
|
|
640
|
+
orig(...args);
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
captureLog(level, args) {
|
|
645
|
+
const entry = { level, args: args.map(stringifyArg), t: Date.now() };
|
|
646
|
+
this.debugRing.push(entry);
|
|
647
|
+
if (this.debugRing.length > DEBUG_LOG_MAX)
|
|
648
|
+
this.debugRing.shift();
|
|
649
|
+
for (const cb of this.debugListeners) {
|
|
650
|
+
try {
|
|
651
|
+
cb(entry);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
/* a listener must never break logging */
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (this.debugEnabled && this.shellOrigin)
|
|
658
|
+
this.post({ type: 'helix:log', entry }, this.shellOrigin);
|
|
659
|
+
}
|
|
660
|
+
post(message, targetOrigin) {
|
|
661
|
+
window.parent.postMessage(message, targetOrigin);
|
|
662
|
+
}
|
|
663
|
+
postToShell(message) {
|
|
664
|
+
if (!this.shellOrigin)
|
|
665
|
+
return false;
|
|
666
|
+
this.post(message, this.shellOrigin);
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
// Normalize a captured image to a transferable ArrayBuffer + a content type for
|
|
671
|
+
// the shell to upload. Accepts a Blob (browser capture), an ArrayBuffer, or a
|
|
672
|
+
// Uint8Array. A Blob carries its own MIME; otherwise default to JPEG.
|
|
673
|
+
async function toBytes(image, contentTypeOverride) {
|
|
674
|
+
if (typeof Blob !== 'undefined' && image instanceof Blob) {
|
|
675
|
+
return {
|
|
676
|
+
bytes: await image.arrayBuffer(),
|
|
677
|
+
contentType: contentTypeOverride || image.type || 'image/jpeg',
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
if (image instanceof Uint8Array) {
|
|
681
|
+
const copy = image.slice();
|
|
682
|
+
return { bytes: copy.buffer, contentType: contentTypeOverride || 'image/jpeg' };
|
|
683
|
+
}
|
|
684
|
+
// Remaining case is ArrayBuffer (the Blob branch above returns); the compound
|
|
685
|
+
// typeof-guard stops TS from negative-narrowing Blob, so assert it here.
|
|
686
|
+
return { bytes: image, contentType: contentTypeOverride || 'image/jpeg' };
|
|
687
|
+
}
|
|
688
|
+
// Serialize a console arg to a string the shell can print: strings as-is, Errors to their stack, other
|
|
689
|
+
// objects to compact JSON (falling back to String() on cyclic/unserializable values).
|
|
690
|
+
function stringifyArg(a) {
|
|
691
|
+
if (typeof a === 'string')
|
|
692
|
+
return a;
|
|
693
|
+
if (a instanceof Error)
|
|
694
|
+
return a.stack ?? `${a.name}: ${a.message}`;
|
|
695
|
+
try {
|
|
696
|
+
return typeof a === 'object' && a !== null ? JSON.stringify(a) : String(a);
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
return String(a);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// Worlds import this singleton: `import { Helix } from '@hypersoniclabs/helix-sdk'`.
|
|
703
|
+
export const Helix = new HelixSdk();
|
|
704
|
+
//# sourceMappingURL=index.js.map
|