@arcadible/sdk 1.0.0 → 1.1.1
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/README.md +3 -3
- package/dist/bus/protocol.d.ts +6 -0
- package/dist/bus/protocol.js +9 -12
- package/dist/bus/server.d.ts +2 -1
- package/dist/bus/server.js +24 -21
- package/dist/client.d.ts +10 -0
- package/dist/saves/backend/memory.js +1 -1
- package/dist/saves/client.d.ts +7 -0
- package/dist/saves/client.js +13 -1
- package/dist/saves/rules.d.ts +6 -0
- package/dist/saves/server.js +12 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
<br>
|
|
21
21
|
|
|
22
|
-
Each
|
|
22
|
+
Each build of a game on [Arcadible](https://arcadible.com) has its own origin, so its browser storage starts empty with every update. Saves made through the Arcadible SDK are kept by the play page instead, on the player's device, so they carry over. The SDK is optional, and saves are all it does.
|
|
23
23
|
|
|
24
24
|
## Install
|
|
25
25
|
|
|
@@ -27,7 +27,7 @@ Each version of a game on [Arcadible](https://arcadible.com) has its own origin,
|
|
|
27
27
|
npm install @arcadible/sdk
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
Use the package in a game with a build step, such as a Vite project. A game without one includes the hosted script instead, which Arcadible serves from every build's own origin, as `arc serve` does locally:
|
|
31
31
|
|
|
32
32
|
```html
|
|
33
33
|
<script src="/arcadible/sdk/v1.js"></script>
|
|
@@ -46,7 +46,7 @@ const progress = saved ? JSON.parse(saved) : { level: 1 };
|
|
|
46
46
|
await arcadible.saves.write("progress", JSON.stringify(progress));
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
A save is a name holding one string. `ready` settles once the game's saves have loaded, and never fails; from then on, `readSync` answers from memory. Every call that fails rejects with an `ArcadibleError`, whose `code` says what to do.
|
|
49
|
+
A save is a name holding one string. `ready` settles once the game's saves have loaded, and never fails; from then on, `readSync` answers from memory. Every call that fails rejects (or, for `readSync`, throws) with an `ArcadibleError`, whose `code` says what to do.
|
|
50
50
|
|
|
51
51
|
Off Arcadible, such as on your dev server, the package works on its own, with the same rules and limits: saves are kept on the page's own origin, and `arcadible.build` is `null`.
|
|
52
52
|
|
package/dist/bus/protocol.d.ts
CHANGED
|
@@ -16,6 +16,11 @@ export declare const Codes: {
|
|
|
16
16
|
readonly Unsupported: "unsupported";
|
|
17
17
|
};
|
|
18
18
|
export type Code = (typeof Codes)[keyof typeof Codes];
|
|
19
|
+
/**
|
|
20
|
+
* The build that's running, from the page's own lookup: its version, from
|
|
21
|
+
* `arcadible.toml` or `package.json`, and its channel (`production`,
|
|
22
|
+
* `staging`, or `local` under `arc serve`). `null` standing alone.
|
|
23
|
+
*/
|
|
19
24
|
export type Build = {
|
|
20
25
|
version: string;
|
|
21
26
|
channel: string;
|
|
@@ -55,6 +60,7 @@ type BusEvent = {
|
|
|
55
60
|
data?: unknown;
|
|
56
61
|
};
|
|
57
62
|
export type Message = Request | Response | BusEvent;
|
|
63
|
+
/** A failed call. Match on `code`; the message is for people. */
|
|
58
64
|
export declare class ArcadibleError extends Error {
|
|
59
65
|
readonly code: Code;
|
|
60
66
|
constructor(code: Code, message: string);
|
package/dist/bus/protocol.js
CHANGED
|
@@ -9,22 +9,19 @@ export const BUS_VERSION = 1;
|
|
|
9
9
|
export const HANDSHAKE_TIMEOUT = 1000;
|
|
10
10
|
// How long a request waits for its response.
|
|
11
11
|
export const REQUEST_TIMEOUT = 10_000;
|
|
12
|
-
// The play pages a game accepts a welcome from: production's, and `arc
|
|
13
|
-
// serve`'s on this machine. Another development page (play.arcadible.test)
|
|
14
|
-
// is passed with `createClient({ origins })`, never trusted by what games
|
|
15
|
-
// ship, since a hostile network could pose as an http one.
|
|
16
|
-
const HOST_ORIGINS = ["https://play.arcadible.com"];
|
|
17
|
-
// `arc serve`'s play page, on any port. The network can't pose as loopback,
|
|
18
|
-
// and on Arcadible a build's `frame-ancestors` lets only the play page frame
|
|
19
|
-
// it, so no local page can reach a player's saves there.
|
|
20
|
-
const LOCAL_ORIGIN = /^http:\/\/localhost:\d+$/u;
|
|
21
12
|
// [function]
|
|
22
13
|
// Whether a game accepts a welcome from `origin`: one of `origins` when it
|
|
23
|
-
// was given, or else
|
|
14
|
+
// was given (tests, and development pages), or else any real origin, `http`
|
|
15
|
+
// included, whatever framed it. Who may frame a build is decided outside the
|
|
16
|
+
// game, where it can change: on Arcadible by each build's `frame-ancestors`,
|
|
17
|
+
// which the browser enforces before the game runs, and elsewhere by whoever
|
|
18
|
+
// serves it. So the host is never trusted with anything of value (DESIGN.md,
|
|
19
|
+
// section 4). Only `null` is refused: a `file://` page or a sandboxed frame,
|
|
20
|
+
// which can't be told apart.
|
|
24
21
|
export function isHostOrigin(origin, origins) {
|
|
25
22
|
return origins
|
|
26
23
|
? origins.includes(origin)
|
|
27
|
-
:
|
|
24
|
+
: origin !== "null" && origin !== "";
|
|
28
25
|
}
|
|
29
26
|
// The most requests one game can have waiting on the page; past it, a
|
|
30
27
|
// request fails with `busy`.
|
|
@@ -49,7 +46,7 @@ export const Codes = {
|
|
|
49
46
|
Unsupported: "unsupported",
|
|
50
47
|
};
|
|
51
48
|
// [class]
|
|
52
|
-
|
|
49
|
+
/** A failed call. Match on `code`; the message is for people. */
|
|
53
50
|
export class ArcadibleError extends Error {
|
|
54
51
|
// [field]
|
|
55
52
|
code;
|
package/dist/bus/server.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { type Build, type Welcome } from "./protocol.js";
|
|
|
2
2
|
export type Params = Record<string, unknown>;
|
|
3
3
|
export type Handler = {
|
|
4
4
|
run(params: Params): Promise<unknown>;
|
|
5
|
-
|
|
5
|
+
key?(params: Params): string;
|
|
6
|
+
coalesce?: boolean;
|
|
6
7
|
};
|
|
7
8
|
export type ModuleContext = {
|
|
8
9
|
broadcast(name: string, data: unknown): void;
|
package/dist/bus/server.js
CHANGED
|
@@ -23,7 +23,7 @@ function paramsOf(value) {
|
|
|
23
23
|
export function createServer(options) {
|
|
24
24
|
const ports = new Set();
|
|
25
25
|
const inFlight = new Map();
|
|
26
|
-
//
|
|
26
|
+
// the newest request waiting its turn, by key.
|
|
27
27
|
const waiting = new Map();
|
|
28
28
|
let queue = Promise.resolve();
|
|
29
29
|
const enqueue = function (job) {
|
|
@@ -50,9 +50,14 @@ export function createServer(options) {
|
|
|
50
50
|
const refuse = function (port, id, error) {
|
|
51
51
|
port.postMessage({ kind: "response", id, error });
|
|
52
52
|
};
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
// answers each request in `entry`, which counted once toward the limit.
|
|
54
|
+
const respond = function ({ ids, port }, outcome) {
|
|
55
|
+
const count = inFlight.get(port);
|
|
56
|
+
if (count !== undefined)
|
|
57
|
+
inFlight.set(port, count - 1);
|
|
58
|
+
if (!ports.has(port))
|
|
59
|
+
return;
|
|
60
|
+
for (const id of ids)
|
|
56
61
|
port.postMessage({ kind: "response", id, ...outcome });
|
|
57
62
|
};
|
|
58
63
|
const answer = function (port, request) {
|
|
@@ -65,35 +70,33 @@ export function createServer(options) {
|
|
|
65
70
|
});
|
|
66
71
|
return;
|
|
67
72
|
}
|
|
73
|
+
const params = paramsOf(request.params);
|
|
74
|
+
const key = handler.key?.(params) ?? null;
|
|
75
|
+
const pending = key === null ? undefined : waiting.get(key);
|
|
76
|
+
if (handler.coalesce &&
|
|
77
|
+
pending &&
|
|
78
|
+
pending.name === name &&
|
|
79
|
+
pending.port === port) {
|
|
80
|
+
pending.params = params;
|
|
81
|
+
pending.ids.push(id);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
68
84
|
if ((inFlight.get(port) ?? 0) >= MAX_IN_FLIGHT) {
|
|
69
85
|
refuse(port, id, {
|
|
70
86
|
code: Codes.Busy,
|
|
71
|
-
message: "Too many
|
|
87
|
+
message: "Too many calls at once. Wait for some to finish.",
|
|
72
88
|
});
|
|
73
89
|
return;
|
|
74
90
|
}
|
|
75
91
|
inFlight.set(port, (inFlight.get(port) ?? 0) + 1);
|
|
76
|
-
const
|
|
77
|
-
const key = handler.coalesce
|
|
78
|
-
? `${name}\u0000${handler.coalesce(params)}`
|
|
79
|
-
: null;
|
|
80
|
-
const pending = key === null ? undefined : waiting.get(key);
|
|
81
|
-
if (pending) {
|
|
82
|
-
pending.params = params;
|
|
83
|
-
pending.ids.push(id);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const entry = { params, ids: [id] };
|
|
92
|
+
const entry = { name, port, params, ids: [id] };
|
|
87
93
|
if (key !== null)
|
|
88
94
|
waiting.set(key, entry);
|
|
89
95
|
enqueue(() => {
|
|
90
|
-
if (key !== null)
|
|
96
|
+
if (key !== null && waiting.get(key) === entry)
|
|
91
97
|
waiting.delete(key);
|
|
92
98
|
return handler.run(entry.params);
|
|
93
|
-
}).then((result) =>
|
|
94
|
-
const error = errorBody(err);
|
|
95
|
-
entry.ids.forEach((i) => respond(port, i, { error }));
|
|
96
|
-
});
|
|
99
|
+
}).then((result) => respond(entry, { result }), (err) => respond(entry, { error: errorBody(err) }));
|
|
97
100
|
};
|
|
98
101
|
const receive = function (port, data) {
|
|
99
102
|
if (!isMessage(data) || data.kind !== "request")
|
package/dist/client.d.ts
CHANGED
|
@@ -4,10 +4,20 @@ import { type Saves } from "./saves/client.js";
|
|
|
4
4
|
export type Ready = {
|
|
5
5
|
build: Build | null;
|
|
6
6
|
};
|
|
7
|
+
/** The `arcadible` object a game calls. */
|
|
7
8
|
export interface Arcadible {
|
|
9
|
+
/** The SDK's own version. */
|
|
8
10
|
readonly version: string;
|
|
11
|
+
/** Settles once the game's saves have loaded, and never fails. */
|
|
9
12
|
readonly ready: Promise<Ready>;
|
|
13
|
+
/**
|
|
14
|
+
* The build that's running, its version and channel, once `ready` has
|
|
15
|
+
* settled: compare a save's `info().version` with it, so as not to
|
|
16
|
+
* overwrite a save from a newer version. `null` standing alone, off
|
|
17
|
+
* Arcadible, and `undefined` before.
|
|
18
|
+
*/
|
|
10
19
|
readonly build: Build | null | undefined;
|
|
20
|
+
/** Saves that carry over between the game's versions. */
|
|
11
21
|
readonly saves: Saves;
|
|
12
22
|
}
|
|
13
23
|
export type ClientOptions = {
|
|
@@ -5,7 +5,7 @@ function keyOf(scope) {
|
|
|
5
5
|
return `${scope.game}\u0000${scope.channel}`;
|
|
6
6
|
}
|
|
7
7
|
// [function]
|
|
8
|
-
// Saves kept in memory, for tests,
|
|
8
|
+
// Saves kept in memory, for tests, and wherever IndexedDB
|
|
9
9
|
// won't open. Hosts sharing one act like tabs sharing a device. Its saves
|
|
10
10
|
// last as long as the page, so it says `ephemeral`.
|
|
11
11
|
export function memoryBackend({ status = "ephemeral", } = {}) {
|
package/dist/saves/client.d.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type { Bus } from "../bus/client.js";
|
|
2
2
|
import { type SaveInfo } from "./rules.js";
|
|
3
|
+
/** `arcadible.saves`: each save is a name holding one string. */
|
|
3
4
|
export interface Saves {
|
|
5
|
+
/** Keeps `data` as the save `name`, replacing what it held. */
|
|
4
6
|
write(name: string, data: string): Promise<SaveInfo>;
|
|
7
|
+
/** The save's string, or `null` when there's none. */
|
|
5
8
|
read(name: string): Promise<string | null>;
|
|
9
|
+
/** The names of the game's saves, sorted. */
|
|
6
10
|
list(): Promise<string[]>;
|
|
11
|
+
/** Which version wrote the save, and when, or `null` when there's none. */
|
|
7
12
|
info(name: string): Promise<SaveInfo | null>;
|
|
13
|
+
/** Removes the save, if there is one. */
|
|
8
14
|
remove(name: string): Promise<void>;
|
|
15
|
+
/** After `ready`, the save's string from memory; before it, throws `not_ready`. */
|
|
9
16
|
readSync(name: string): string | null;
|
|
10
17
|
}
|
|
11
18
|
export declare function createSaves(bus: Bus, { timeout }: {
|
package/dist/saves/client.js
CHANGED
|
@@ -25,6 +25,11 @@ export function createSaves(bus, { timeout }) {
|
|
|
25
25
|
// the newest revision seen of each save, removals included.
|
|
26
26
|
const revisions = new Map();
|
|
27
27
|
const early = [];
|
|
28
|
+
// the newest write of each save, until the page answers it: an older
|
|
29
|
+
// write's answer, merged with it or ahead of it, mustn't replace it in
|
|
30
|
+
// the memory copy.
|
|
31
|
+
const writing = new Map();
|
|
32
|
+
let writes = 0;
|
|
28
33
|
let open = () => undefined;
|
|
29
34
|
const opened = new Promise((resolve) => {
|
|
30
35
|
open = () => {
|
|
@@ -130,6 +135,8 @@ export function createSaves(bus, { timeout }) {
|
|
|
130
135
|
// the memory copy changes at once, so the next read sees it; the
|
|
131
136
|
// promise settles once the page has kept it.
|
|
132
137
|
const previous = mirror.get(name);
|
|
138
|
+
const ticket = ++writes;
|
|
139
|
+
writing.set(name, ticket);
|
|
133
140
|
if (optimistic) {
|
|
134
141
|
mirror.set(name, {
|
|
135
142
|
name,
|
|
@@ -147,10 +154,15 @@ export function createSaves(bus, { timeout }) {
|
|
|
147
154
|
name,
|
|
148
155
|
data,
|
|
149
156
|
}));
|
|
150
|
-
|
|
157
|
+
if (writing.get(name) === ticket) {
|
|
158
|
+
writing.delete(name);
|
|
159
|
+
kept(name, meta.revision, { name, data, info: meta });
|
|
160
|
+
}
|
|
151
161
|
return infoOf(meta);
|
|
152
162
|
}
|
|
153
163
|
catch (err) {
|
|
164
|
+
if (writing.get(name) === ticket)
|
|
165
|
+
writing.delete(name);
|
|
154
166
|
// one the page refused is rolled back, unless something newer
|
|
155
167
|
// replaced it meanwhile.
|
|
156
168
|
if (optimistic &&
|
package/dist/saves/rules.d.ts
CHANGED
|
@@ -2,8 +2,14 @@ import { ArcadibleError } from "../bus/protocol.js";
|
|
|
2
2
|
export declare const MAX_SAVES = 32;
|
|
3
3
|
export declare const MAX_TOTAL: number;
|
|
4
4
|
export type SaveStatus = "local" | "ephemeral";
|
|
5
|
+
/** What a game is told of a save. */
|
|
5
6
|
export type SaveInfo = {
|
|
7
|
+
/**
|
|
8
|
+
* The game's version that wrote it, stamped by the page; `null` when it
|
|
9
|
+
* was written standing alone, where there's no version to stamp.
|
|
10
|
+
*/
|
|
6
11
|
version: string | null;
|
|
12
|
+
/** When it was written, as an ISO 8601 time. */
|
|
7
13
|
savedAt: string;
|
|
8
14
|
};
|
|
9
15
|
export type SaveMeta = SaveInfo & {
|
package/dist/saves/server.js
CHANGED
|
@@ -23,6 +23,11 @@ function nameOf(params) {
|
|
|
23
23
|
return params["name"];
|
|
24
24
|
}
|
|
25
25
|
// [function]
|
|
26
|
+
// what a write or remove is about, so they keep their order (bus/server.ts).
|
|
27
|
+
function saveKey(params) {
|
|
28
|
+
return String(params["name"]);
|
|
29
|
+
}
|
|
30
|
+
// [function]
|
|
26
31
|
// the device is out of room: retrying won't help until the player frees
|
|
27
32
|
// some. Anything else is the bus's `unavailable`.
|
|
28
33
|
function storageError(err) {
|
|
@@ -57,9 +62,13 @@ export function savesServer(options) {
|
|
|
57
62
|
return change?.record ? toSave(change.record).info : null;
|
|
58
63
|
};
|
|
59
64
|
const load = async function () {
|
|
65
|
+
// when the saves were last opened is bookkeeping: a device too full to
|
|
66
|
+
// keep it still loads them.
|
|
60
67
|
const [list] = await Promise.all([
|
|
61
68
|
backend.list(scope),
|
|
62
|
-
backend.touch(scope, now().toISOString())
|
|
69
|
+
backend.touch(scope, now().toISOString()).catch((err) => {
|
|
70
|
+
console.warn("arcadible: the saves' last use couldn't be kept:", err);
|
|
71
|
+
}),
|
|
63
72
|
]);
|
|
64
73
|
return { status: backend.status(), items: list.map(toSave) };
|
|
65
74
|
};
|
|
@@ -89,9 +98,10 @@ export function savesServer(options) {
|
|
|
89
98
|
"saves.list": {
|
|
90
99
|
run: async () => (await backend.list(scope)).map((record) => record.name).sort(),
|
|
91
100
|
},
|
|
92
|
-
"saves.write": { run: write,
|
|
101
|
+
"saves.write": { run: write, key: saveKey, coalesce: true },
|
|
93
102
|
// the removal's revision, or null when there was nothing to remove.
|
|
94
103
|
"saves.remove": {
|
|
104
|
+
key: saveKey,
|
|
95
105
|
run: async (params) => {
|
|
96
106
|
const change = await backend.change(scope, nameOf(params), () => null, source);
|
|
97
107
|
return change?.revision ?? null;
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "1.
|
|
1
|
+
export declare const VERSION = "1.1.1";
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"author": "Offst, Inc.",
|
|
3
|
-
"description": "The Arcadible SDK:
|
|
3
|
+
"description": "The Arcadible SDK: saves that carry over between a game's versions on Arcadible.",
|
|
4
4
|
"devDependencies": {
|
|
5
5
|
"@playwright/test": "1.63.0",
|
|
6
6
|
"@types/node": "^25.0.3",
|
|
@@ -43,5 +43,5 @@
|
|
|
43
43
|
],
|
|
44
44
|
"type": "module",
|
|
45
45
|
"types": "./dist/index.d.ts",
|
|
46
|
-
"version": "1.
|
|
46
|
+
"version": "1.1.1"
|
|
47
47
|
}
|