@arcadible/sdk 1.1.0 → 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 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>
@@ -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
  };
@@ -16,6 +16,12 @@ 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, or when
23
+ * the page doesn't say.
24
+ */
19
25
  export type Build = {
20
26
  version: string;
21
27
  channel: string;
@@ -30,6 +36,7 @@ export type Welcome = {
30
36
  v: number;
31
37
  build: Build | null;
32
38
  };
39
+ export type Shape<T> = (value: unknown) => value is T;
33
40
  export type ErrorBody = {
34
41
  code: string;
35
42
  message: string;
@@ -55,13 +62,24 @@ type BusEvent = {
55
62
  data?: unknown;
56
63
  };
57
64
  export type Message = Request | Response | BusEvent;
65
+ /** A failed call. Match on `code`; the message is for people. */
58
66
  export declare class ArcadibleError extends Error {
59
67
  readonly code: Code;
60
68
  constructor(code: Code, message: string);
61
69
  toJSON(): ErrorBody;
62
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;
63
80
  export declare function isHello(value: unknown): value is Hello;
64
81
  export declare function isWelcome(value: unknown): value is Welcome;
82
+ export declare function buildOf(value: unknown): Build | null;
65
83
  export declare function isMessage(value: unknown): value is Message;
66
84
  export declare function utf8Size(text: string): number;
67
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
@@ -46,7 +48,7 @@ export const Codes = {
46
48
  Unsupported: "unsupported",
47
49
  };
48
50
  // [class]
49
- // A failed call. Match on `code`; the message is for people.
51
+ /** A failed call. Match on `code`; the message is for people. */
50
52
  export class ArcadibleError extends Error {
51
53
  // [field]
52
54
  code;
@@ -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;
@@ -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;
@@ -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.",
@@ -23,7 +24,7 @@ function paramsOf(value) {
23
24
  export function createServer(options) {
24
25
  const ports = new Set();
25
26
  const inFlight = new Map();
26
- // requests waiting their turn, by name and coalescing key.
27
+ // the newest request waiting its turn, by key.
27
28
  const waiting = new Map();
28
29
  let queue = Promise.resolve();
29
30
  const enqueue = function (job) {
@@ -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) {
@@ -50,10 +62,23 @@ export function createServer(options) {
50
62
  const refuse = function (port, id, error) {
51
63
  port.postMessage({ kind: "response", id, error });
52
64
  };
53
- const respond = function (port, id, outcome) {
54
- inFlight.set(port, (inFlight.get(port) ?? 1) - 1);
55
- if (ports.has(port))
56
- port.postMessage({ kind: "response", id, ...outcome });
65
+ // answers each request in `entry`, which counted once toward the limit.
66
+ const respond = function ({ ids, name, port }, outcome) {
67
+ const count = inFlight.get(port);
68
+ if (count !== undefined)
69
+ inFlight.set(port, count - 1);
70
+ if (!ports.has(port))
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;
80
+ for (const id of ids)
81
+ port.postMessage({ kind: "response", id, ...fitting });
57
82
  };
58
83
  const answer = function (port, request) {
59
84
  const { id, name } = request;
@@ -65,35 +90,33 @@ export function createServer(options) {
65
90
  });
66
91
  return;
67
92
  }
93
+ const params = paramsOf(request.params);
94
+ const key = handler.key?.(params) ?? null;
95
+ const pending = key === null ? undefined : waiting.get(key);
96
+ if (handler.coalesce &&
97
+ pending &&
98
+ pending.name === name &&
99
+ pending.port === port) {
100
+ pending.params = params;
101
+ pending.ids.push(id);
102
+ return;
103
+ }
68
104
  if ((inFlight.get(port) ?? 0) >= MAX_IN_FLIGHT) {
69
105
  refuse(port, id, {
70
106
  code: Codes.Busy,
71
- message: "Too many requests at once. Wait for some to finish.",
107
+ message: "Too many calls at once. Wait for some to finish.",
72
108
  });
73
109
  return;
74
110
  }
75
111
  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] };
112
+ const entry = { name, port, params, ids: [id] };
87
113
  if (key !== null)
88
114
  waiting.set(key, entry);
89
115
  enqueue(() => {
90
- if (key !== null)
116
+ if (key !== null && waiting.get(key) === entry)
91
117
  waiting.delete(key);
92
118
  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
- });
119
+ }).then((result) => respond(entry, { result }), (err) => respond(entry, { error: errorBody(err) }));
97
120
  };
98
121
  const receive = function (port, data) {
99
122
  if (!isMessage(data) || data.kind !== "request")
package/dist/client.d.ts CHANGED
@@ -4,10 +4,24 @@ 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
+ /**
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
+ */
9
16
  readonly ready: Promise<Ready>;
17
+ /**
18
+ * The build that's running, its version and channel, once `ready` has
19
+ * settled: compare a save's `info().version` with it, so as not to
20
+ * overwrite a save from a newer version. `null` when nobody says, such as
21
+ * standing alone, and `undefined` before.
22
+ */
10
23
  readonly build: Build | null | undefined;
24
+ /** Saves that carry over between the game's versions. */
11
25
  readonly saves: Saves;
12
26
  }
13
27
  export type ClientOptions = {
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,6 @@ export type IndexedDBOptions = {
3
3
  factory?: IDBFactory | null;
4
4
  player?: string;
5
5
  persist?: boolean;
6
- openTimeout?: number;
7
6
  };
8
7
  export declare function indexedDBBackend(options?: IndexedDBOptions): Backend & {
9
8
  close(): void;