@arcadible/sdk 1.1.1 → 1.2.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 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` answers from memory. Every call that fails rejects (or, for `readSync`, throws) 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`, `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
 
@@ -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?: object): Promise<unknown>;
14
- on(name: string, listener: (data: unknown) => void): () => void;
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;
@@ -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
- handle(message);
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
- if ("error" in message) {
37
- const { code, message: text } = message.error;
38
- call.reject(new ArcadibleError(code, text));
95
+ try {
96
+ settle(call, message);
39
97
  }
40
- else {
41
- call.resolve(message.result);
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
- reject(new ArcadibleError(Codes.Timeout, `${name} timed out.`));
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, { resolve, reject, timer });
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(listener);
219
+ set.add(checked);
128
220
  return () => {
129
- set.delete(listener);
221
+ set.delete(checked);
130
222
  };
131
223
  },
132
224
  };
@@ -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;
@@ -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
- // One message, checked by the side that receives it: room for a whole 1 MB
30
- // save, and the rest of the message.
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;
@@ -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. `answerHellos` connects it to the play page's iframe.
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
- console.error("arcadible: a request failed:", err);
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
- port.postMessage({ kind: "event", name, data });
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, ...outcome });
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
- /** Settles once the game's saves have loaded, and never fails. */
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` standing alone, off
17
- * Arcadible, and `undefined` before.
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 stamp: saves say `null`.
13
- const STANDALONE = { game: "standalone", channel: "local", version: null };
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 ?? null };
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
@@ -15,6 +15,7 @@ export function serve(options) {
15
15
  now: options.now ?? (() => new Date()),
16
16
  scope: { game, channel },
17
17
  version,
18
+ where: options.where ?? "game",
18
19
  }),
19
20
  ],
20
21
  });
@@ -3,7 +3,7 @@ export type IndexedDBOptions = {
3
3
  factory?: IDBFactory | null;
4
4
  player?: string;
5
5
  persist?: boolean;
6
- openTimeout?: number;
6
+ reopenWait?: number;
7
7
  };
8
8
  export declare function indexedDBBackend(options?: IndexedDBOptions): Backend & {
9
9
  close(): void;
@@ -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,9 @@ 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;
16
+ // after a failed reopen, how long calls fail at once before it's tried again,
17
+ // so a game saving every frame doesn't try, and fail, every frame.
18
+ const REOPEN_WAIT = 1000;
18
19
  // [function]
19
20
  function settle(request) {
20
21
  return new Promise((resolve, reject) => {
@@ -31,9 +32,10 @@ function finish(tx) {
31
32
  });
32
33
  }
33
34
  // [function]
34
- function open(factory, timeout) {
35
+ // Opens the database, however long it takes: a slow open is waited for,
36
+ // since the saves are late, not gone (the game's calls wait, or time out).
37
+ function open(factory) {
35
38
  return new Promise((resolve, reject) => {
36
- const timer = setTimeout(() => reject(new Error("Opening IndexedDB timed out.")), timeout);
37
39
  const request = factory.open(DB_NAME, DB_VERSION);
38
40
  request.onupgradeneeded = () => {
39
41
  const db = request.result;
@@ -45,14 +47,8 @@ function open(factory, timeout) {
45
47
  saves.createIndex(BY_SCOPE, ["player", "game", "channel"]);
46
48
  db.createObjectStore(GAMES, { keyPath: ["player", "game", "channel"] });
47
49
  };
48
- request.onsuccess = () => {
49
- clearTimeout(timer);
50
- resolve(request.result);
51
- };
52
- request.onerror = () => {
53
- clearTimeout(timer);
54
- reject(request.error);
55
- };
50
+ request.onsuccess = () => resolve(request.result);
51
+ request.onerror = () => reject(request.error);
56
52
  });
57
53
  }
58
54
  // [function]
@@ -69,8 +65,11 @@ function isClosed(err) {
69
65
  }
70
66
  // [function]
71
67
  // Saves kept in IndexedDB: the play page's, which every game shares, and a
72
- // game standing alone on its own origin. Where IndexedDB won't open, such as
73
- // some `file://` pages, they're kept in memory, and `status()` says so.
68
+ // game standing alone on its own origin. Where there's no IndexedDB, or it
69
+ // refuses to open (a browser blocking site data, some `file://` pages),
70
+ // they're kept in memory, and `status()` says so. Once it has opened, it
71
+ // never switches: a lost connection fails calls with `unavailable` until it
72
+ // opens again, trying at most once a second, rather than show an empty copy.
74
73
  export function indexedDBBackend(options = {}) {
75
74
  const factory = options.factory === undefined
76
75
  ? (globalThis.indexedDB ?? null)
@@ -78,35 +77,77 @@ export function indexedDBBackend(options = {}) {
78
77
  const player = options.player ?? GUEST;
79
78
  const fallback = memoryBackend();
80
79
  const listeners = listenersByKey();
81
- let status = "local";
80
+ // memory only when the first open fails; never once it has opened.
81
+ let status = "device";
82
+ let opened = false;
83
+ let current = null;
84
+ let opening = null;
85
+ // whether reopening has failed since it last worked, and when to try again.
86
+ let down = false;
87
+ let retryAt = 0;
82
88
  let asked = false;
83
89
  const keyOf = (scope) => `${player}\u0000${scope.game}\u0000${scope.channel}`;
84
90
  const rangeOf = (scope) => IDBKeyRange.only([player, scope.game, scope.channel]);
91
+ const lost = function (db) {
92
+ if (current === db)
93
+ current = null;
94
+ };
95
+ const unreachable = () => new ArcadibleError(Codes.Unavailable, "Saves can't be reached right now. Try again.");
85
96
  const connect = async function () {
86
97
  try {
87
98
  if (!factory)
88
99
  throw new Error("IndexedDB isn't available here.");
89
- const db = await open(factory, options.openTimeout ?? OPEN_TIMEOUT);
100
+ const db = await open(factory);
90
101
  // a newer version of this page wants to upgrade: step aside.
91
- db.onversionchange = () => db.close();
102
+ db.onversionchange = () => {
103
+ db.close();
104
+ lost(db);
105
+ };
106
+ // the browser closed it under us (Safari can).
107
+ db.onclose = () => lost(db);
92
108
  if (options.persist && !asked) {
93
109
  asked = true;
94
110
  globalThis.navigator?.storage?.persist?.().catch(() => false);
95
111
  }
112
+ opened = true;
113
+ down = false;
114
+ current = db;
96
115
  return db;
97
116
  }
98
117
  catch (err) {
99
- console.warn("arcadible: saves are kept in memory only:", err);
100
- status = "ephemeral";
118
+ if (opened) {
119
+ // once until it works again: the calls failing meanwhile say so.
120
+ if (!down) {
121
+ log("warn", "the saves' storage couldn't be opened again.", err);
122
+ }
123
+ down = true;
124
+ retryAt = Date.now() + (options.reopenWait ?? REOPEN_WAIT);
125
+ throw unreachable();
126
+ }
127
+ log("warn", "this browser won't keep saves here, so they're kept in memory until the page closes.", err);
128
+ status = "memory";
101
129
  return null;
102
130
  }
131
+ finally {
132
+ opening = null;
133
+ }
134
+ };
135
+ // the database, opening it once however many ask; null in memory.
136
+ const database = function () {
137
+ if (status === "memory")
138
+ return Promise.resolve(null);
139
+ if (current)
140
+ return Promise.resolve(current);
141
+ if (down && !opening && Date.now() < retryAt) {
142
+ return Promise.reject(unreachable());
143
+ }
144
+ opening ??= connect();
145
+ return opening;
103
146
  };
104
- let opening = connect();
105
147
  // runs `op` on the database, opening it again once if it was closed, or
106
148
  // on the memory fallback when there's none.
107
149
  const run = async function (op, otherwise) {
108
- const current = opening;
109
- const db = await current;
150
+ const db = await database();
110
151
  if (!db)
111
152
  return otherwise();
112
153
  try {
@@ -115,13 +156,12 @@ export function indexedDBBackend(options = {}) {
115
156
  catch (err) {
116
157
  if (!isClosed(err))
117
158
  throw err;
118
- // once, however many operations found it closed.
119
- if (opening === current)
120
- opening = connect();
121
- const again = await opening;
159
+ lost(db);
160
+ const again = await database();
122
161
  return again ? op(again) : otherwise();
123
162
  }
124
163
  };
164
+ database().catch(() => undefined);
125
165
  const broadcast = typeof BroadcastChannel === "undefined"
126
166
  ? null
127
167
  : new BroadcastChannel(CHANNEL);
@@ -218,7 +258,8 @@ export function indexedDBBackend(options = {}) {
218
258
  },
219
259
  close() {
220
260
  broadcast?.close();
221
- opening.then((db) => db?.close()).catch(() => undefined);
261
+ current?.close();
262
+ opening?.then((db) => db?.close()).catch(() => undefined);
222
263
  },
223
264
  };
224
265
  }
@@ -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
- console.error("arcadible: a change's listener failed:", err);
29
+ log("error", "a change's listener failed.", err);
29
30
  }
30
31
  }
31
32
  },
@@ -1,7 +1,7 @@
1
- import type { SaveStatus } from "../rules.js";
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?: SaveStatus;
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 `ephemeral`.
11
- export function memoryBackend({ status = "ephemeral", } = {}) {
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 { SaveStatus } from "../rules.js";
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(): SaveStatus;
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>;
@@ -1,5 +1,5 @@
1
- import type { Bus } from "../bus/client.js";
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;
@@ -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 { ArcadibleError, Codes, utf8Size } from "../bus/protocol.js";
4
- import { checkName, checkWrite, } from "./rules.js";
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
- // a call that failed this way may still have been kept by the page.
7
- function mayHaveLanded(err) {
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
- const request = async function (name, params) {
81
- await opened;
82
- return bus.request(name, params);
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
- bus.on("saves.load", (data) => {
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
- const load = data;
91
- try {
92
- if (!load?.error && Array.isArray(load?.items)) {
93
- for (const save of load.items) {
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
- finally {
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", (data) => {
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(data);
198
+ apply(change);
111
199
  else
112
- early.push(data);
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
- console.warn("arcadible: the game's saves haven't arrived yet.");
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
- async write(name, data) {
128
- await opened;
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
- return (await request("saves.read", { name }));
224
+ await opened;
225
+ return bus.request("saves.read", { name }, isStringOrNull);
181
226
  },
182
227
  async list() {
183
- return (await request("saves.list"));
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
- const meta = (await request("saves.info", { name }));
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
- await opened;
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
  }
@@ -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
- export type SaveStatus = "local" | "ephemeral";
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
- status: SaveStatus;
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;
@@ -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
+ }
@@ -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
  };
@@ -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
- console.warn("arcadible: the saves' last use couldn't be kept:", err);
71
+ log("warn", "the saves' last use couldn't be kept.", err);
71
72
  }),
72
73
  ]);
73
- return { status: backend.status(), items: list.map(toSave) };
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
- const change = await backend.change(scope, nameOf(params), () => null, source);
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
- console.error("arcadible: the game's saves couldn't load:", err);
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.1";
1
+ export declare const VERSION = "1.2.1";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // The client's version, sent in its hello and shown as `arcadible.version`,
2
2
  // for bug reports. It matches package.json (test/unit/protocol.test.ts).
3
- export const VERSION = "1.1.1";
3
+ export const VERSION = "1.2.1";
package/package.json CHANGED
@@ -43,5 +43,5 @@
43
43
  ],
44
44
  "type": "module",
45
45
  "types": "./dist/index.d.ts",
46
- "version": "1.1.1"
46
+ "version": "1.2.1"
47
47
  }