@arcadible/sdk 1.1.1 → 1.2.0
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 +2 -2
- package/dist/bus/client.d.ts +7 -3
- package/dist/bus/client.js +107 -15
- package/dist/bus/protocol.d.ts +13 -1
- package/dist/bus/protocol.js +44 -3
- package/dist/bus/server.js +27 -7
- package/dist/client.d.ts +7 -3
- package/dist/client.js +11 -5
- package/dist/host.d.ts +2 -0
- package/dist/host.js +1 -0
- package/dist/saves/backend/indexeddb.d.ts +0 -1
- package/dist/saves/backend/indexeddb.js +53 -28
- package/dist/saves/backend/listeners.js +2 -1
- package/dist/saves/backend/memory.d.ts +2 -2
- package/dist/saves/backend/memory.js +3 -2
- package/dist/saves/backend/types.d.ts +2 -2
- package/dist/saves/client.d.ts +13 -2
- package/dist/saves/client.js +141 -94
- package/dist/saves/rules.d.ts +15 -2
- package/dist/saves/rules.js +46 -1
- package/dist/saves/server.d.ts +2 -0
- package/dist/saves/server.js +18 -12
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,9 +46,9 @@ 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`
|
|
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`, `listSync`, and `infoSync` answer from memory. Every call that fails rejects (or, for the sync calls, throws) with an `ArcadibleError`, whose `code` says what to do. `arcadible.saves.where` says who can read the saves back: every version of the game, only this build, or only until the page closes.
|
|
50
50
|
|
|
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`.
|
|
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, `arcadible.saves.where` is `"build"`, and `arcadible.build` is `null`.
|
|
52
52
|
|
|
53
53
|
## Learn more
|
|
54
54
|
|
package/dist/bus/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Welcome } from "./protocol.js";
|
|
1
|
+
import { type Shape, type Welcome } from "./protocol.js";
|
|
2
2
|
import type { Server } from "./server.js";
|
|
3
3
|
export type BusOptions = {
|
|
4
4
|
client: string;
|
|
@@ -8,9 +8,13 @@ export type BusOptions = {
|
|
|
8
8
|
requestTimeout?: number | undefined;
|
|
9
9
|
standalone: () => Server;
|
|
10
10
|
};
|
|
11
|
+
export type RequestOptions<T> = {
|
|
12
|
+
late?: (result: T) => void;
|
|
13
|
+
};
|
|
11
14
|
export type Bus = {
|
|
12
15
|
readonly welcomed: Promise<Welcome>;
|
|
13
|
-
request(name: string, params?:
|
|
14
|
-
on(name: string, listener: (data:
|
|
16
|
+
request<T>(name: string, params: object | undefined, shape: Shape<T>, options?: RequestOptions<T>): Promise<T>;
|
|
17
|
+
on<T>(name: string, shape: Shape<T>, listener: (data: T) => void): () => void;
|
|
15
18
|
};
|
|
19
|
+
export declare function mayHaveLanded(err: unknown): boolean;
|
|
16
20
|
export declare function openBus(options: BusOptions): Bus;
|
package/dist/bus/client.js
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
// The game's end of the bus: it says hello to the play page, and stands
|
|
2
2
|
// alone when no page answers, running the page's end itself, in its own
|
|
3
|
-
// page, so the rules are the same.
|
|
3
|
+
// page, so the rules are the same. It takes nothing the page sends on trust:
|
|
4
|
+
// every call settles, with what its request expects or a code.
|
|
4
5
|
// [import]
|
|
5
|
-
import { ArcadibleError, BUS_VERSION, Codes, HANDSHAKE_TIMEOUT, isHostOrigin, isMessage, isWelcome, REQUEST_TIMEOUT, } from "./protocol.js";
|
|
6
|
+
import { ArcadibleError, BUS_VERSION, Codes, HANDSHAKE_TIMEOUT, isErrorBody, isHostOrigin, isMessage, isWelcome, log, REQUEST_TIMEOUT, } from "./protocol.js";
|
|
7
|
+
// [declare]
|
|
8
|
+
// How many calls that timed out keep waiting for a late answer; past it,
|
|
9
|
+
// the oldest stop.
|
|
10
|
+
const MAX_LATE = 64;
|
|
11
|
+
// failures after which the page may still have done what was asked: a
|
|
12
|
+
// `timeout`, or an answer the game couldn't read.
|
|
13
|
+
const uncertain = new WeakSet();
|
|
14
|
+
// [function]
|
|
15
|
+
// Whether the page may have done what a failed call asked, so a game's
|
|
16
|
+
// memory copy keeps what it showed.
|
|
17
|
+
export function mayHaveLanded(err) {
|
|
18
|
+
return err instanceof ArcadibleError && uncertain.has(err);
|
|
19
|
+
}
|
|
20
|
+
// [function]
|
|
21
|
+
function unsure(code, message) {
|
|
22
|
+
const err = new ArcadibleError(code, message);
|
|
23
|
+
uncertain.add(err);
|
|
24
|
+
return err;
|
|
25
|
+
}
|
|
6
26
|
// [function]
|
|
7
27
|
export function openBus(options) {
|
|
8
28
|
const win = options.window ?? (typeof window === "undefined" ? undefined : window);
|
|
@@ -13,6 +33,7 @@ export function openBus(options) {
|
|
|
13
33
|
let nextId = 1;
|
|
14
34
|
let handshakeTimer;
|
|
15
35
|
const pending = new Map();
|
|
36
|
+
const late = new Map();
|
|
16
37
|
const listeners = new Map();
|
|
17
38
|
let welcome = () => undefined;
|
|
18
39
|
const welcomed = new Promise((resolve) => {
|
|
@@ -23,22 +44,62 @@ export function openBus(options) {
|
|
|
23
44
|
const message = event.data;
|
|
24
45
|
if (!isMessage(message))
|
|
25
46
|
return;
|
|
26
|
-
|
|
47
|
+
try {
|
|
48
|
+
handle(message);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
log("error", "a message from the play page failed.", err);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
// [function]
|
|
55
|
+
// a call's answer: settled before anything else is read, so whatever the
|
|
56
|
+
// page sent, the call ends.
|
|
57
|
+
const settle = function (call, message) {
|
|
58
|
+
const error = message["error"];
|
|
59
|
+
if (error !== undefined) {
|
|
60
|
+
if (!isErrorBody(error)) {
|
|
61
|
+
call.reject(unsure(Codes.Unavailable, "The play page's answer was malformed."));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const text = error.message;
|
|
65
|
+
call.reject(new ArcadibleError(error.code, typeof text === "string" ? text : "That failed. Try again."));
|
|
66
|
+
}
|
|
67
|
+
else if (call.shape(message["result"])) {
|
|
68
|
+
call.resolve(message["result"]);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
call.reject(unsure(Codes.Unavailable, "The play page's answer was malformed."));
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
// [function]
|
|
75
|
+
const answerLate = function (id, message) {
|
|
76
|
+
const call = late.get(id);
|
|
77
|
+
if (!call)
|
|
78
|
+
return;
|
|
79
|
+
late.delete(id);
|
|
80
|
+
const result = message["result"];
|
|
81
|
+
if (message["error"] === undefined && call.shape(result)) {
|
|
82
|
+
call.late(result);
|
|
83
|
+
}
|
|
27
84
|
};
|
|
28
85
|
// [function]
|
|
29
86
|
const handle = function (message) {
|
|
30
87
|
if (message.kind === "response") {
|
|
31
88
|
const call = pending.get(message.id);
|
|
32
|
-
if (!call)
|
|
89
|
+
if (!call) {
|
|
90
|
+
answerLate(message.id, message);
|
|
33
91
|
return;
|
|
92
|
+
}
|
|
34
93
|
pending.delete(message.id);
|
|
35
94
|
clearTimeout(call.timer);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
call.reject(new ArcadibleError(code, text));
|
|
95
|
+
try {
|
|
96
|
+
settle(call, message);
|
|
39
97
|
}
|
|
40
|
-
|
|
41
|
-
call
|
|
98
|
+
catch (err) {
|
|
99
|
+
// `settle` rejects before it could throw; this only keeps a call
|
|
100
|
+
// from hanging if that ever changes.
|
|
101
|
+
call.reject(unsure(Codes.Unavailable, "The play page's answer was malformed."));
|
|
102
|
+
log("error", `${call.name}'s answer couldn't be read.`, err);
|
|
42
103
|
}
|
|
43
104
|
}
|
|
44
105
|
else if (message.kind === "event") {
|
|
@@ -71,6 +132,9 @@ export function openBus(options) {
|
|
|
71
132
|
return;
|
|
72
133
|
state = "standalone";
|
|
73
134
|
win?.removeEventListener("message", onWindowMessage);
|
|
135
|
+
if (win && win.parent !== win) {
|
|
136
|
+
log("warn", 'no play page answered within a second, so this game keeps its saves on its own origin, where only this build reads them (saves.where is "build"); reloading usually fixes it.');
|
|
137
|
+
}
|
|
74
138
|
const channel = new MessageChannel();
|
|
75
139
|
attach(channel.port1, options.standalone().connect(channel.port2));
|
|
76
140
|
};
|
|
@@ -101,7 +165,7 @@ export function openBus(options) {
|
|
|
101
165
|
}
|
|
102
166
|
return {
|
|
103
167
|
welcomed,
|
|
104
|
-
async request(name, params) {
|
|
168
|
+
async request(name, params, shape, requestOptions = {}) {
|
|
105
169
|
await welcomed;
|
|
106
170
|
// welcomed, so there's a channel: the play page's, or this page's own.
|
|
107
171
|
const link = port;
|
|
@@ -111,22 +175,50 @@ export function openBus(options) {
|
|
|
111
175
|
const id = nextId++;
|
|
112
176
|
return new Promise((resolve, reject) => {
|
|
113
177
|
const timer = setTimeout(() => {
|
|
178
|
+
const call = pending.get(id);
|
|
114
179
|
pending.delete(id);
|
|
115
|
-
|
|
180
|
+
if (call?.late) {
|
|
181
|
+
late.set(id, { shape: call.shape, late: call.late });
|
|
182
|
+
if (late.size > MAX_LATE) {
|
|
183
|
+
late.delete(late.keys().next().value);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
reject(unsure(Codes.Timeout, "The play page took too long to answer. Try again."));
|
|
116
187
|
}, requestTimeout);
|
|
117
|
-
pending.set(id, {
|
|
188
|
+
pending.set(id, {
|
|
189
|
+
name,
|
|
190
|
+
resolve: resolve,
|
|
191
|
+
reject,
|
|
192
|
+
timer,
|
|
193
|
+
shape,
|
|
194
|
+
late: requestOptions.late,
|
|
195
|
+
});
|
|
118
196
|
link.postMessage({ kind: "request", id, name, params });
|
|
119
197
|
});
|
|
120
198
|
},
|
|
121
|
-
on(name, listener) {
|
|
199
|
+
on(name, shape, listener) {
|
|
200
|
+
// each listener on its own: one that throws, or data it can't take,
|
|
201
|
+
// doesn't stop the others.
|
|
202
|
+
const checked = function (data) {
|
|
203
|
+
if (!shape(data)) {
|
|
204
|
+
log("warn", `ignored a malformed ${name} from the play page.`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
listener(data);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
log("error", `a ${name} listener failed.`, err);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
122
214
|
let set = listeners.get(name);
|
|
123
215
|
if (!set) {
|
|
124
216
|
set = new Set();
|
|
125
217
|
listeners.set(name, set);
|
|
126
218
|
}
|
|
127
|
-
set.add(
|
|
219
|
+
set.add(checked);
|
|
128
220
|
return () => {
|
|
129
|
-
set.delete(
|
|
221
|
+
set.delete(checked);
|
|
130
222
|
};
|
|
131
223
|
},
|
|
132
224
|
};
|
package/dist/bus/protocol.d.ts
CHANGED
|
@@ -19,7 +19,8 @@ export type Code = (typeof Codes)[keyof typeof Codes];
|
|
|
19
19
|
/**
|
|
20
20
|
* The build that's running, from the page's own lookup: its version, from
|
|
21
21
|
* `arcadible.toml` or `package.json`, and its channel (`production`,
|
|
22
|
-
* `staging`, or `local` under `arc serve`). `null` standing alone
|
|
22
|
+
* `staging`, or `local` under `arc serve`). `null` standing alone, or when
|
|
23
|
+
* the page doesn't say.
|
|
23
24
|
*/
|
|
24
25
|
export type Build = {
|
|
25
26
|
version: string;
|
|
@@ -35,6 +36,7 @@ export type Welcome = {
|
|
|
35
36
|
v: number;
|
|
36
37
|
build: Build | null;
|
|
37
38
|
};
|
|
39
|
+
export type Shape<T> = (value: unknown) => value is T;
|
|
38
40
|
export type ErrorBody = {
|
|
39
41
|
code: string;
|
|
40
42
|
message: string;
|
|
@@ -66,8 +68,18 @@ export declare class ArcadibleError extends Error {
|
|
|
66
68
|
constructor(code: Code, message: string);
|
|
67
69
|
toJSON(): ErrorBody;
|
|
68
70
|
}
|
|
71
|
+
export declare function isObject(value: unknown): value is Record<string, unknown>;
|
|
72
|
+
export declare function isString(value: unknown): value is string;
|
|
73
|
+
export declare function isNumber(value: unknown): value is number;
|
|
74
|
+
export declare function isStringOrNull(value: unknown): value is string | null;
|
|
75
|
+
export declare function isStringArray(value: unknown): value is string[];
|
|
76
|
+
export declare function isErrorBody(value: unknown): value is {
|
|
77
|
+
code: string;
|
|
78
|
+
};
|
|
79
|
+
export declare function log(level: "warn" | "error", sentence: string, ...detail: unknown[]): void;
|
|
69
80
|
export declare function isHello(value: unknown): value is Hello;
|
|
70
81
|
export declare function isWelcome(value: unknown): value is Welcome;
|
|
82
|
+
export declare function buildOf(value: unknown): Build | null;
|
|
71
83
|
export declare function isMessage(value: unknown): value is Message;
|
|
72
84
|
export declare function utf8Size(text: string): number;
|
|
73
85
|
export declare function messageSize(value: unknown, limit?: number): number;
|
package/dist/bus/protocol.js
CHANGED
|
@@ -26,8 +26,10 @@ export function isHostOrigin(origin, origins) {
|
|
|
26
26
|
// The most requests one game can have waiting on the page; past it, a
|
|
27
27
|
// request fails with `busy`.
|
|
28
28
|
export const MAX_IN_FLIGHT = 64;
|
|
29
|
-
//
|
|
30
|
-
// save, and the rest of the
|
|
29
|
+
// The largest message either side sends: room for a whole 1 MB save, or
|
|
30
|
+
// every save at once (a game's are 1 MB together), and the rest of the
|
|
31
|
+
// message. The page refuses a larger request, and never sends a larger
|
|
32
|
+
// answer or event (DESIGN.md, section 4).
|
|
31
33
|
export const MAX_MESSAGE = 1024 * 1024 + 64 * 1024;
|
|
32
34
|
// [declare]
|
|
33
35
|
// Every code a call can fail with. Worth retrying: `busy`, `timeout`, and
|
|
@@ -62,10 +64,38 @@ export class ArcadibleError extends Error {
|
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
// [function]
|
|
65
|
-
function isObject(value) {
|
|
67
|
+
export function isObject(value) {
|
|
66
68
|
return typeof value === "object" && value !== null;
|
|
67
69
|
}
|
|
68
70
|
// [function]
|
|
71
|
+
export function isString(value) {
|
|
72
|
+
return typeof value === "string";
|
|
73
|
+
}
|
|
74
|
+
// [function]
|
|
75
|
+
export function isNumber(value) {
|
|
76
|
+
return typeof value === "number";
|
|
77
|
+
}
|
|
78
|
+
// [function]
|
|
79
|
+
export function isStringOrNull(value) {
|
|
80
|
+
return value === null || typeof value === "string";
|
|
81
|
+
}
|
|
82
|
+
// [function]
|
|
83
|
+
export function isStringArray(value) {
|
|
84
|
+
return Array.isArray(value) && value.every(isString);
|
|
85
|
+
}
|
|
86
|
+
// [function]
|
|
87
|
+
// What an error is sent as: a string `code`, and a message for people.
|
|
88
|
+
export function isErrorBody(value) {
|
|
89
|
+
return isObject(value) && typeof value["code"] === "string";
|
|
90
|
+
}
|
|
91
|
+
// [function]
|
|
92
|
+
// The SDK's console line: one sentence after its name, so a developer can
|
|
93
|
+
// tell it from the game's own. `warn` for a degraded state, and `error`
|
|
94
|
+
// only for a bug.
|
|
95
|
+
export function log(level, sentence, ...detail) {
|
|
96
|
+
console[level](`Arcadible SDK: ${sentence}`, ...detail);
|
|
97
|
+
}
|
|
98
|
+
// [function]
|
|
69
99
|
export function isHello(value) {
|
|
70
100
|
return (isObject(value) &&
|
|
71
101
|
value["arcadible"] === "hello" &&
|
|
@@ -79,6 +109,17 @@ export function isWelcome(value) {
|
|
|
79
109
|
value["v"] === BUS_VERSION);
|
|
80
110
|
}
|
|
81
111
|
// [function]
|
|
112
|
+
// The welcome's build as a game sees it: a copy of just its version and
|
|
113
|
+
// channel, so a page can't add fields games come to depend on, or `null`
|
|
114
|
+
// when the page doesn't say, or says it wrong.
|
|
115
|
+
export function buildOf(value) {
|
|
116
|
+
return isObject(value) &&
|
|
117
|
+
typeof value["version"] === "string" &&
|
|
118
|
+
typeof value["channel"] === "string"
|
|
119
|
+
? { version: value["version"], channel: value["channel"] }
|
|
120
|
+
: null;
|
|
121
|
+
}
|
|
122
|
+
// [function]
|
|
82
123
|
export function isMessage(value) {
|
|
83
124
|
if (!isObject(value))
|
|
84
125
|
return false;
|
package/dist/bus/server.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// The page's end of the bus: it answers a game's requests with its modules'
|
|
2
2
|
// handlers, one at a time in order, and refuses what's too large, too many,
|
|
3
|
-
// or unknown.
|
|
3
|
+
// or unknown. It never sends more than `MAX_MESSAGE` either. `answerHellos`
|
|
4
|
+
// connects it to the play page's iframe.
|
|
4
5
|
// [import]
|
|
5
|
-
import { ArcadibleError, BUS_VERSION, Codes, isHello, isMessage, MAX_IN_FLIGHT, MAX_MESSAGE, messageSize, } from "./protocol.js";
|
|
6
|
+
import { ArcadibleError, BUS_VERSION, Codes, isHello, isMessage, log, MAX_IN_FLIGHT, MAX_MESSAGE, messageSize, } from "./protocol.js";
|
|
6
7
|
// [function]
|
|
7
8
|
function errorBody(err) {
|
|
8
9
|
if (err instanceof ArcadibleError)
|
|
9
10
|
return err.toJSON();
|
|
10
|
-
|
|
11
|
+
log("error", "a request failed.", err);
|
|
11
12
|
return {
|
|
12
13
|
code: Codes.Unavailable,
|
|
13
14
|
message: "That's unavailable right now. Try again.",
|
|
@@ -31,9 +32,20 @@ export function createServer(options) {
|
|
|
31
32
|
queue = run.catch(() => undefined);
|
|
32
33
|
return run;
|
|
33
34
|
};
|
|
35
|
+
// nothing goes to a game that's larger than a request may be: the limits
|
|
36
|
+
// keep every answer and event within it, so one that isn't is a bug.
|
|
37
|
+
const fits = function (message, what) {
|
|
38
|
+
if (messageSize(message, MAX_MESSAGE) <= MAX_MESSAGE)
|
|
39
|
+
return true;
|
|
40
|
+
log("error", `${what} was too large to send.`);
|
|
41
|
+
return false;
|
|
42
|
+
};
|
|
34
43
|
const send = function (port, name, data) {
|
|
35
|
-
if (ports.has(port))
|
|
36
|
-
|
|
44
|
+
if (!ports.has(port))
|
|
45
|
+
return;
|
|
46
|
+
const message = { kind: "event", name, data };
|
|
47
|
+
if (fits(message, name))
|
|
48
|
+
port.postMessage(message);
|
|
37
49
|
};
|
|
38
50
|
const modules = options.modules.map((make) => make({
|
|
39
51
|
broadcast(name, data) {
|
|
@@ -51,14 +63,22 @@ export function createServer(options) {
|
|
|
51
63
|
port.postMessage({ kind: "response", id, error });
|
|
52
64
|
};
|
|
53
65
|
// answers each request in `entry`, which counted once toward the limit.
|
|
54
|
-
const respond = function ({ ids, port }, outcome) {
|
|
66
|
+
const respond = function ({ ids, name, port }, outcome) {
|
|
55
67
|
const count = inFlight.get(port);
|
|
56
68
|
if (count !== undefined)
|
|
57
69
|
inFlight.set(port, count - 1);
|
|
58
70
|
if (!ports.has(port))
|
|
59
71
|
return;
|
|
72
|
+
const fitting = "result" in outcome && !fits(outcome, `${name}'s answer`)
|
|
73
|
+
? {
|
|
74
|
+
error: {
|
|
75
|
+
code: Codes.Unavailable,
|
|
76
|
+
message: "That's unavailable right now. Try again.",
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
: outcome;
|
|
60
80
|
for (const id of ids)
|
|
61
|
-
port.postMessage({ kind: "response", id, ...
|
|
81
|
+
port.postMessage({ kind: "response", id, ...fitting });
|
|
62
82
|
};
|
|
63
83
|
const answer = function (port, request) {
|
|
64
84
|
const { id, name } = request;
|
package/dist/client.d.ts
CHANGED
|
@@ -8,13 +8,17 @@ export type Ready = {
|
|
|
8
8
|
export interface Arcadible {
|
|
9
9
|
/** The SDK's own version. */
|
|
10
10
|
readonly version: string;
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Settles once the game's saves have loaded, or after 10 seconds if they
|
|
13
|
+
* haven't, and never fails. It resolves with `build`; each module's own
|
|
14
|
+
* facts are on the module, such as `saves.where`.
|
|
15
|
+
*/
|
|
12
16
|
readonly ready: Promise<Ready>;
|
|
13
17
|
/**
|
|
14
18
|
* The build that's running, its version and channel, once `ready` has
|
|
15
19
|
* settled: compare a save's `info().version` with it, so as not to
|
|
16
|
-
* overwrite a save from a newer version. `null`
|
|
17
|
-
*
|
|
20
|
+
* overwrite a save from a newer version. `null` when nobody says, such as
|
|
21
|
+
* standing alone, and `undefined` before.
|
|
18
22
|
*/
|
|
19
23
|
readonly build: Build | null | undefined;
|
|
20
24
|
/** Saves that carry over between the game's versions. */
|
package/dist/client.js
CHANGED
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
// same rules in its own page, when no page answers.
|
|
4
4
|
// [import]
|
|
5
5
|
import { openBus } from "./bus/client.js";
|
|
6
|
-
import { REQUEST_TIMEOUT } from "./bus/protocol.js";
|
|
6
|
+
import { buildOf, REQUEST_TIMEOUT } from "./bus/protocol.js";
|
|
7
7
|
import { indexedDBBackend } from "./saves/backend/indexeddb.js";
|
|
8
8
|
import { createSaves } from "./saves/client.js";
|
|
9
9
|
import { serve } from "./host.js";
|
|
10
10
|
import { VERSION } from "./version.js";
|
|
11
|
-
// The scope a game standing alone keeps its saves under, on its own origin
|
|
12
|
-
// Standing alone there's no version to
|
|
13
|
-
|
|
11
|
+
// The scope a game standing alone keeps its saves under, on its own origin,
|
|
12
|
+
// where only this build reads them. Standing alone there's no version to
|
|
13
|
+
// stamp: saves say `null`.
|
|
14
|
+
const STANDALONE = {
|
|
15
|
+
game: "standalone",
|
|
16
|
+
channel: "local",
|
|
17
|
+
version: null,
|
|
18
|
+
where: "build",
|
|
19
|
+
};
|
|
14
20
|
// [function]
|
|
15
21
|
export function createClient(options = {}) {
|
|
16
22
|
const backend = options.backend ?? (() => indexedDBBackend());
|
|
@@ -28,7 +34,7 @@ export function createClient(options = {}) {
|
|
|
28
34
|
let settled = null;
|
|
29
35
|
// ready once welcomed, and each module's first messages have arrived.
|
|
30
36
|
const ready = Promise.all([bus.welcomed, opened]).then(([welcome]) => {
|
|
31
|
-
settled = { build: welcome.build
|
|
37
|
+
settled = { build: buildOf(welcome.build) };
|
|
32
38
|
return settled;
|
|
33
39
|
});
|
|
34
40
|
return {
|
package/dist/host.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type Server } from "./bus/server.js";
|
|
2
2
|
import type { Backend } from "./saves/backend/types.js";
|
|
3
|
+
import type { Where } from "./saves/rules.js";
|
|
3
4
|
export type ServeOptions = {
|
|
4
5
|
game: string;
|
|
5
6
|
channel: string;
|
|
6
7
|
version: string | null;
|
|
7
8
|
backend: Backend;
|
|
9
|
+
where?: Where;
|
|
8
10
|
now?: () => Date;
|
|
9
11
|
};
|
|
10
12
|
type HostOptions = ServeOptions & {
|
package/dist/host.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// [import]
|
|
2
|
+
import { ArcadibleError, Codes, log } from "../../bus/protocol.js";
|
|
2
3
|
import { listenersByKey } from "./listeners.js";
|
|
3
4
|
import { memoryBackend } from "./memory.js";
|
|
4
5
|
// [declare]
|
|
@@ -12,9 +13,6 @@ const CHANNEL = "arcadible-saves";
|
|
|
12
13
|
// every save is a guest's for One; a signed-in player's sit beside them
|
|
13
14
|
// later, under their own id, with no migration.
|
|
14
15
|
const GUEST = "guest";
|
|
15
|
-
// how long opening may take before saves are kept in memory instead: some
|
|
16
|
-
// browsers can leave an open hanging.
|
|
17
|
-
const OPEN_TIMEOUT = 5000;
|
|
18
16
|
// [function]
|
|
19
17
|
function settle(request) {
|
|
20
18
|
return new Promise((resolve, reject) => {
|
|
@@ -31,9 +29,10 @@ function finish(tx) {
|
|
|
31
29
|
});
|
|
32
30
|
}
|
|
33
31
|
// [function]
|
|
34
|
-
|
|
32
|
+
// Opens the database, however long it takes: a slow open is waited for,
|
|
33
|
+
// since the saves are late, not gone (the game's calls wait, or time out).
|
|
34
|
+
function open(factory) {
|
|
35
35
|
return new Promise((resolve, reject) => {
|
|
36
|
-
const timer = setTimeout(() => reject(new Error("Opening IndexedDB timed out.")), timeout);
|
|
37
36
|
const request = factory.open(DB_NAME, DB_VERSION);
|
|
38
37
|
request.onupgradeneeded = () => {
|
|
39
38
|
const db = request.result;
|
|
@@ -45,14 +44,8 @@ function open(factory, timeout) {
|
|
|
45
44
|
saves.createIndex(BY_SCOPE, ["player", "game", "channel"]);
|
|
46
45
|
db.createObjectStore(GAMES, { keyPath: ["player", "game", "channel"] });
|
|
47
46
|
};
|
|
48
|
-
request.onsuccess = () =>
|
|
49
|
-
|
|
50
|
-
resolve(request.result);
|
|
51
|
-
};
|
|
52
|
-
request.onerror = () => {
|
|
53
|
-
clearTimeout(timer);
|
|
54
|
-
reject(request.error);
|
|
55
|
-
};
|
|
47
|
+
request.onsuccess = () => resolve(request.result);
|
|
48
|
+
request.onerror = () => reject(request.error);
|
|
56
49
|
});
|
|
57
50
|
}
|
|
58
51
|
// [function]
|
|
@@ -69,8 +62,11 @@ function isClosed(err) {
|
|
|
69
62
|
}
|
|
70
63
|
// [function]
|
|
71
64
|
// Saves kept in IndexedDB: the play page's, which every game shares, and a
|
|
72
|
-
// game standing alone on its own origin. Where
|
|
73
|
-
//
|
|
65
|
+
// game standing alone on its own origin. Where there's no IndexedDB, or it
|
|
66
|
+
// refuses to open (a browser blocking site data, some `file://` pages),
|
|
67
|
+
// they're kept in memory, and `status()` says so. Once it has opened, it
|
|
68
|
+
// never switches: a lost connection fails calls with `unavailable` until it
|
|
69
|
+
// opens again, rather than show an empty copy.
|
|
74
70
|
export function indexedDBBackend(options = {}) {
|
|
75
71
|
const factory = options.factory === undefined
|
|
76
72
|
? (globalThis.indexedDB ?? null)
|
|
@@ -78,35 +74,64 @@ export function indexedDBBackend(options = {}) {
|
|
|
78
74
|
const player = options.player ?? GUEST;
|
|
79
75
|
const fallback = memoryBackend();
|
|
80
76
|
const listeners = listenersByKey();
|
|
81
|
-
|
|
77
|
+
// memory only when the first open fails; never once it has opened.
|
|
78
|
+
let status = "device";
|
|
79
|
+
let opened = false;
|
|
80
|
+
let current = null;
|
|
81
|
+
let opening = null;
|
|
82
82
|
let asked = false;
|
|
83
83
|
const keyOf = (scope) => `${player}\u0000${scope.game}\u0000${scope.channel}`;
|
|
84
84
|
const rangeOf = (scope) => IDBKeyRange.only([player, scope.game, scope.channel]);
|
|
85
|
+
const lost = function (db) {
|
|
86
|
+
if (current === db)
|
|
87
|
+
current = null;
|
|
88
|
+
};
|
|
85
89
|
const connect = async function () {
|
|
86
90
|
try {
|
|
87
91
|
if (!factory)
|
|
88
92
|
throw new Error("IndexedDB isn't available here.");
|
|
89
|
-
const db = await open(factory
|
|
93
|
+
const db = await open(factory);
|
|
90
94
|
// a newer version of this page wants to upgrade: step aside.
|
|
91
|
-
db.onversionchange = () =>
|
|
95
|
+
db.onversionchange = () => {
|
|
96
|
+
db.close();
|
|
97
|
+
lost(db);
|
|
98
|
+
};
|
|
99
|
+
// the browser closed it under us (Safari can).
|
|
100
|
+
db.onclose = () => lost(db);
|
|
92
101
|
if (options.persist && !asked) {
|
|
93
102
|
asked = true;
|
|
94
103
|
globalThis.navigator?.storage?.persist?.().catch(() => false);
|
|
95
104
|
}
|
|
105
|
+
opened = true;
|
|
106
|
+
current = db;
|
|
96
107
|
return db;
|
|
97
108
|
}
|
|
98
109
|
catch (err) {
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
if (opened) {
|
|
111
|
+
log("warn", "the saves' storage couldn't be opened again.", err);
|
|
112
|
+
throw new ArcadibleError(Codes.Unavailable, "Saves can't be reached right now. Try again.");
|
|
113
|
+
}
|
|
114
|
+
log("warn", "this browser won't keep saves here, so they're kept in memory until the page closes.", err);
|
|
115
|
+
status = "memory";
|
|
101
116
|
return null;
|
|
102
117
|
}
|
|
118
|
+
finally {
|
|
119
|
+
opening = null;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
// the database, opening it once however many ask; null in memory.
|
|
123
|
+
const database = function () {
|
|
124
|
+
if (status === "memory")
|
|
125
|
+
return Promise.resolve(null);
|
|
126
|
+
if (current)
|
|
127
|
+
return Promise.resolve(current);
|
|
128
|
+
opening ??= connect();
|
|
129
|
+
return opening;
|
|
103
130
|
};
|
|
104
|
-
let opening = connect();
|
|
105
131
|
// runs `op` on the database, opening it again once if it was closed, or
|
|
106
132
|
// on the memory fallback when there's none.
|
|
107
133
|
const run = async function (op, otherwise) {
|
|
108
|
-
const
|
|
109
|
-
const db = await current;
|
|
134
|
+
const db = await database();
|
|
110
135
|
if (!db)
|
|
111
136
|
return otherwise();
|
|
112
137
|
try {
|
|
@@ -115,13 +140,12 @@ export function indexedDBBackend(options = {}) {
|
|
|
115
140
|
catch (err) {
|
|
116
141
|
if (!isClosed(err))
|
|
117
142
|
throw err;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
opening = connect();
|
|
121
|
-
const again = await opening;
|
|
143
|
+
lost(db);
|
|
144
|
+
const again = await database();
|
|
122
145
|
return again ? op(again) : otherwise();
|
|
123
146
|
}
|
|
124
147
|
};
|
|
148
|
+
database().catch(() => undefined);
|
|
125
149
|
const broadcast = typeof BroadcastChannel === "undefined"
|
|
126
150
|
? null
|
|
127
151
|
: new BroadcastChannel(CHANNEL);
|
|
@@ -218,7 +242,8 @@ export function indexedDBBackend(options = {}) {
|
|
|
218
242
|
},
|
|
219
243
|
close() {
|
|
220
244
|
broadcast?.close();
|
|
221
|
-
|
|
245
|
+
current?.close();
|
|
246
|
+
opening?.then((db) => db?.close()).catch(() => undefined);
|
|
222
247
|
},
|
|
223
248
|
};
|
|
224
249
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// [import]
|
|
2
|
+
import { log } from "../../bus/protocol.js";
|
|
2
3
|
// [function]
|
|
3
4
|
// Each game's subscribers, by key, for a backend to tell of changes. Each
|
|
4
5
|
// gets its own copy, so one can't change what the next one sees, and one
|
|
@@ -25,7 +26,7 @@ export function listenersByKey() {
|
|
|
25
26
|
listener(structuredClone(change), source);
|
|
26
27
|
}
|
|
27
28
|
catch (err) {
|
|
28
|
-
|
|
29
|
+
log("error", "a change's listener failed.", err);
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { BackendStatus } from "../rules.js";
|
|
2
2
|
import type { Backend, Scope } from "./types.js";
|
|
3
3
|
export declare function memoryBackend({ status, }?: {
|
|
4
|
-
status?:
|
|
4
|
+
status?: BackendStatus;
|
|
5
5
|
}): Backend & {
|
|
6
6
|
used(scope: Scope): string | undefined;
|
|
7
7
|
};
|
|
@@ -7,8 +7,9 @@ function keyOf(scope) {
|
|
|
7
7
|
// [function]
|
|
8
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
|
-
// last as long as the page, so it says `
|
|
11
|
-
|
|
10
|
+
// last as long as the page, so it says `memory`; tests pretend it's a
|
|
11
|
+
// device's with `device`.
|
|
12
|
+
export function memoryBackend({ status = "memory", } = {}) {
|
|
12
13
|
const games = new Map();
|
|
13
14
|
const listeners = listenersByKey();
|
|
14
15
|
const gameOf = function (scope) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { BackendStatus } from "../rules.js";
|
|
2
2
|
export type Scope = {
|
|
3
3
|
game: string;
|
|
4
4
|
channel: string;
|
|
@@ -18,7 +18,7 @@ export type Change = {
|
|
|
18
18
|
revision: number;
|
|
19
19
|
};
|
|
20
20
|
export interface Backend {
|
|
21
|
-
status():
|
|
21
|
+
status(): BackendStatus;
|
|
22
22
|
list(scope: Scope): Promise<SaveRecord[]>;
|
|
23
23
|
get(scope: Scope, name: string): Promise<SaveRecord | null>;
|
|
24
24
|
change(scope: Scope, name: string, decide: (saves: SaveRecord[]) => NewRecord | null, source: string): Promise<Change | null>;
|
package/dist/saves/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { type SaveInfo } from "./rules.js";
|
|
1
|
+
import { type Bus } from "../bus/client.js";
|
|
2
|
+
import { type SaveInfo, type Where } from "./rules.js";
|
|
3
3
|
/** `arcadible.saves`: each save is a name holding one string. */
|
|
4
4
|
export interface Saves {
|
|
5
5
|
/** Keeps `data` as the save `name`, replacing what it held. */
|
|
@@ -14,6 +14,17 @@ export interface Saves {
|
|
|
14
14
|
remove(name: string): Promise<void>;
|
|
15
15
|
/** After `ready`, the save's string from memory; before it, throws `not_ready`. */
|
|
16
16
|
readSync(name: string): string | null;
|
|
17
|
+
/** After `ready`, the names of the game's saves from memory, sorted. */
|
|
18
|
+
listSync(): string[];
|
|
19
|
+
/** After `ready`, the save's version and time from memory, or `null`. */
|
|
20
|
+
infoSync(name: string): SaveInfo | null;
|
|
21
|
+
/**
|
|
22
|
+
* Who can read the saves back, once `ready` has settled: `"game"` (every
|
|
23
|
+
* version of the game), `"build"` (only this build), or `"memory"` (only
|
|
24
|
+
* until the page closes). `undefined` until it's known. Other values may
|
|
25
|
+
* be added: treat one you don't know as `"game"`.
|
|
26
|
+
*/
|
|
27
|
+
readonly where: Where | undefined;
|
|
17
28
|
}
|
|
18
29
|
export declare function createSaves(bus: Bus, { timeout }: {
|
|
19
30
|
timeout: number;
|
package/dist/saves/client.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Saves, the game's side: `arcadible.saves`, and its memory copy of the
|
|
2
2
|
// game's saves, so reads after `ready` answer at once.
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
3
|
+
// [import]
|
|
4
|
+
import { mayHaveLanded } from "../bus/client.js";
|
|
5
|
+
import { ArcadibleError, buildOf, Codes, isStringArray, isStringOrNull, log, utf8Size, } from "../bus/protocol.js";
|
|
6
|
+
import { checkName, checkWrite, isRevisionOrNull, isSaveInfoOrNull, isSaveMeta, isSavesChange, isSavesLoad, } from "./rules.js";
|
|
5
7
|
// [function]
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return err instanceof ArcadibleError && err.code === Codes.Timeout;
|
|
8
|
+
function isAnything(_value) {
|
|
9
|
+
return true;
|
|
9
10
|
}
|
|
10
11
|
// [function]
|
|
11
12
|
function infoOf(meta) {
|
|
@@ -20,6 +21,7 @@ export function createSaves(bus, { timeout }) {
|
|
|
20
21
|
let settled = false;
|
|
21
22
|
// whether the memory copy holds the game's saves.
|
|
22
23
|
let loaded = false;
|
|
24
|
+
let where;
|
|
23
25
|
let version = null;
|
|
24
26
|
const mirror = new Map();
|
|
25
27
|
// the newest revision seen of each save, removals included.
|
|
@@ -57,6 +59,14 @@ export function createSaves(bus, { timeout }) {
|
|
|
57
59
|
kept(name, revision, save);
|
|
58
60
|
};
|
|
59
61
|
// [function]
|
|
62
|
+
// the answer to a write or remove that timed out, once it comes: the
|
|
63
|
+
// page did keep it, so the memory copy takes it, unless a newer write of
|
|
64
|
+
// the save is waiting, whose answer will be newer still.
|
|
65
|
+
const landed = function (name, revision, save) {
|
|
66
|
+
if (!writing.has(name))
|
|
67
|
+
kept(name, revision, save);
|
|
68
|
+
};
|
|
69
|
+
// [function]
|
|
60
70
|
const restore = function (name, previous) {
|
|
61
71
|
if (previous)
|
|
62
72
|
mirror.set(name, previous);
|
|
@@ -77,138 +87,160 @@ export function createSaves(bus, { timeout }) {
|
|
|
77
87
|
}
|
|
78
88
|
};
|
|
79
89
|
// [function]
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
// runs `call` once `ready` has settled: at once when it has, so a write
|
|
91
|
+
// changes the memory copy in the same tick, and a `readSync` right after
|
|
92
|
+
// it sees the save.
|
|
93
|
+
const whenOpened = function (call) {
|
|
94
|
+
return settled ? call() : opened.then(call);
|
|
95
|
+
};
|
|
96
|
+
// [function]
|
|
97
|
+
const send = function (name, data) {
|
|
98
|
+
// before the saves have loaded, only the name and document can be
|
|
99
|
+
// checked here; the page checks the rest once they have.
|
|
100
|
+
const optimistic = loaded;
|
|
101
|
+
const refused = checkWrite(optimistic ? mirror : new Map(), name, data);
|
|
102
|
+
if (refused)
|
|
103
|
+
return Promise.reject(refused);
|
|
104
|
+
// the memory copy changes at once, so the next read sees it; the
|
|
105
|
+
// promise settles once the page has kept it.
|
|
106
|
+
const previous = mirror.get(name);
|
|
107
|
+
const ticket = ++writes;
|
|
108
|
+
writing.set(name, ticket);
|
|
109
|
+
if (optimistic) {
|
|
110
|
+
mirror.set(name, {
|
|
111
|
+
name,
|
|
112
|
+
data,
|
|
113
|
+
info: {
|
|
114
|
+
version,
|
|
115
|
+
savedAt: new Date().toISOString(),
|
|
116
|
+
size: utf8Size(data),
|
|
117
|
+
revision: previous?.info.revision ?? 0,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const save = (meta) => ({ name, data, info: meta });
|
|
122
|
+
return bus
|
|
123
|
+
.request("saves.write", { name, data }, isSaveMeta, {
|
|
124
|
+
late: (meta) => landed(name, meta.revision, save(meta)),
|
|
125
|
+
})
|
|
126
|
+
.then((meta) => {
|
|
127
|
+
if (writing.get(name) === ticket) {
|
|
128
|
+
writing.delete(name);
|
|
129
|
+
kept(name, meta.revision, save(meta));
|
|
130
|
+
}
|
|
131
|
+
return infoOf(meta);
|
|
132
|
+
}, (err) => {
|
|
133
|
+
if (writing.get(name) === ticket)
|
|
134
|
+
writing.delete(name);
|
|
135
|
+
// one the page refused is rolled back, unless something newer
|
|
136
|
+
// replaced it meanwhile; one it may have kept stays.
|
|
137
|
+
if (optimistic &&
|
|
138
|
+
!mayHaveLanded(err) &&
|
|
139
|
+
mirror.get(name)?.data === data) {
|
|
140
|
+
restore(name, previous);
|
|
141
|
+
}
|
|
142
|
+
throw err;
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
// [function]
|
|
146
|
+
const erase = function (name) {
|
|
147
|
+
const optimistic = loaded;
|
|
148
|
+
const previous = mirror.get(name);
|
|
149
|
+
if (optimistic)
|
|
150
|
+
mirror.delete(name);
|
|
151
|
+
return bus
|
|
152
|
+
.request("saves.remove", { name }, isRevisionOrNull, {
|
|
153
|
+
late: (revision) => {
|
|
154
|
+
if (revision !== null)
|
|
155
|
+
landed(name, revision, null);
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
.then((revision) => {
|
|
159
|
+
if (revision !== null)
|
|
160
|
+
kept(name, revision, null);
|
|
161
|
+
}, (err) => {
|
|
162
|
+
if (optimistic && !mayHaveLanded(err) && !mirror.has(name)) {
|
|
163
|
+
restore(name, previous);
|
|
164
|
+
}
|
|
165
|
+
throw err;
|
|
166
|
+
});
|
|
83
167
|
};
|
|
84
168
|
// [start]
|
|
85
169
|
let timer;
|
|
86
|
-
|
|
170
|
+
// the load is checked here, not by the bus, so even a malformed one
|
|
171
|
+
// settles `ready` at once: the saves didn't load, and the sync reads say so.
|
|
172
|
+
bus.on("saves.load", isAnything, (load) => {
|
|
87
173
|
if (loaded)
|
|
88
174
|
return;
|
|
89
175
|
clearTimeout(timer);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
mirror.set(save.name, save);
|
|
95
|
-
revisions.set(save.name, save.info.revision);
|
|
96
|
-
}
|
|
97
|
-
loaded = true;
|
|
98
|
-
for (const change of early.splice(0))
|
|
99
|
-
apply(change);
|
|
100
|
-
}
|
|
176
|
+
if (!isSavesLoad(load)) {
|
|
177
|
+
log("warn", "the play page sent saves the game couldn't read.");
|
|
178
|
+
open();
|
|
179
|
+
return;
|
|
101
180
|
}
|
|
102
|
-
|
|
103
|
-
// even a malformed load settles `ready`.
|
|
181
|
+
if (load.error) {
|
|
104
182
|
open();
|
|
183
|
+
return;
|
|
105
184
|
}
|
|
185
|
+
for (const save of load.items) {
|
|
186
|
+
mirror.set(save.name, save);
|
|
187
|
+
revisions.set(save.name, save.info.revision);
|
|
188
|
+
}
|
|
189
|
+
loaded = true;
|
|
190
|
+
where = typeof load.where === "string" ? load.where : undefined;
|
|
191
|
+
for (const change of early.splice(0))
|
|
192
|
+
apply(change);
|
|
193
|
+
open();
|
|
106
194
|
});
|
|
107
|
-
bus.on("saves.change", (
|
|
195
|
+
bus.on("saves.change", isSavesChange, (change) => {
|
|
108
196
|
// one that comes before the saves is kept until they do.
|
|
109
197
|
if (loaded)
|
|
110
|
-
apply(
|
|
198
|
+
apply(change);
|
|
111
199
|
else
|
|
112
|
-
early.push(
|
|
200
|
+
early.push(change);
|
|
113
201
|
});
|
|
114
202
|
bus.welcomed.then((welcome) => {
|
|
115
|
-
version = welcome.build?.version ?? null;
|
|
203
|
+
version = buildOf(welcome.build)?.version ?? null;
|
|
116
204
|
// the memory copy doesn't pretend to be empty while the saves are late:
|
|
117
205
|
// a game that took that for "no save yet" could start over, and
|
|
118
206
|
// overwrite the player's progress once they arrived.
|
|
119
207
|
if (!settled) {
|
|
120
208
|
timer = setTimeout(() => {
|
|
121
|
-
|
|
209
|
+
if (!loaded) {
|
|
210
|
+
log("warn", "the game's saves haven't arrived yet, so the sync reads throw unavailable until they do.");
|
|
211
|
+
}
|
|
122
212
|
open();
|
|
123
213
|
}, timeout);
|
|
124
214
|
}
|
|
125
215
|
});
|
|
126
216
|
const saves = {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// before the saves have loaded, only the name and document can be
|
|
130
|
-
// checked here; the page checks the rest once they have.
|
|
131
|
-
const optimistic = loaded;
|
|
132
|
-
const refused = checkWrite(optimistic ? mirror : new Map(), name, data);
|
|
133
|
-
if (refused)
|
|
134
|
-
throw refused;
|
|
135
|
-
// the memory copy changes at once, so the next read sees it; the
|
|
136
|
-
// promise settles once the page has kept it.
|
|
137
|
-
const previous = mirror.get(name);
|
|
138
|
-
const ticket = ++writes;
|
|
139
|
-
writing.set(name, ticket);
|
|
140
|
-
if (optimistic) {
|
|
141
|
-
mirror.set(name, {
|
|
142
|
-
name,
|
|
143
|
-
data,
|
|
144
|
-
info: {
|
|
145
|
-
version,
|
|
146
|
-
savedAt: new Date().toISOString(),
|
|
147
|
-
size: utf8Size(data),
|
|
148
|
-
revision: previous?.info.revision ?? 0,
|
|
149
|
-
},
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
try {
|
|
153
|
-
const meta = (await bus.request("saves.write", {
|
|
154
|
-
name,
|
|
155
|
-
data,
|
|
156
|
-
}));
|
|
157
|
-
if (writing.get(name) === ticket) {
|
|
158
|
-
writing.delete(name);
|
|
159
|
-
kept(name, meta.revision, { name, data, info: meta });
|
|
160
|
-
}
|
|
161
|
-
return infoOf(meta);
|
|
162
|
-
}
|
|
163
|
-
catch (err) {
|
|
164
|
-
if (writing.get(name) === ticket)
|
|
165
|
-
writing.delete(name);
|
|
166
|
-
// one the page refused is rolled back, unless something newer
|
|
167
|
-
// replaced it meanwhile.
|
|
168
|
-
if (optimistic &&
|
|
169
|
-
!mayHaveLanded(err) &&
|
|
170
|
-
mirror.get(name)?.data === data) {
|
|
171
|
-
restore(name, previous);
|
|
172
|
-
}
|
|
173
|
-
throw err;
|
|
174
|
-
}
|
|
217
|
+
write(name, data) {
|
|
218
|
+
return whenOpened(() => send(name, data));
|
|
175
219
|
},
|
|
176
220
|
async read(name) {
|
|
177
221
|
const refused = checkName(name);
|
|
178
222
|
if (refused)
|
|
179
223
|
throw refused;
|
|
180
|
-
|
|
224
|
+
await opened;
|
|
225
|
+
return bus.request("saves.read", { name }, isStringOrNull);
|
|
181
226
|
},
|
|
182
227
|
async list() {
|
|
183
|
-
|
|
228
|
+
await opened;
|
|
229
|
+
return bus.request("saves.list", undefined, isStringArray);
|
|
184
230
|
},
|
|
185
231
|
async info(name) {
|
|
186
232
|
const refused = checkName(name);
|
|
187
233
|
if (refused)
|
|
188
234
|
throw refused;
|
|
189
|
-
|
|
235
|
+
await opened;
|
|
236
|
+
const meta = await bus.request("saves.info", { name }, isSaveInfoOrNull);
|
|
190
237
|
return meta ? infoOf(meta) : null;
|
|
191
238
|
},
|
|
192
239
|
async remove(name) {
|
|
193
240
|
const refused = checkName(name);
|
|
194
241
|
if (refused)
|
|
195
242
|
throw refused;
|
|
196
|
-
|
|
197
|
-
const optimistic = loaded;
|
|
198
|
-
const previous = mirror.get(name);
|
|
199
|
-
if (optimistic)
|
|
200
|
-
mirror.delete(name);
|
|
201
|
-
try {
|
|
202
|
-
const revision = (await bus.request("saves.remove", { name }));
|
|
203
|
-
if (revision !== null)
|
|
204
|
-
kept(name, revision, null);
|
|
205
|
-
}
|
|
206
|
-
catch (err) {
|
|
207
|
-
if (optimistic && !mayHaveLanded(err) && !mirror.has(name)) {
|
|
208
|
-
restore(name, previous);
|
|
209
|
-
}
|
|
210
|
-
throw err;
|
|
211
|
-
}
|
|
243
|
+
return whenOpened(() => erase(name));
|
|
212
244
|
},
|
|
213
245
|
readSync(name) {
|
|
214
246
|
assertLoaded();
|
|
@@ -217,6 +249,21 @@ export function createSaves(bus, { timeout }) {
|
|
|
217
249
|
throw refused;
|
|
218
250
|
return mirror.get(name)?.data ?? null;
|
|
219
251
|
},
|
|
252
|
+
listSync() {
|
|
253
|
+
assertLoaded();
|
|
254
|
+
return [...mirror.keys()].sort();
|
|
255
|
+
},
|
|
256
|
+
infoSync(name) {
|
|
257
|
+
assertLoaded();
|
|
258
|
+
const refused = checkName(name);
|
|
259
|
+
if (refused)
|
|
260
|
+
throw refused;
|
|
261
|
+
const save = mirror.get(name);
|
|
262
|
+
return save ? infoOf(save.info) : null;
|
|
263
|
+
},
|
|
264
|
+
get where() {
|
|
265
|
+
return where;
|
|
266
|
+
},
|
|
220
267
|
};
|
|
221
268
|
return { saves, opened };
|
|
222
269
|
}
|
package/dist/saves/rules.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { ArcadibleError } from "../bus/protocol.js";
|
|
2
2
|
export declare const MAX_SAVES = 32;
|
|
3
3
|
export declare const MAX_TOTAL: number;
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Who can read the saves back: every version of the game (`"game"`), only
|
|
6
|
+
* this build (`"build"`), or only until the page closes (`"memory"`). Other
|
|
7
|
+
* values may be added, such as `"account"`: a game treats one it doesn't
|
|
8
|
+
* know as `"game"`.
|
|
9
|
+
*/
|
|
10
|
+
export type Where = "game" | "build" | "memory" | (string & {});
|
|
11
|
+
export type BackendStatus = "device" | "memory";
|
|
5
12
|
/** What a game is told of a save. */
|
|
6
13
|
export type SaveInfo = {
|
|
7
14
|
/**
|
|
@@ -22,7 +29,7 @@ export type Save = {
|
|
|
22
29
|
info: SaveMeta;
|
|
23
30
|
};
|
|
24
31
|
export type SavesLoad = {
|
|
25
|
-
|
|
32
|
+
where?: Where;
|
|
26
33
|
items: Save[];
|
|
27
34
|
error?: {
|
|
28
35
|
code: string;
|
|
@@ -38,3 +45,9 @@ export declare function checkName(name: unknown): ArcadibleError | null;
|
|
|
38
45
|
export declare function checkWrite(saves: ReadonlyMap<string, {
|
|
39
46
|
info: SaveMeta;
|
|
40
47
|
}>, name: unknown, data: unknown): ArcadibleError | null;
|
|
48
|
+
export declare function isSaveMeta(value: unknown): value is SaveMeta;
|
|
49
|
+
export declare function isSaveInfoOrNull(value: unknown): value is SaveInfo | null;
|
|
50
|
+
export declare function isSave(value: unknown): value is Save;
|
|
51
|
+
export declare function isSavesLoad(value: unknown): value is SavesLoad;
|
|
52
|
+
export declare function isSavesChange(value: unknown): value is SavesChange;
|
|
53
|
+
export declare function isRevisionOrNull(value: unknown): value is number | null;
|
package/dist/saves/rules.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Saves' rules, shared by the game's side and the page's so they can't drift
|
|
2
2
|
// apart: the shapes, the limits, and their checks (DESIGN.md, section 6).
|
|
3
3
|
// [import]
|
|
4
|
-
import { ArcadibleError, Codes, utf8Size } from "../bus/protocol.js";
|
|
4
|
+
import { ArcadibleError, Codes, isNumber, isObject, isStringOrNull, utf8Size, } from "../bus/protocol.js";
|
|
5
5
|
// [declare]
|
|
6
6
|
// Saves' limits, the same everywhere (DESIGN.md, section 6, Limits).
|
|
7
7
|
export const MAX_SAVES = 32;
|
|
@@ -47,3 +47,48 @@ export function checkWrite(saves, name, data) {
|
|
|
47
47
|
}
|
|
48
48
|
return null;
|
|
49
49
|
}
|
|
50
|
+
// [function]
|
|
51
|
+
// What the page sends of a save, checked for only what a game relies on
|
|
52
|
+
// (DESIGN.md, The host is never trusted): extra fields pass.
|
|
53
|
+
export function isSaveMeta(value) {
|
|
54
|
+
return (isObject(value) &&
|
|
55
|
+
isStringOrNull(value["version"]) &&
|
|
56
|
+
typeof value["savedAt"] === "string" &&
|
|
57
|
+
isNumber(value["size"]) &&
|
|
58
|
+
isNumber(value["revision"]));
|
|
59
|
+
}
|
|
60
|
+
// [function]
|
|
61
|
+
export function isSaveInfoOrNull(value) {
|
|
62
|
+
return (value === null ||
|
|
63
|
+
(isObject(value) &&
|
|
64
|
+
isStringOrNull(value["version"]) &&
|
|
65
|
+
typeof value["savedAt"] === "string"));
|
|
66
|
+
}
|
|
67
|
+
// [function]
|
|
68
|
+
export function isSave(value) {
|
|
69
|
+
return (isObject(value) &&
|
|
70
|
+
checkName(value["name"]) === null &&
|
|
71
|
+
typeof value["data"] === "string" &&
|
|
72
|
+
isSaveMeta(value["info"]));
|
|
73
|
+
}
|
|
74
|
+
// [function]
|
|
75
|
+
// A load the memory copy can take whole: every save readable, or none.
|
|
76
|
+
export function isSavesLoad(value) {
|
|
77
|
+
return (isObject(value) &&
|
|
78
|
+
Array.isArray(value["items"]) &&
|
|
79
|
+
(value["error"] !== undefined || value["items"].every(isSave)));
|
|
80
|
+
}
|
|
81
|
+
// [function]
|
|
82
|
+
export function isSavesChange(value) {
|
|
83
|
+
if (!isObject(value) ||
|
|
84
|
+
checkName(value["name"]) !== null ||
|
|
85
|
+
!isNumber(value["revision"])) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
const save = value["save"];
|
|
89
|
+
return save === null || (isSave(save) && save.name === value["name"]);
|
|
90
|
+
}
|
|
91
|
+
// [function]
|
|
92
|
+
export function isRevisionOrNull(value) {
|
|
93
|
+
return value === null || isNumber(value);
|
|
94
|
+
}
|
package/dist/saves/server.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Module, ModuleContext } from "../bus/server.js";
|
|
2
2
|
import type { Backend, Scope } from "./backend/types.js";
|
|
3
|
+
import { type Where } from "./rules.js";
|
|
3
4
|
type SavesServerOptions = {
|
|
4
5
|
scope: Scope;
|
|
5
6
|
version: string | null;
|
|
7
|
+
where: Where;
|
|
6
8
|
backend: Backend;
|
|
7
9
|
now: () => Date;
|
|
8
10
|
};
|
package/dist/saves/server.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Saves, the page's side: it answers a game's saves requests from its
|
|
2
2
|
// backend, enforces the limits, and stamps each save with the game's
|
|
3
|
-
// version. Its first message on each channel is the game's saves
|
|
3
|
+
// version. Its first message on each channel is the game's saves, and who
|
|
4
|
+
// can read them back.
|
|
4
5
|
// [import]
|
|
5
|
-
import { ArcadibleError, Codes, utf8Size } from "../bus/protocol.js";
|
|
6
|
+
import { ArcadibleError, Codes, log, utf8Size } from "../bus/protocol.js";
|
|
6
7
|
import { checkName, checkWrite, } from "./rules.js";
|
|
7
8
|
// [function]
|
|
8
9
|
function toSave(record) {
|
|
@@ -38,7 +39,7 @@ function storageError(err) {
|
|
|
38
39
|
}
|
|
39
40
|
// [function]
|
|
40
41
|
export function savesServer(options) {
|
|
41
|
-
const { backend, now, scope, version } = options;
|
|
42
|
+
const { backend, now, scope, version, where } = options;
|
|
42
43
|
const source = sourceId();
|
|
43
44
|
// a write, checked against the game's saves in the same step as it's kept.
|
|
44
45
|
const write = async function (params) {
|
|
@@ -67,10 +68,13 @@ export function savesServer(options) {
|
|
|
67
68
|
const [list] = await Promise.all([
|
|
68
69
|
backend.list(scope),
|
|
69
70
|
backend.touch(scope, now().toISOString()).catch((err) => {
|
|
70
|
-
|
|
71
|
+
log("warn", "the saves' last use couldn't be kept.", err);
|
|
71
72
|
}),
|
|
72
73
|
]);
|
|
73
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
where: backend.status() === "memory" ? "memory" : where,
|
|
76
|
+
items: list.map(toSave),
|
|
77
|
+
};
|
|
74
78
|
};
|
|
75
79
|
return (context) => {
|
|
76
80
|
// another tab (later, another device) changed a save: tell the game,
|
|
@@ -103,7 +107,13 @@ export function savesServer(options) {
|
|
|
103
107
|
"saves.remove": {
|
|
104
108
|
key: saveKey,
|
|
105
109
|
run: async (params) => {
|
|
106
|
-
|
|
110
|
+
// a removal writes too (the game's change counter), so a full
|
|
111
|
+
// device refuses it the same way.
|
|
112
|
+
const change = await backend
|
|
113
|
+
.change(scope, nameOf(params), () => null, source)
|
|
114
|
+
.catch((err) => {
|
|
115
|
+
throw storageError(err);
|
|
116
|
+
});
|
|
107
117
|
return change?.revision ?? null;
|
|
108
118
|
},
|
|
109
119
|
},
|
|
@@ -113,15 +123,11 @@ export function savesServer(options) {
|
|
|
113
123
|
send("saves.load", await load());
|
|
114
124
|
}
|
|
115
125
|
catch (err) {
|
|
116
|
-
|
|
126
|
+
log("warn", "the game's saves couldn't load.", err);
|
|
117
127
|
const error = err instanceof ArcadibleError
|
|
118
128
|
? err.toJSON()
|
|
119
129
|
: { code: Codes.Unavailable, message: "Saves couldn't load." };
|
|
120
|
-
send("saves.load", {
|
|
121
|
-
status: "ephemeral",
|
|
122
|
-
items: [],
|
|
123
|
-
error,
|
|
124
|
-
});
|
|
130
|
+
send("saves.load", { items: [], error });
|
|
125
131
|
}
|
|
126
132
|
},
|
|
127
133
|
close: unsubscribe,
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "1.
|
|
1
|
+
export declare const VERSION = "1.2.0";
|
package/dist/version.js
CHANGED
package/package.json
CHANGED