@spawndotfamily/sdk 0.2.7
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/AGENTS.md +55 -0
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/cli/api.d.ts +19 -0
- package/dist/cli/api.js +187 -0
- package/dist/cli/files.d.ts +4 -0
- package/dist/cli/files.js +40 -0
- package/dist/cli/index.d.ts +61 -0
- package/dist/cli/index.js +503 -0
- package/dist/cli/listing.d.ts +14 -0
- package/dist/cli/listing.js +155 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +18 -0
- package/dist/cli/upload-client.d.ts +84 -0
- package/dist/cli/upload-client.js +737 -0
- package/dist/dev/economy.d.ts +29 -0
- package/dist/dev/economy.js +49 -0
- package/dist/dev/host.d.ts +1 -0
- package/dist/dev/host.js +225 -0
- package/dist/dev/panel.d.ts +6 -0
- package/dist/dev/panel.js +63 -0
- package/dist/dev/run.d.ts +2 -0
- package/dist/dev/run.js +19 -0
- package/dist/dev/server.d.ts +5 -0
- package/dist/dev/server.js +188 -0
- package/dist/dev/shell.d.ts +1 -0
- package/dist/dev/shell.js +18 -0
- package/dist/dev/state.d.ts +33 -0
- package/dist/dev/state.js +77 -0
- package/dist/dev/styles.d.ts +1 -0
- package/dist/dev/styles.js +25 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +403 -0
- package/dist/multiplayer.d.ts +17 -0
- package/dist/multiplayer.js +174 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +112 -0
- package/dist/startup.d.ts +21 -0
- package/dist/startup.js +85 -0
- package/docs/creator-checklist.md +62 -0
- package/docs/integration.md +69 -0
- package/docs/multiplayer.md +89 -0
- package/docs/publishing.md +115 -0
- package/docs/security.md +83 -0
- package/docs/startup.md +35 -0
- package/docs/testing.md +90 -0
- package/examples/creator-server.js +16 -0
- package/examples/github-browser-build.yml +28 -0
- package/examples/multiplayer-game.js +38 -0
- package/examples/preview-game.js +13 -0
- package/package.json +69 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { LocalTestEconomy, localPlayer } from "./economy.js";
|
|
2
|
+
/** In-memory fixtures only. This module never calls a platform API. */
|
|
3
|
+
export class LocalTestState {
|
|
4
|
+
economy = new LocalTestEconomy();
|
|
5
|
+
submissions = [];
|
|
6
|
+
get scores() { return structuredClone(this.submissions); }
|
|
7
|
+
saves = new Map();
|
|
8
|
+
quotes = new Map();
|
|
9
|
+
identity(player) {
|
|
10
|
+
this.player(player);
|
|
11
|
+
return { id: 'local_test_' + player, handle: 'test_' + player,
|
|
12
|
+
displayName: (player === 'alice' ? 'Alice' : player === 'bob' ? 'Bob' : 'Empty balance') + ' (local test)', avatarUrl: null, environment: 'sandbox' };
|
|
13
|
+
}
|
|
14
|
+
player(value) {
|
|
15
|
+
localPlayer(value);
|
|
16
|
+
}
|
|
17
|
+
recordKey(player, key) {
|
|
18
|
+
this.player(player);
|
|
19
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(key))
|
|
20
|
+
throw new Error('Invalid record key.');
|
|
21
|
+
return player + ':' + key;
|
|
22
|
+
}
|
|
23
|
+
balance(player) { this.player(player); return this.economy.balance(player); }
|
|
24
|
+
load(player, key) { return structuredClone(this.saves.get(this.recordKey(player, key)) ?? null); }
|
|
25
|
+
save(player, key, value, version) {
|
|
26
|
+
const id = this.recordKey(player, key);
|
|
27
|
+
if (!Number.isSafeInteger(version) || version < 0 || (this.saves.get(id)?.version ?? 0) !== version)
|
|
28
|
+
throw new Error('Save changed; reload before saving.');
|
|
29
|
+
const text = JSON.stringify(value);
|
|
30
|
+
if (text === undefined || text.length > 16000 || this.saves.size >= 1000 && !this.saves.has(id))
|
|
31
|
+
throw new Error('Local record limit reached.');
|
|
32
|
+
const save = { value: JSON.parse(text), version: version + 1, updatedAt: new Date().toISOString() };
|
|
33
|
+
this.saves.set(id, save);
|
|
34
|
+
return structuredClone(save);
|
|
35
|
+
}
|
|
36
|
+
score(player, score, details) {
|
|
37
|
+
this.player(player);
|
|
38
|
+
if (!Number.isFinite(score) || JSON.stringify(details ?? {}).length > 4000)
|
|
39
|
+
throw new Error('Invalid local score.');
|
|
40
|
+
const result = { id: 'local_' + crypto.randomUUID(), verification: 'unverified' };
|
|
41
|
+
this.submissions.unshift({ ...result, player, score, details: JSON.parse(JSON.stringify(details ?? {})) });
|
|
42
|
+
this.submissions.splice(100);
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
quote(player, launch, product) {
|
|
46
|
+
this.player(player);
|
|
47
|
+
if (product !== 'entry')
|
|
48
|
+
throw new Error('Only the entry test product is available.');
|
|
49
|
+
const existing = [...this.quotes.values()].find(q => q.player === player && q.launch === launch && q.status !== 'cancelled');
|
|
50
|
+
if (existing)
|
|
51
|
+
return structuredClone(existing);
|
|
52
|
+
if (this.balance(player) < 10)
|
|
53
|
+
throw new Error('Insufficient local test balance. Choose Alice or Bob, or reset testing.');
|
|
54
|
+
if (this.quotes.size >= 1000)
|
|
55
|
+
throw new Error('Local test limit reached. Reset testing.');
|
|
56
|
+
const quote = { id: 'local_' + crypto.randomUUID(), player, launch, status: 'pending' };
|
|
57
|
+
this.quotes.set(quote.id, quote);
|
|
58
|
+
return structuredClone(quote);
|
|
59
|
+
}
|
|
60
|
+
cancel(id) {
|
|
61
|
+
const quote = this.quotes.get(id);
|
|
62
|
+
if (quote?.status === 'pending')
|
|
63
|
+
quote.status = 'cancelled';
|
|
64
|
+
}
|
|
65
|
+
confirm(id) {
|
|
66
|
+
const quote = this.quotes.get(id);
|
|
67
|
+
if (!quote || quote.status === 'cancelled')
|
|
68
|
+
throw new Error('Local payment cancelled.');
|
|
69
|
+
if (quote.receipt)
|
|
70
|
+
return structuredClone(quote.receipt);
|
|
71
|
+
const receipt = { id: 'local_' + crypto.randomUUID(), intentId: id, amount: 10, asset: 'TEST', environment: 'sandbox', status: 'paid' };
|
|
72
|
+
this.economy.entry(quote.player, receipt.id);
|
|
73
|
+
quote.status = 'paid';
|
|
74
|
+
quote.receipt = receipt;
|
|
75
|
+
return structuredClone(quote.receipt);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const launcherCss = "\n:root{font:15px/1.5 system-ui;color:#202321;background:#f5f6f3}\n*{box-sizing:border-box}body{margin:0}\nheader{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:14px 24px;background:white;border-bottom:1px solid #dadfd7}\nheader>strong{margin-right:auto;letter-spacing:.02em}header strong span{font-size:11px;margin-left:8px;background:#d4ff57;padding:4px 6px;border-radius:3px}\nbutton,input{font:inherit;color:inherit;border:1px solid #cdd3c9;border-radius:6px;background:white}\nbutton{padding:8px 12px;cursor:pointer}button:disabled{opacity:.5;cursor:wait}button:focus-visible,input:focus-visible{outline:3px solid #4d794f;outline-offset:2px}\n.players{display:flex;gap:4px}.players button[aria-pressed=\"true\"]{background:#202321;color:white}\n.notice{padding:10px 24px;background:#ecf0e5;color:#52594e;font-size:13px}\nmain{display:grid;grid-template-columns:minmax(0,1fr) 340px;height:calc(100dvh - 136px);min-height:480px}\n#frame-slot,iframe,#game{height:100%;width:100%;border:0;background:#151a16}\naside{overflow:auto;border-left:1px solid #dadfd7;background:#fff}.panel-section{padding:20px;border-bottom:1px solid #e2e5dd}\nh1,h2{font-size:16px;margin:0 0 12px}h1{font-size:22px;letter-spacing:-.03em}.eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#67705f;margin:0 0 4px}\np{margin:0 0 10px}.hint,.empty,#status{font-size:13px;color:#67705f}.empty{margin-bottom:0}\n.balances{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:18px}.balances div{padding:10px;background:#f2f5ee;border-radius:6px}.balances span{display:block;font-size:12px;color:#67705f}.balances strong{display:block;font-size:18px}\nlabel{display:block;font-size:13px;margin-bottom:6px}label span{float:right}input{width:100%;padding:8px 10px;margin-bottom:10px}\n.pool-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}.pool-actions .primary{grid-column:1/-1}.pool-actions button{font-size:13px}\n#transfer-feedback{margin:10px 0 0}#transfer-feedback[data-error=\"true\"]{color:#ac2e24}\nol{padding-left:18px;font-size:13px;margin:0}li{padding-bottom:8px;overflow-wrap:anywhere}\n.transactions{list-style:none;padding:0}.transactions li{padding:10px 0;border-bottom:1px solid #edf0e9}.transactions li:last-child{border:0;padding-bottom:0}.transactions strong,.transactions span{display:block}.transactions span{color:#67705f}.transactions details{font-size:11px;color:#67705f;margin-top:4px}.transactions summary{cursor:pointer}.transactions code{font-size:10px}\ndialog{max-width:410px;width:calc(100% - 32px);padding:28px;border:1px solid #cdd3c9;border-radius:12px;box-shadow:0 16px 70px #0003}dialog::backdrop{background:#101a1266}.actions{display:flex;gap:12px;justify-content:flex-end;margin-top:24px}\n.primary{background:#d4ff57;border-color:#b4df3d;font-weight:650}.paid{color:#23834b}.paid:before{content:'\u2713';display:inline-grid;place-items:center;width:36px;height:36px;background:#e1f3e6;border-radius:50%;margin-right:8px;animation:appear .25s ease-out}\n@keyframes appear{from{transform:scale(.7);opacity:.2}}@media(prefers-reduced-motion:reduce){.paid:before{animation:none}}\n@media(max-width:760px){main{display:block;height:auto}#game{height:60dvh;min-height:260px}aside{border-left:0}header{padding:12px;gap:8px}header>strong{width:100%}header button{font-size:13px;padding:7px 9px}.notice{padding:10px 12px}.panel-section{padding:18px}}\n";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const launcherCss = `
|
|
2
|
+
:root{font:15px/1.5 system-ui;color:#202321;background:#f5f6f3}
|
|
3
|
+
*{box-sizing:border-box}body{margin:0}
|
|
4
|
+
header{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding:14px 24px;background:white;border-bottom:1px solid #dadfd7}
|
|
5
|
+
header>strong{margin-right:auto;letter-spacing:.02em}header strong span{font-size:11px;margin-left:8px;background:#d4ff57;padding:4px 6px;border-radius:3px}
|
|
6
|
+
button,input{font:inherit;color:inherit;border:1px solid #cdd3c9;border-radius:6px;background:white}
|
|
7
|
+
button{padding:8px 12px;cursor:pointer}button:disabled{opacity:.5;cursor:wait}button:focus-visible,input:focus-visible{outline:3px solid #4d794f;outline-offset:2px}
|
|
8
|
+
.players{display:flex;gap:4px}.players button[aria-pressed="true"]{background:#202321;color:white}
|
|
9
|
+
.notice{padding:10px 24px;background:#ecf0e5;color:#52594e;font-size:13px}
|
|
10
|
+
main{display:grid;grid-template-columns:minmax(0,1fr) 340px;height:calc(100dvh - 136px);min-height:480px}
|
|
11
|
+
#frame-slot,iframe,#game{height:100%;width:100%;border:0;background:#151a16}
|
|
12
|
+
aside{overflow:auto;border-left:1px solid #dadfd7;background:#fff}.panel-section{padding:20px;border-bottom:1px solid #e2e5dd}
|
|
13
|
+
h1,h2{font-size:16px;margin:0 0 12px}h1{font-size:22px;letter-spacing:-.03em}.eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#67705f;margin:0 0 4px}
|
|
14
|
+
p{margin:0 0 10px}.hint,.empty,#status{font-size:13px;color:#67705f}.empty{margin-bottom:0}
|
|
15
|
+
.balances{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:18px}.balances div{padding:10px;background:#f2f5ee;border-radius:6px}.balances span{display:block;font-size:12px;color:#67705f}.balances strong{display:block;font-size:18px}
|
|
16
|
+
label{display:block;font-size:13px;margin-bottom:6px}label span{float:right}input{width:100%;padding:8px 10px;margin-bottom:10px}
|
|
17
|
+
.pool-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}.pool-actions .primary{grid-column:1/-1}.pool-actions button{font-size:13px}
|
|
18
|
+
#transfer-feedback{margin:10px 0 0}#transfer-feedback[data-error="true"]{color:#ac2e24}
|
|
19
|
+
ol{padding-left:18px;font-size:13px;margin:0}li{padding-bottom:8px;overflow-wrap:anywhere}
|
|
20
|
+
.transactions{list-style:none;padding:0}.transactions li{padding:10px 0;border-bottom:1px solid #edf0e9}.transactions li:last-child{border:0;padding-bottom:0}.transactions strong,.transactions span{display:block}.transactions span{color:#67705f}.transactions details{font-size:11px;color:#67705f;margin-top:4px}.transactions summary{cursor:pointer}.transactions code{font-size:10px}
|
|
21
|
+
dialog{max-width:410px;width:calc(100% - 32px);padding:28px;border:1px solid #cdd3c9;border-radius:12px;box-shadow:0 16px 70px #0003}dialog::backdrop{background:#101a1266}.actions{display:flex;gap:12px;justify-content:flex-end;margin-top:24px}
|
|
22
|
+
.primary{background:#d4ff57;border-color:#b4df3d;font-weight:650}.paid{color:#23834b}.paid:before{content:'✓';display:inline-grid;place-items:center;width:36px;height:36px;background:#e1f3e6;border-radius:50%;margin-right:8px;animation:appear .25s ease-out}
|
|
23
|
+
@keyframes appear{from{transform:scale(.7);opacity:.2}}@media(prefers-reduced-motion:reduce){.paid:before{animation:none}}
|
|
24
|
+
@media(max-width:760px){main{display:block;height:auto}#game{height:60dvh;min-height:260px}aside{border-left:0}header{padding:12px;gap:8px}header>strong{width:100%}header button{font-size:13px;padding:7px 9px}.notice{padding:10px 12px}.panel-section{padding:18px}}
|
|
25
|
+
`;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type Save<T> = {
|
|
2
|
+
value: T;
|
|
3
|
+
version: number;
|
|
4
|
+
updatedAt: string;
|
|
5
|
+
};
|
|
6
|
+
/** @deprecated Reviewed first-party same-origin prototype. Creators should use createSpawnGameClient in isolated previews. Never forward its cookies to another origin. */
|
|
7
|
+
export declare function createSpawnClient(gameId: string): {
|
|
8
|
+
load: <T>(key: string) => Promise<Save<T> | null>;
|
|
9
|
+
save: <T>(key: string, value: T, expectedVersion: number) => Promise<Save<T>>;
|
|
10
|
+
};
|
|
11
|
+
export type SpawnGameIdentity = {
|
|
12
|
+
id: string;
|
|
13
|
+
handle: string;
|
|
14
|
+
displayName: string;
|
|
15
|
+
avatarUrl: string | null;
|
|
16
|
+
environment: 'sandbox';
|
|
17
|
+
};
|
|
18
|
+
export type SpawnScoreSubmission = {
|
|
19
|
+
id: string;
|
|
20
|
+
verification: 'unverified';
|
|
21
|
+
};
|
|
22
|
+
export type SpawnTestPayment = {
|
|
23
|
+
id: string;
|
|
24
|
+
intentId: string;
|
|
25
|
+
amount: 10;
|
|
26
|
+
asset: 'TEST';
|
|
27
|
+
environment: 'sandbox';
|
|
28
|
+
status: 'paid';
|
|
29
|
+
};
|
|
30
|
+
export type SpawnGameClient = {
|
|
31
|
+
identity(): Promise<SpawnGameIdentity>;
|
|
32
|
+
load<T>(keyOrRequest: string | {
|
|
33
|
+
key: string;
|
|
34
|
+
}): Promise<Save<T> | null>;
|
|
35
|
+
save<T>(keyOrRequest: string | {
|
|
36
|
+
key: string;
|
|
37
|
+
value: T;
|
|
38
|
+
expectedVersion: number;
|
|
39
|
+
}, value?: T, expectedVersion?: number): Promise<Save<T>>;
|
|
40
|
+
submitScore(scoreOrRequest: number | {
|
|
41
|
+
score: number;
|
|
42
|
+
details?: Record<string, unknown>;
|
|
43
|
+
}, details?: Record<string, unknown>): Promise<SpawnScoreSubmission>;
|
|
44
|
+
requestPayment(productOrRequest: 'entry' | {
|
|
45
|
+
productId: 'entry';
|
|
46
|
+
}): Promise<SpawnTestPayment>;
|
|
47
|
+
dispose(): void;
|
|
48
|
+
};
|
|
49
|
+
export declare const MAX_GAME_BRIDGE_OUTSTANDING = 20;
|
|
50
|
+
/** Published and local launchers inject only this public origin, never identity or credentials. */
|
|
51
|
+
export declare function createSpawnGameClient(options?: {
|
|
52
|
+
platformOrigin?: string;
|
|
53
|
+
}): SpawnGameClient;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/** @deprecated Reviewed first-party same-origin prototype. Creators should use createSpawnGameClient in isolated previews. Never forward its cookies to another origin. */
|
|
2
|
+
export function createSpawnClient(gameId) {
|
|
3
|
+
if (gameId !== 'rob-the-rich')
|
|
4
|
+
throw new Error('Game is not enabled for this SDK prototype.');
|
|
5
|
+
async function request(key, init) {
|
|
6
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(key))
|
|
7
|
+
throw new Error('Invalid save key.');
|
|
8
|
+
const response = await fetch('/api/v1/game-storage/' + gameId + '/' + key, {
|
|
9
|
+
credentials: 'same-origin',
|
|
10
|
+
...init,
|
|
11
|
+
});
|
|
12
|
+
const result = await response.json();
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
const message = typeof result === 'object' &&
|
|
15
|
+
result !== null &&
|
|
16
|
+
'error' in result &&
|
|
17
|
+
typeof result.error === 'string'
|
|
18
|
+
? result.error
|
|
19
|
+
: 'Spawn request failed.';
|
|
20
|
+
throw new Error(message);
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
load: (key) => request(key),
|
|
26
|
+
save: (key, value, expectedVersion) => request(key, {
|
|
27
|
+
method: 'PUT',
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ value, expectedVersion }),
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export const MAX_GAME_BRIDGE_OUTSTANDING = 20;
|
|
34
|
+
const GAME_BRIDGE_VERSION = 1;
|
|
35
|
+
const GAME_BRIDGE_TIMEOUT_MS = 15_000;
|
|
36
|
+
const GAME_BRIDGE_PAYMENT_TIMEOUT_MS = 300_000;
|
|
37
|
+
const PUBLIC_PROFILE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
38
|
+
const DOCUMENT_TOKEN_PATTERN = /^\/build\/([A-Za-z0-9_-]{43})\//;
|
|
39
|
+
const BRIDGE_NONCE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
40
|
+
function isObject(value) {
|
|
41
|
+
return typeof value === 'object' && value !== null;
|
|
42
|
+
}
|
|
43
|
+
function validSaveKey(key) {
|
|
44
|
+
return /^[a-zA-Z0-9_-]{1,64}$/.test(key);
|
|
45
|
+
}
|
|
46
|
+
function normalizePlatformOrigin(platformOrigin) {
|
|
47
|
+
if (typeof platformOrigin !== 'string' || platformOrigin.trim() === '') {
|
|
48
|
+
throw new Error('platformOrigin is required for the embedded Spawn game client.');
|
|
49
|
+
}
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = new URL(platformOrigin);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error('platformOrigin must be an absolute HTTP or HTTPS origin.');
|
|
56
|
+
}
|
|
57
|
+
const schemeEnd = platformOrigin.indexOf('://');
|
|
58
|
+
const authorityAndRemainder = schemeEnd < 0 ? platformOrigin : platformOrigin.slice(schemeEnd + 3);
|
|
59
|
+
const remainderStart = authorityAndRemainder.search(/[/?#\\]/);
|
|
60
|
+
const remainder = remainderStart < 0 ? '' : authorityAndRemainder.slice(remainderStart);
|
|
61
|
+
const authority = remainderStart < 0 ? authorityAndRemainder : authorityAndRemainder.slice(0, remainderStart);
|
|
62
|
+
const isLiteralLoopback = !authority.includes('@') && (authority.toLowerCase() === 'localhost' ||
|
|
63
|
+
authority.toLowerCase().startsWith('localhost:') && /^localhost:\d+$/i.test(authority) ||
|
|
64
|
+
authority === '127.0.0.1' ||
|
|
65
|
+
/^127\.0\.0\.1:\d+$/.test(authority) ||
|
|
66
|
+
authority === '[::1]' ||
|
|
67
|
+
/^\[::1\]:\d+$/.test(authority));
|
|
68
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || platformOrigin !== platformOrigin.trim() || (remainder !== '' && remainder !== '/') || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash || platformOrigin.includes('?') || platformOrigin.includes('#') || parsed.protocol === 'http:' && !isLiteralLoopback) {
|
|
69
|
+
throw new Error('platformOrigin must be an absolute HTTP or HTTPS origin without a path, credentials, query or fragment.');
|
|
70
|
+
}
|
|
71
|
+
return parsed.origin;
|
|
72
|
+
}
|
|
73
|
+
function embeddedWindow() {
|
|
74
|
+
const currentWindow = globalThis.window;
|
|
75
|
+
if (!currentWindow || currentWindow.parent === currentWindow) {
|
|
76
|
+
throw new Error('createSpawnGameClient requires an embedded game iframe.');
|
|
77
|
+
}
|
|
78
|
+
return currentWindow;
|
|
79
|
+
}
|
|
80
|
+
function launchedDocumentToken(gameWindow) {
|
|
81
|
+
const pathname = gameWindow.location?.pathname;
|
|
82
|
+
const match = typeof pathname === 'string' ? DOCUMENT_TOKEN_PATTERN.exec(pathname) : null;
|
|
83
|
+
if (!match) {
|
|
84
|
+
throw new Error('createSpawnGameClient requires a launched /build/<document-token>/ game document.');
|
|
85
|
+
}
|
|
86
|
+
return match[1];
|
|
87
|
+
}
|
|
88
|
+
function bridgeRequestId() {
|
|
89
|
+
const cryptoValue = globalThis.crypto;
|
|
90
|
+
if (!cryptoValue || typeof cryptoValue.randomUUID !== 'function') {
|
|
91
|
+
throw new Error('The embedded Spawn game client requires crypto.randomUUID.');
|
|
92
|
+
}
|
|
93
|
+
return cryptoValue.randomUUID();
|
|
94
|
+
}
|
|
95
|
+
function safePayment(value) {
|
|
96
|
+
return isObject(value) &&
|
|
97
|
+
exactObjectKeys(value, ['id', 'intentId', 'amount', 'asset', 'environment', 'status']) &&
|
|
98
|
+
typeof value.id === 'string' &&
|
|
99
|
+
typeof value.intentId === 'string' &&
|
|
100
|
+
value.amount === 10 &&
|
|
101
|
+
value.asset === 'TEST' &&
|
|
102
|
+
value.environment === 'sandbox' &&
|
|
103
|
+
value.status === 'paid';
|
|
104
|
+
}
|
|
105
|
+
function exactObjectKeys(value, required, optional = []) {
|
|
106
|
+
const allowed = new Set([...required, ...optional]);
|
|
107
|
+
const keys = Object.keys(value);
|
|
108
|
+
return required.every((key) => key in value) && keys.every((key) => allowed.has(key));
|
|
109
|
+
}
|
|
110
|
+
function validAvatarUrl(value, targetOrigin) {
|
|
111
|
+
if (typeof value !== 'string' || value.trim() === '' || value !== value.trim())
|
|
112
|
+
return false;
|
|
113
|
+
try {
|
|
114
|
+
const parsed = new URL(value);
|
|
115
|
+
const avatarPathPrefix = '/api/v1/avatars/';
|
|
116
|
+
const profileId = parsed.pathname.startsWith(avatarPathPrefix)
|
|
117
|
+
? parsed.pathname.slice(avatarPathPrefix.length)
|
|
118
|
+
: '';
|
|
119
|
+
return ['http:', 'https:'].includes(parsed.protocol) &&
|
|
120
|
+
parsed.origin === targetOrigin &&
|
|
121
|
+
!parsed.username &&
|
|
122
|
+
!parsed.password &&
|
|
123
|
+
!parsed.hash &&
|
|
124
|
+
PUBLIC_PROFILE_ID_PATTERN.test(profileId) &&
|
|
125
|
+
(parsed.search === '' || /^\?v=[0-9a-f]{16}$/.test(parsed.search));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function normalizeIdentity(value, targetOrigin) {
|
|
132
|
+
if (!isObject(value) || !exactObjectKeys(value, ['id', 'handle', 'displayName', 'environment'], ['avatarUrl'])) {
|
|
133
|
+
throw new Error('Spawn bridge returned an invalid identity.');
|
|
134
|
+
}
|
|
135
|
+
if (typeof value.id !== 'string' ||
|
|
136
|
+
typeof value.handle !== 'string' ||
|
|
137
|
+
typeof value.displayName !== 'string' ||
|
|
138
|
+
value.environment !== 'sandbox') {
|
|
139
|
+
throw new Error('Spawn bridge returned an invalid identity.');
|
|
140
|
+
}
|
|
141
|
+
if (value.avatarUrl !== undefined && value.avatarUrl !== null && !validAvatarUrl(value.avatarUrl, targetOrigin)) {
|
|
142
|
+
throw new Error('Spawn bridge returned an invalid avatar URL.');
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
id: value.id,
|
|
146
|
+
handle: value.handle,
|
|
147
|
+
displayName: value.displayName,
|
|
148
|
+
avatarUrl: value.avatarUrl ?? null,
|
|
149
|
+
environment: 'sandbox',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function isMessagePort(value) {
|
|
153
|
+
return isObject(value) &&
|
|
154
|
+
typeof value.postMessage === 'function' &&
|
|
155
|
+
typeof value.addEventListener === 'function' &&
|
|
156
|
+
typeof value.removeEventListener === 'function' &&
|
|
157
|
+
typeof value.close === 'function';
|
|
158
|
+
}
|
|
159
|
+
function closeMessagePort(port) {
|
|
160
|
+
if (!port)
|
|
161
|
+
return;
|
|
162
|
+
try {
|
|
163
|
+
port.close();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// A disconnected MessagePort is already unusable.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Published and local launchers inject only this public origin, never identity or credentials. */
|
|
170
|
+
export function createSpawnGameClient(options = {}) {
|
|
171
|
+
const injected = globalThis.__SPAWN_LAUNCH__;
|
|
172
|
+
const platformOrigin = options.platformOrigin ?? (typeof injected?.platformOrigin === 'string' ? injected.platformOrigin : '');
|
|
173
|
+
const targetOrigin = normalizePlatformOrigin(platformOrigin);
|
|
174
|
+
const gameWindow = embeddedWindow();
|
|
175
|
+
const documentToken = launchedDocumentToken(gameWindow);
|
|
176
|
+
let disposed = false;
|
|
177
|
+
let handshakeAccepted = false;
|
|
178
|
+
let connectedPort;
|
|
179
|
+
const pending = new Map();
|
|
180
|
+
const removePending = (id) => {
|
|
181
|
+
const request = pending.get(id);
|
|
182
|
+
if (!request)
|
|
183
|
+
return undefined;
|
|
184
|
+
pending.delete(id);
|
|
185
|
+
if (request.timer !== undefined)
|
|
186
|
+
clearTimeout(request.timer);
|
|
187
|
+
return request;
|
|
188
|
+
};
|
|
189
|
+
const onPortMessage = (event) => {
|
|
190
|
+
const data = event.data;
|
|
191
|
+
if (isObject(data) && data.type === 'spawn:ready') {
|
|
192
|
+
if (connectedPort &&
|
|
193
|
+
data.version === GAME_BRIDGE_VERSION &&
|
|
194
|
+
exactObjectKeys(data, ['type', 'version', 'nonce']) &&
|
|
195
|
+
typeof data.nonce === 'string' &&
|
|
196
|
+
BRIDGE_NONCE_PATTERN.test(data.nonce)) {
|
|
197
|
+
try {
|
|
198
|
+
connectedPort.postMessage({
|
|
199
|
+
type: 'spawn:ready-ack',
|
|
200
|
+
version: GAME_BRIDGE_VERSION,
|
|
201
|
+
nonce: data.nonce,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// The parent may have invalidated this port during navigation.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (!isObject(data) || data.type !== 'spawn:response' || data.version !== GAME_BRIDGE_VERSION || typeof data.id !== 'string')
|
|
211
|
+
return;
|
|
212
|
+
const request = pending.get(data.id);
|
|
213
|
+
if (!request)
|
|
214
|
+
return;
|
|
215
|
+
if (data.ok !== true && data.ok !== false)
|
|
216
|
+
return;
|
|
217
|
+
if (data.ok === true) {
|
|
218
|
+
if (!exactObjectKeys(data, ['type', 'version', 'id', 'ok', 'value']))
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
else if (!exactObjectKeys(data, ['type', 'version', 'id', 'ok', 'error']) || typeof data.error !== 'string') {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const completed = removePending(data.id);
|
|
225
|
+
if (!completed)
|
|
226
|
+
return;
|
|
227
|
+
if (data.ok === true)
|
|
228
|
+
completed.resolve(data.value);
|
|
229
|
+
else if (typeof data.error === 'string')
|
|
230
|
+
completed.reject(new Error(data.error));
|
|
231
|
+
else
|
|
232
|
+
completed.reject(new Error('Spawn bridge returned an invalid error.'));
|
|
233
|
+
};
|
|
234
|
+
const sendPending = (id, request) => {
|
|
235
|
+
if (!connectedPort || pending.get(id) !== request)
|
|
236
|
+
return;
|
|
237
|
+
try {
|
|
238
|
+
connectedPort.postMessage(request.message);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
const failed = removePending(id);
|
|
242
|
+
if (failed)
|
|
243
|
+
failed.reject(error instanceof Error ? error : new Error('Spawn bridge request failed.'));
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
const flushQueued = () => {
|
|
247
|
+
if (!connectedPort)
|
|
248
|
+
return;
|
|
249
|
+
for (const [id, request] of pending)
|
|
250
|
+
sendPending(id, request);
|
|
251
|
+
};
|
|
252
|
+
const onWindowMessage = (event) => {
|
|
253
|
+
if (event.source !== gameWindow.parent || event.origin !== targetOrigin)
|
|
254
|
+
return;
|
|
255
|
+
const data = event.data;
|
|
256
|
+
if (!isObject(data) || data.type !== 'spawn:connected' || data.version !== GAME_BRIDGE_VERSION || !exactObjectKeys(data, ['type', 'version']))
|
|
257
|
+
return;
|
|
258
|
+
if (handshakeAccepted) {
|
|
259
|
+
const candidate = event.ports?.length === 1 && isMessagePort(event.ports[0])
|
|
260
|
+
? event.ports[0]
|
|
261
|
+
: undefined;
|
|
262
|
+
if (candidate && candidate !== connectedPort)
|
|
263
|
+
closeMessagePort(candidate);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (event.ports?.length !== 1 || !isMessagePort(event.ports[0]))
|
|
267
|
+
return;
|
|
268
|
+
const port = event.ports[0];
|
|
269
|
+
handshakeAccepted = true;
|
|
270
|
+
connectedPort = port;
|
|
271
|
+
try {
|
|
272
|
+
port.addEventListener('message', onPortMessage);
|
|
273
|
+
port.start?.();
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
connectedPort = undefined;
|
|
277
|
+
closeMessagePort(port);
|
|
278
|
+
for (const id of pending.keys()) {
|
|
279
|
+
const failed = removePending(id);
|
|
280
|
+
if (failed)
|
|
281
|
+
failed.reject(error instanceof Error ? error : new Error('Spawn bridge connection failed.'));
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
flushQueued();
|
|
286
|
+
};
|
|
287
|
+
gameWindow.addEventListener('message', onWindowMessage);
|
|
288
|
+
try {
|
|
289
|
+
gameWindow.parent.postMessage({ type: 'spawn:connect', version: GAME_BRIDGE_VERSION, documentToken }, targetOrigin);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
gameWindow.removeEventListener('message', onWindowMessage);
|
|
293
|
+
throw error instanceof Error ? error : new Error('Spawn bridge connection failed.');
|
|
294
|
+
}
|
|
295
|
+
function request(method, payload, timeoutMs) {
|
|
296
|
+
if (disposed)
|
|
297
|
+
return Promise.reject(new Error('Spawn game client is disposed.'));
|
|
298
|
+
if (pending.size >= MAX_GAME_BRIDGE_OUTSTANDING) {
|
|
299
|
+
return Promise.reject(new Error(`Spawn game client allows at most ${MAX_GAME_BRIDGE_OUTSTANDING} outstanding requests.`));
|
|
300
|
+
}
|
|
301
|
+
const id = bridgeRequestId();
|
|
302
|
+
const requestState = {
|
|
303
|
+
message: {
|
|
304
|
+
type: 'spawn:request',
|
|
305
|
+
version: GAME_BRIDGE_VERSION,
|
|
306
|
+
id,
|
|
307
|
+
method,
|
|
308
|
+
payload,
|
|
309
|
+
},
|
|
310
|
+
resolve: () => undefined,
|
|
311
|
+
reject: () => undefined,
|
|
312
|
+
};
|
|
313
|
+
const promise = new Promise((resolve, reject) => {
|
|
314
|
+
requestState.resolve = resolve;
|
|
315
|
+
requestState.reject = reject;
|
|
316
|
+
});
|
|
317
|
+
pending.set(id, requestState);
|
|
318
|
+
requestState.timer = setTimeout(() => {
|
|
319
|
+
const current = pending.get(id);
|
|
320
|
+
if (!current)
|
|
321
|
+
return;
|
|
322
|
+
pending.delete(id);
|
|
323
|
+
if (current.timer !== undefined)
|
|
324
|
+
clearTimeout(current.timer);
|
|
325
|
+
current.reject(new Error('Spawn bridge request timed out.'));
|
|
326
|
+
}, timeoutMs);
|
|
327
|
+
sendPending(id, requestState);
|
|
328
|
+
return promise;
|
|
329
|
+
}
|
|
330
|
+
function dispose() {
|
|
331
|
+
if (disposed)
|
|
332
|
+
return;
|
|
333
|
+
disposed = true;
|
|
334
|
+
gameWindow.removeEventListener('message', onWindowMessage);
|
|
335
|
+
if (connectedPort) {
|
|
336
|
+
connectedPort.removeEventListener('message', onPortMessage);
|
|
337
|
+
closeMessagePort(connectedPort);
|
|
338
|
+
connectedPort = undefined;
|
|
339
|
+
}
|
|
340
|
+
for (const request of pending.values()) {
|
|
341
|
+
if (request.timer !== undefined)
|
|
342
|
+
clearTimeout(request.timer);
|
|
343
|
+
request.reject(new Error('Spawn game client is disposed.'));
|
|
344
|
+
}
|
|
345
|
+
pending.clear();
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
identity: () => request('identity', {}, GAME_BRIDGE_TIMEOUT_MS).then((value) => normalizeIdentity(value, targetOrigin)),
|
|
349
|
+
load: (keyOrRequest) => {
|
|
350
|
+
const key = typeof keyOrRequest === 'string'
|
|
351
|
+
? keyOrRequest
|
|
352
|
+
: isObject(keyOrRequest) && typeof keyOrRequest.key === 'string' && exactObjectKeys(keyOrRequest, ['key'])
|
|
353
|
+
? keyOrRequest.key
|
|
354
|
+
: '';
|
|
355
|
+
if (!validSaveKey(key))
|
|
356
|
+
return Promise.reject(new Error('Invalid save key.'));
|
|
357
|
+
return request('load', { key }, GAME_BRIDGE_TIMEOUT_MS);
|
|
358
|
+
},
|
|
359
|
+
save: (keyOrRequest, value, expectedVersion) => {
|
|
360
|
+
const requestObject = isObject(keyOrRequest) && exactObjectKeys(keyOrRequest, ['key', 'value', 'expectedVersion'])
|
|
361
|
+
? keyOrRequest
|
|
362
|
+
: undefined;
|
|
363
|
+
const key = typeof keyOrRequest === 'string' ? keyOrRequest : requestObject?.key ?? '';
|
|
364
|
+
const actualValue = typeof keyOrRequest === 'string' ? value : requestObject?.value;
|
|
365
|
+
const actualVersion = typeof keyOrRequest === 'string' ? expectedVersion : requestObject?.expectedVersion;
|
|
366
|
+
if (!validSaveKey(key))
|
|
367
|
+
return Promise.reject(new Error('Invalid save key.'));
|
|
368
|
+
if (typeof actualVersion !== 'number' || !Number.isInteger(actualVersion) || actualVersion < 0) {
|
|
369
|
+
return Promise.reject(new Error('Invalid save version.'));
|
|
370
|
+
}
|
|
371
|
+
return request('save', { key, value: actualValue, expectedVersion: actualVersion }, GAME_BRIDGE_TIMEOUT_MS);
|
|
372
|
+
},
|
|
373
|
+
submitScore: (scoreOrRequest, details) => {
|
|
374
|
+
const requestObject = isObject(scoreOrRequest) && exactObjectKeys(scoreOrRequest, ['score'], ['details'])
|
|
375
|
+
? scoreOrRequest
|
|
376
|
+
: undefined;
|
|
377
|
+
const score = typeof scoreOrRequest === 'number' ? scoreOrRequest : requestObject?.score;
|
|
378
|
+
const actualDetails = typeof scoreOrRequest === 'number' ? details : requestObject?.details;
|
|
379
|
+
if (typeof score !== 'number' || !Number.isFinite(score)) {
|
|
380
|
+
return Promise.reject(new Error('Score must be a finite number.'));
|
|
381
|
+
}
|
|
382
|
+
if (actualDetails !== undefined && (!isObject(actualDetails) || Array.isArray(actualDetails))) {
|
|
383
|
+
return Promise.reject(new Error('Score details must be an object.'));
|
|
384
|
+
}
|
|
385
|
+
const payload = actualDetails === undefined ? { score } : { score, details: actualDetails };
|
|
386
|
+
return request('submitScore', payload, GAME_BRIDGE_TIMEOUT_MS);
|
|
387
|
+
},
|
|
388
|
+
requestPayment: (productOrRequest) => {
|
|
389
|
+
const productId = productOrRequest === 'entry' ||
|
|
390
|
+
(isObject(productOrRequest) && productOrRequest.productId === 'entry' && exactObjectKeys(productOrRequest, ['productId']))
|
|
391
|
+
? 'entry'
|
|
392
|
+
: undefined;
|
|
393
|
+
if (!productId)
|
|
394
|
+
return Promise.reject(new Error('Only the entry test product is available.'));
|
|
395
|
+
return request('requestPayment', { productId }, GAME_BRIDGE_PAYMENT_TIMEOUT_MS).then((value) => {
|
|
396
|
+
if (!safePayment(value))
|
|
397
|
+
throw new Error('Spawn bridge returned an invalid test payment.');
|
|
398
|
+
return value;
|
|
399
|
+
});
|
|
400
|
+
},
|
|
401
|
+
dispose,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Browser-only admission transport. Account proof is verified on the creator's server. */
|
|
2
|
+
export type SpawnMultiplayerOptions = {
|
|
3
|
+
platformOrigin: string;
|
|
4
|
+
serverOrigin: string;
|
|
5
|
+
/** Trusted same-server resource routing refresh; never player or payment proof. */
|
|
6
|
+
onResourcePath?: (path: string) => void;
|
|
7
|
+
};
|
|
8
|
+
export type SpawnMultiplayerClient = {
|
|
9
|
+
ready(): Promise<void>;
|
|
10
|
+
requestGrant(): Promise<{
|
|
11
|
+
ticket: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** Presentation only. Never authorizes a player, action or reward. */
|
|
14
|
+
reportConnection(state: 'connecting' | 'ready' | 'disconnected'): boolean;
|
|
15
|
+
dispose(): void;
|
|
16
|
+
};
|
|
17
|
+
export declare function createSpawnMultiplayerClient(options: SpawnMultiplayerOptions): SpawnMultiplayerClient;
|