@arcadible/sdk 1.1.0 → 1.1.1

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