dewdrop-sdk 0.3.2 → 0.4.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
@@ -1,7 +1,7 @@
1
1
  # dewdrop-sdk
2
2
 
3
3
  The package that builds a three.js game for Dewdrop and runs it locally: the
4
- Vite plugin, model calls and multiplayer.
4
+ Vite plugin, model calls, multiplayer and saves.
5
5
 
6
6
  ## Set up
7
7
 
@@ -19,11 +19,49 @@ export default { plugins: [dew()] };
19
19
  ```
20
20
 
21
21
  `npx dewctl init` sets up a new game this way, with the Dewdrop CLI beside it.
22
+ To update a project, run `npm install -D dewdrop-sdk@latest`; saves need 0.4.0
23
+ or later.
24
+
25
+ ## Saves locally
26
+
27
+ On Dewdrop, `localStorage` and IndexedDB aren't local: nothing your game
28
+ writes to them is stored in the player's browser or on their device, and they
29
+ start empty each time a player opens your game. `saves.save()` keeps what they
30
+ hold in Dewdrop's storage for that player, and `saves.load()` puts it back,
31
+ in later uploads of your game too. A game with a `backend.js` keeps no saves:
32
+ its state is its rooms', and both calls refuse with `NotSupportedError`.
33
+ `npm run dev` does the same in your browser: every page load starts with
34
+ `localStorage` and IndexedDB empty, and saves are kept in the browser, in a
35
+ database named `dewdrop-saves` your game doesn't see, for the game
36
+ `dewdrop.json` names and the player `?player=` names, across your edits. Tabs
37
+ of one dev server share the browser's storage, so loading one empties the
38
+ others'.
39
+
40
+ ## Rooms locally
41
+
42
+ `npm run dev` runs your `backend.js` for each room the tabs join: `?room=`
43
+ names the room, `?player=` the player, and `?players=` how many players a
44
+ room the tab opens seats, 100 unless it says 1 to 100, as Dewdrop's rooms
45
+ do; `?players=1` is singleplayer. A player is counted once however many tabs
46
+ they play from, and a tab past a full room is refused, with the reason in
47
+ the console. In the backend, `clients.capacity` is the room's size.
48
+
49
+ ## Sessions that end
50
+
51
+ On Dewdrop a session can end ahead of time: for maintenance, or a newer
52
+ version of your game published. Its player sees a countdown and reconnects
53
+ on their own, and your game hears of it first, up to fifteen minutes
54
+ before: `session.ending` from `dewdrop-sdk` is `{at, reason}`, `at` in
55
+ milliseconds as `Date.now()` counts and `reason` `'restart'` or `'update'`,
56
+ and an `ending` event fires each time it changes, its withdrawal to `null`
57
+ included. Call `saves.save()` then to keep what the player has done. `npm
58
+ run dev` ends no session ahead of time, so `session.ending` stays `null`
59
+ there.
22
60
 
23
61
  ## Docs
24
62
 
25
- How to build your game, from the look to model calls, multiplayer and what
26
- works on Dewdrop, is in the docs: https://alpha.dewdrop.world/docs
63
+ How to build your game, from the look to model calls, multiplayer, saves and
64
+ what works on Dewdrop, is in the docs: https://alpha.dewdrop.world/docs
27
65
 
28
66
  ## License
29
67
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dewdrop-sdk",
3
- "version": "0.3.2",
4
- "description": "Build a three.js game for Dewdrop: the Vite plugin, model calls and multiplayer.",
3
+ "version": "0.4.1",
4
+ "description": "Build a three.js game for Dewdrop: the Vite plugin, model calls, multiplayer and saves.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
@@ -11,7 +11,7 @@ import { pathToFileURL } from 'node:url';
11
11
  import { attachClients } from './clients.js';
12
12
 
13
13
  process.env.DEW_DEV_ORIGIN = workerData.origin;
14
- const start = attachClients(parentPort);
14
+ const start = attachClients(parentPort, { capacity: workerData.capacity });
15
15
  await import(pathToFileURL(workerData.entry).href);
16
16
  process.on('uncaughtException', (error) => console.error(`uncaught ${error?.stack ?? error}`));
17
17
  start();
package/src/backend.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // past its cap. The page's URL says which room the tab joins and under
8
8
  // what name; on Dewdrop the game's page says which room, and the name is
9
9
  // the player's username.
10
- import { MAX_NET_CLIENT_FRAME_BYTES, createInbox, createOutbox, defineHandlers, label, side, unpack } from './net.js';
10
+ import { MAX_NET_CLIENTS, MAX_NET_CLIENT_FRAME_BYTES, createInbox, createOutbox, defineHandlers, label, side, unpack } from './net.js';
11
11
  import { NO_CHANNEL } from './route.js';
12
12
 
13
13
  let channel = null;
@@ -58,20 +58,35 @@ function name(text) {
58
58
  return label(text.replace(/\p{Cc}/gu, '').trim());
59
59
  }
60
60
 
61
- // Where a page's URL joins its tab: the room `?room=` names and the name
62
- // `?player=` names, each held to Dewdrop's rule — the empty room without
61
+ // The players a room seats as `?players=` names them: a whole number from
62
+ // 1 to MAX_NET_CLIENTS, or the most without one or with any other.
63
+ function players(text) {
64
+ return /^[0-9]{1,3}$/.test(text) && Number(text) >= 1 && Number(text) <= MAX_NET_CLIENTS ? Number(text) : MAX_NET_CLIENTS;
65
+ }
66
+
67
+ // Where a page's URL joins its tab: the room `?room=` names, the name
68
+ // `?player=` names, and the players `?players=` says the room seats should
69
+ // the tab make it, each held to Dewdrop's rule — the empty room without
63
70
  // one, or with one that names no room; unnamed without a name, or with one
64
71
  // that is nothing but space and control characters.
65
72
  export function joining(search) {
66
73
  const params = new URLSearchParams(search);
67
- return { room: room(params.get('room') ?? ''), player: name(params.get('player') ?? '') };
74
+ return { room: room(params.get('room') ?? ''), player: name(params.get('player') ?? ''), players: players(params.get('players') ?? '') };
75
+ }
76
+
77
+ // Whether the page's game has a backend, as the plugin said; false until
78
+ // it has.
79
+ export function gameHasBackend() {
80
+ return hasBackend;
68
81
  }
69
82
 
70
83
  // Joins the backend of `room` under `player`'s name over `hot`, the page's
71
- // hot context, once the plugin has said whether the game has one:
72
- // `present` is that, `schedule` runs a function at the next animation
73
- // frame. Without a plugin there is no channel, and `send` says so.
74
- export function attachBackend(hot, { channel: routed, hasBackend: present, frame: schedule, room = '', player = '' }) {
84
+ // hot context, once the plugin has said whether the game has one, a room
85
+ // it makes seating `players`: `present` is that, `schedule` runs a
86
+ // function at the next animation frame. Without a plugin there is no
87
+ // channel, and `send` says so. A room that seats no more says so in the
88
+ // console, as Dewdrop refuses the connect, and the tab's sends reach no one.
89
+ export function attachBackend(hot, { channel: routed, hasBackend: present, frame: schedule, room = '', player = '', players = MAX_NET_CLIENTS }) {
75
90
  if (!routed) return;
76
91
  channel = hot;
77
92
  hasBackend = present;
@@ -83,5 +98,6 @@ export function attachBackend(hot, { channel: routed, hasBackend: present, frame
83
98
  draining = true;
84
99
  frame(deliver);
85
100
  });
86
- hot.send('dew:join', { room, player });
101
+ hot.on('dew:full', ({ capacity }) => console.error(`[dewdrop] this room is full: it seats ${capacity === 1 ? '1 player' : `${capacity} players`}`));
102
+ hot.send('dew:join', { room, player, players });
87
103
  }
package/src/clients.js CHANGED
@@ -1,15 +1,16 @@
1
1
  // `clients` on a developer's machine: the backend's side of the messages a
2
- // game exchanges with its clients, run by the plugin in a thread of the
3
- // dev server with a port to the plugin, which carries each message over
4
- // the socket to the tab it names. The thread ticks sixty times a second:
2
+ // game exchanges with its clients, and the most players its room seats,
3
+ // run by the plugin in a thread of the dev server with a port to the
4
+ // plugin, which carries each message over the socket to the tab it names. The thread ticks sixty times a second:
5
5
  // each tick delivers what the clients sent as `join`, `leave`, `message`,
6
6
  // and `gap` events — a join naming the player's label — runs the
7
7
  // animation-frame callbacks, and sends the frame's messages.
8
- import { MAX_NET_BACKEND_FRAME_BYTES, NET_TICK_MS, ClientEvent, createInbox, createOutbox, defineHandlers, side, unpack } from './net.js';
8
+ import { MAX_NET_BACKEND_FRAME_BYTES, MAX_NET_CLIENTS, NET_TICK_MS, ClientEvent, createInbox, createOutbox, defineHandlers, side, unpack } from './net.js';
9
9
 
10
10
  const outbox = createOutbox(MAX_NET_BACKEND_FRAME_BYTES);
11
11
  const inbox = createInbox();
12
12
  let port = null;
13
+ let seats = 0;
13
14
 
14
15
  // A client number as the backend was told it: a whole number from one.
15
16
  function number(op, value) {
@@ -28,6 +29,12 @@ class Clients extends EventTarget {
28
29
  if (port === null) throw new DOMException('clients is unavailable in a client', 'NotSupportedError');
29
30
  outbox.push('clients.broadcast', 0, data);
30
31
  }
32
+ // The most players the room seats, as it was made: 1 for a room of one,
33
+ // a player counted once however many clients they play from.
34
+ get capacity() {
35
+ if (port === null) throw new DOMException('clients is unavailable in a client', 'NotSupportedError');
36
+ return seats;
37
+ }
31
38
  }
32
39
  defineHandlers(Clients.prototype, ['join', 'leave', 'message', 'gap']);
33
40
 
@@ -76,9 +83,11 @@ function frame() {
76
83
  // held until the returned function starts the ticks, which the host calls
77
84
  // once the backend has evaluated, so the first tick delivers what arrived
78
85
  // meanwhile, the first join included, to the handlers the module
79
- // registered. `tick` runs the frame once per tick from then on.
80
- export function attachClients(channel, { tick = (fn) => setInterval(fn, NET_TICK_MS) } = {}) {
86
+ // registered. `tick` runs the frame once per tick from then on, and
87
+ // `capacity` is the most players the room seats.
88
+ export function attachClients(channel, { tick = (fn) => setInterval(fn, NET_TICK_MS), capacity = MAX_NET_CLIENTS } = {}) {
81
89
  port = channel;
90
+ seats = capacity;
82
91
  side.backend = true;
83
92
  if (typeof globalThis.requestAnimationFrame !== 'function') {
84
93
  globalThis.requestAnimationFrame = requestAnimationFrame;
package/src/fresh.js ADDED
@@ -0,0 +1,103 @@
1
+ // A page the dev server serves starts with its storage empty, as a game
2
+ // does on Dewdrop. The plugin puts `freshScript` in the page's head, where
3
+ // it runs before any of the game's code: it empties `localStorage` there
4
+ // and then, and deletes every IndexedDB database but the one the dev
5
+ // server keeps saves in. Listing the databases takes a moment, so until
6
+ // their deletes are asked for, the first open of each name asks for the
7
+ // name's delete first, and the browser, which serves a name's opens and
8
+ // deletes in the order they are asked for, hands the game an empty
9
+ // database all the same. The script is `freshStorage`'s own text, so the
10
+ // function imports nothing and is written without a `<`: the script
11
+ // escapes every `<` it carries, since a `</script>` would end it early.
12
+
13
+ // The database the dev server keeps saves in, out of the game's sight, and
14
+ // the name of the global symbol the page's script leaves what `saves`
15
+ // needs under.
16
+ export const SAVES_DATABASE = 'dewdrop-saves';
17
+ export const SAVES_STATE = 'dewdrop.saves';
18
+
19
+ // Empties the storage of `scope`, the page's window, but `database`, and
20
+ // leaves under `Symbol.for(state)`: `game`, `database`, `ready`, which
21
+ // resolves once every other database is gone and rejects when one cannot
22
+ // go, and `connected`, whether the page holds a database open or is
23
+ // opening one.
24
+ export function freshStorage(scope, { game, database, state }) {
25
+ scope.localStorage.clear();
26
+ const factory = scope.indexedDB;
27
+ const open = factory.open;
28
+ const list = factory.databases;
29
+ // The page's connections and the opens not yet answered.
30
+ const held = new Set();
31
+ // The names that are empty, or will be once the opens asked for so far
32
+ // are answered: the saves' own, which is never emptied, and each name
33
+ // whose delete has been asked for.
34
+ const cleared = new Set([database]);
35
+ const deletes = [];
36
+ let listed = false;
37
+ let refuse;
38
+ const refused = new Promise((resolve, reject) => { refuse = reject; });
39
+
40
+ function clear(name) {
41
+ cleared.add(name);
42
+ const request = factory.deleteDatabase(name);
43
+ deletes.push(new Promise((resolve, reject) => {
44
+ request.addEventListener('success', () => resolve());
45
+ request.addEventListener('error', () => reject(request.error));
46
+ // Another tab of the dev server holds the database: the browser
47
+ // deletes it once that tab lets go, and until then this page is not
48
+ // empty and its saves are refused.
49
+ request.addEventListener('blocked', () => {
50
+ const what = `another tab holds the IndexedDB database ${JSON.stringify(name)} open, so this page did not start empty`;
51
+ scope.console.warn(`[dewdrop] ${what}: close that tab, then reload this one`);
52
+ refuse(new DOMException(`${what}: close that tab, then reload this one`, 'InvalidStateError'));
53
+ });
54
+ }));
55
+ }
56
+
57
+ factory.open = function (name) {
58
+ if (!listed && !cleared.has(String(name))) clear(String(name));
59
+ const request = Reflect.apply(open, this, arguments);
60
+ held.add(request);
61
+ request.addEventListener('error', () => held.delete(request));
62
+ request.addEventListener('success', () => {
63
+ held.delete(request);
64
+ const db = request.result;
65
+ held.add(db);
66
+ db.addEventListener('close', () => held.delete(db));
67
+ const close = db.close;
68
+ db.close = function () {
69
+ held.delete(db);
70
+ return Reflect.apply(close, this, arguments);
71
+ };
72
+ });
73
+ return request;
74
+ };
75
+
76
+ const reset = Promise.resolve()
77
+ .then(() => Reflect.apply(list, factory, []))
78
+ .then((found) => {
79
+ for (const { name } of found) if (!cleared.has(name)) clear(name);
80
+ listed = true;
81
+ return Promise.allSettled(deletes);
82
+ });
83
+ const ready = Promise.race([refused, reset.then(() => Promise.all(deletes)).then(() => undefined)]);
84
+ ready.catch(() => {});
85
+
86
+ // The game lists its databases as they are once the deletes are done,
87
+ // without the saves' own.
88
+ factory.databases = function () {
89
+ return reset
90
+ .then(() => Reflect.apply(list, this, arguments))
91
+ .then((found) => found.filter((each) => each.name !== database));
92
+ };
93
+
94
+ Object.defineProperty(scope, Symbol.for(state), {
95
+ value: Object.freeze({ game, database, ready, connected: () => held.size !== 0 }),
96
+ });
97
+ }
98
+
99
+ // The script the plugin puts in a page the dev server serves, for `game`.
100
+ export function freshScript(game) {
101
+ const options = { game, database: SAVES_DATABASE, state: SAVES_STATE };
102
+ return `(${freshStorage})(window, ${JSON.stringify(options)});`.replace(/</g, '\\u003c');
103
+ }
package/src/index.js CHANGED
@@ -2,11 +2,18 @@
2
2
  // `dewdrop-sdk` itself, so a game written against this runs unchanged there;
3
3
  // here each export carries its call through the Vite dev server: `llm`
4
4
  // over its route, `backend` over its socket to the backend the plugin
5
- // runs, and `clients`, in that backend, over its port back.
5
+ // runs, `clients`, in that backend, over its port back, and `saves` into
6
+ // the browser's own storage, which the plugin's script empties as each
7
+ // page loads; `session` says when a session ends ahead of time, which here
8
+ // none does.
6
9
  export { llm } from './llm.js';
7
10
  export { backend } from './backend.js';
8
11
  export { clients } from './clients.js';
12
+ export { session } from './session.js';
9
13
  import { attachBackend, joining } from './backend.js';
14
+ import { load, save } from './saves.js';
15
+
16
+ export const saves = Object.freeze({ load, save });
10
17
 
11
18
  // Where the page's URL joins this tab: `player` is the label it names —
12
19
  // on Dewdrop, the player's username — the empty string when it names none
@@ -25,5 +32,6 @@ if (typeof document !== 'undefined' && import.meta.hot) {
25
32
  frame: (fn) => requestAnimationFrame(fn),
26
33
  room: joined.room,
27
34
  player: joined.player,
35
+ players: joined.players,
28
36
  });
29
37
  }
package/src/net.js CHANGED
@@ -15,6 +15,9 @@ export const NET_RECORD_HEADER_BYTES = 16;
15
15
  // The most bytes a player's label takes: the name the player joined
16
16
  // under, which the backend hears on the join.
17
17
  export const MAX_NET_PLAYER_BYTES = 64;
18
+ // The most players a room seats, and the most clients it holds: a room's
19
+ // size is 1 to this.
20
+ export const MAX_NET_CLIENTS = 100;
18
21
  // How often the local backend ticks: sixty times a second.
19
22
  export const NET_TICK_MS = 1000 / 60;
20
23
 
package/src/route.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The contract between the package's two halves: the dev server route
2
2
  // `llm` calls, how its answers are told apart from a page served without
3
- // it, and where a backend finds the dev server from its thread.
3
+ // it, where a backend finds the dev server from its thread, and what a
4
+ // page served without the plugin says when it calls `backend` or `saves`.
4
5
 
5
6
  // The dev server route the local `llm.fetch` posts to, and the header
6
7
  // every answer from it carries, so a page served without the route — a
@@ -28,3 +29,8 @@ export function routeUrl() {
28
29
  // The message a client sends without a channel to its backend: a page
29
30
  // served without the plugin, or a build served by a static host.
30
31
  export const NO_CHANNEL = 'no backend channel: add the dew() plugin to vite.config.js, or run the game on Dewdrop';
32
+
33
+ // The message `saves` gives in a page without the plugin's script, which
34
+ // starts the page's storage empty: a page served without the plugin, or a
35
+ // build served by a static host.
36
+ export const NO_SAVES = 'no saves here: add the dew() plugin to vite.config.js, or run the game on Dewdrop';
package/src/saves.js ADDED
@@ -0,0 +1,254 @@
1
+ // `saves` on a developer's machine: the browser stands in for Dewdrop's
2
+ // storage. The plugin's script empties `localStorage` and IndexedDB as
3
+ // each page the dev server serves loads, as Dewdrop does each time a
4
+ // player opens a game; `save` keeps what they hold in a database of the
5
+ // browser's own that the game does not see, under the game dewdrop.json
6
+ // names and the player the page's URL names, and `load` puts it back.
7
+ // Calls run in the order they are made, and the checks and their messages
8
+ // are Dewdrop's, so a call that fails here fails there the same way.
9
+ import { gameHasBackend, joining } from './backend.js';
10
+ import { SAVES_STATE } from './fresh.js';
11
+ import { side } from './net.js';
12
+ import { NO_SAVES } from './route.js';
13
+
14
+ // The store of the saves' database, which holds one snapshot per game and
15
+ // player.
16
+ const SNAPSHOTS = 'snapshots';
17
+
18
+ // The player the page's URL names, as the page joins its backend under.
19
+ const player = joining(typeof location === 'undefined' ? '' : location.search).player;
20
+
21
+ // The end of the calls made so far, which the next call runs after, and
22
+ // the loads among them not yet done.
23
+ let last = Promise.resolve();
24
+ let loads = 0;
25
+
26
+ // Runs `run` once every call made before has settled and the page's
27
+ // storage has started empty; refused at once in a game with a backend —
28
+ // its backend and its tabs alike, as on Dewdrop, where such a game keeps
29
+ // no saves — and in a page without the plugin's script.
30
+ function inOrder(op, run) {
31
+ if (side.backend || gameHasBackend()) return Promise.reject(new DOMException('saves is unavailable in a game with a backend', 'NotSupportedError'));
32
+ const state = globalThis[Symbol.for(SAVES_STATE)];
33
+ if (state === undefined) return Promise.reject(new TypeError(`${op}: ${NO_SAVES}`));
34
+ const call = last.then(() => state.ready).then(() => run(state));
35
+ last = call.catch(() => {});
36
+ return call;
37
+ }
38
+
39
+ // What `localStorage` holds, as a save keeps it.
40
+ function readLocal() {
41
+ const local = [];
42
+ for (let i = 0; i < localStorage.length; i++) {
43
+ const key = localStorage.key(i);
44
+ local.push([key, localStorage.getItem(key)]);
45
+ }
46
+ return local;
47
+ }
48
+
49
+ // Keeps what `localStorage` holds and what IndexedDB has committed for
50
+ // this game and player, and resolves once it is stored. `localStorage` is
51
+ // read at the call, as Dewdrop snapshots it, unless a load is under way,
52
+ // whose result it then keeps.
53
+ export function save() {
54
+ const early = loads === 0 && globalThis.localStorage !== undefined ? readLocal() : null;
55
+ return inOrder('saves.save', async ({ game, database }) => {
56
+ const local = early ?? readLocal();
57
+ const kept = await Promise.all((await indexedDB.databases()).map(({ name }) => read(name)));
58
+ const snapshot = { localStorage: local, databases: kept.filter((each) => each !== null) };
59
+ const db = await openSaves(database);
60
+ try {
61
+ await new Promise((resolve, reject) => {
62
+ const transaction = db.transaction(SNAPSHOTS, 'readwrite', { durability: 'strict' });
63
+ transaction.addEventListener('complete', () => resolve());
64
+ transaction.addEventListener('abort', () => reject(transaction.error ?? new DOMException('saves.save: the save was not stored', 'AbortError')));
65
+ transaction.objectStore(SNAPSHOTS).put(snapshot, [game, player]);
66
+ });
67
+ } finally {
68
+ db.close();
69
+ }
70
+ });
71
+ }
72
+
73
+ // Replaces `localStorage` and every IndexedDB database with what was saved
74
+ // for this game and player and resolves true, or empties them and resolves
75
+ // false when nothing was.
76
+ export function load() {
77
+ loads += 1;
78
+ const done = () => { loads -= 1; };
79
+ return inOrder('saves.load', async ({ game, database, connected }) => {
80
+ const db = await openSaves(database);
81
+ let snapshot;
82
+ try {
83
+ snapshot = await answer(db.transaction(SNAPSHOTS, 'readonly').objectStore(SNAPSHOTS).get([game, player]));
84
+ } finally {
85
+ db.close();
86
+ }
87
+ const names = (await indexedDB.databases()).map(({ name }) => name);
88
+ // The check and the deletes in one go, so no open of the page's comes
89
+ // between them.
90
+ if (connected()) {
91
+ throw new DOMException('saves.load: an IndexedDB database is open: call saves.load() before opening IndexedDB', 'InvalidStateError');
92
+ }
93
+ await Promise.all(names.map((name) => remove(name)));
94
+ const saved = snapshot ?? { localStorage: [], databases: [] };
95
+ await Promise.all(saved.databases.map((each) => recreate(each)));
96
+ localStorage.clear();
97
+ for (const [key, value] of saved.localStorage) localStorage.setItem(key, value);
98
+ return snapshot !== undefined;
99
+ }).finally(done);
100
+ }
101
+
102
+ // What a request answers, once it does.
103
+ function answer(request) {
104
+ return new Promise((resolve, reject) => {
105
+ request.addEventListener('success', () => resolve(request.result));
106
+ request.addEventListener('error', () => reject(request.error));
107
+ });
108
+ }
109
+
110
+ // The saves' database, made on its first open.
111
+ function openSaves(database) {
112
+ const request = indexedDB.open(database, 1);
113
+ request.addEventListener('upgradeneeded', () => request.result.createObjectStore(SNAPSHOTS));
114
+ return answer(request).then((db) => {
115
+ db.addEventListener('versionchange', () => db.close());
116
+ return db;
117
+ });
118
+ }
119
+
120
+ // One database as it is committed: its version, each store's key path,
121
+ // key generator and indexes, and its records, read in one transaction; or
122
+ // null when it was deleted since it was listed. A delete or an upgrade
123
+ // asked for meanwhile waits only for the read.
124
+ function read(name) {
125
+ return new Promise((resolve, reject) => {
126
+ const request = indexedDB.open(name);
127
+ let gone = false;
128
+ request.addEventListener('upgradeneeded', () => {
129
+ gone = true;
130
+ request.transaction.abort();
131
+ });
132
+ request.addEventListener('error', () => (gone ? resolve(null) : reject(request.error)));
133
+ request.addEventListener('success', () => {
134
+ const db = request.result;
135
+ db.addEventListener('versionchange', () => db.close());
136
+ const kept = { name, version: db.version, stores: [] };
137
+ const names = [...db.objectStoreNames];
138
+ if (names.length === 0) {
139
+ db.close();
140
+ resolve(kept);
141
+ return;
142
+ }
143
+ const transaction = db.transaction(names, 'readonly');
144
+ for (const storeName of names) {
145
+ const store = transaction.objectStore(storeName);
146
+ const indexes = [...store.indexNames].map((indexName) => {
147
+ const index = store.index(indexName);
148
+ return { name: indexName, keyPath: index.keyPath, unique: index.unique, multiEntry: index.multiEntry };
149
+ });
150
+ const entry = { name: storeName, keyPath: store.keyPath, autoIncrement: store.autoIncrement, indexes, keys: [], values: [] };
151
+ kept.stores.push(entry);
152
+ answer(store.getAllKeys()).then((keys) => { entry.keys = keys; }, () => {});
153
+ answer(store.getAll()).then((values) => { entry.values = values; }, () => {});
154
+ }
155
+ transaction.addEventListener('complete', () => {
156
+ // Each key generator's next key, which no read shows, kept beside
157
+ // the records, as Dewdrop keeps it.
158
+ const generators = kept.stores.filter((entry) => entry.autoIncrement);
159
+ Promise.all(generators.map(async (entry) => { entry.next = await nextKey(db, entry.name); }))
160
+ .then(() => {
161
+ db.close();
162
+ resolve(kept);
163
+ }, (error) => {
164
+ db.close();
165
+ reject(error);
166
+ });
167
+ });
168
+ transaction.addEventListener('abort', () => {
169
+ db.close();
170
+ reject(transaction.error);
171
+ });
172
+ });
173
+ });
174
+ }
175
+
176
+ // The next key the key generator of `storeName` hands out. A browser shows
177
+ // it to no read, so a record is added and the transaction aborted, which
178
+ // takes back the record and the generator's step; null when the add fails.
179
+ // The record is an empty object, which no index can take a key from but
180
+ // one on the generator's own key path.
181
+ function nextKey(db, storeName) {
182
+ return new Promise((resolve) => {
183
+ const transaction = db.transaction(storeName, 'readwrite');
184
+ let next = null;
185
+ const added = transaction.objectStore(storeName).add({});
186
+ added.addEventListener('success', () => {
187
+ next = added.result;
188
+ transaction.abort();
189
+ });
190
+ added.addEventListener('error', (event) => event.preventDefault());
191
+ transaction.addEventListener('abort', () => resolve(typeof next === 'number' ? next : null));
192
+ transaction.addEventListener('complete', () => resolve(null));
193
+ });
194
+ }
195
+
196
+ // A value holding `key` where `keyPath`, a key generator's, reads it, and
197
+ // nothing an index could take another key from.
198
+ function placeholder(keyPath, key) {
199
+ const value = {};
200
+ const names = keyPath.split('.');
201
+ let at = value;
202
+ for (const name of names.slice(0, -1)) at = at[name] = {};
203
+ at[names[names.length - 1]] = key;
204
+ return value;
205
+ }
206
+
207
+ // Deletes a database, refused while another tab of the dev server holds it
208
+ // open: the browser deletes it once that tab lets go.
209
+ function remove(name) {
210
+ return new Promise((resolve, reject) => {
211
+ const request = indexedDB.deleteDatabase(name);
212
+ request.addEventListener('success', () => resolve());
213
+ request.addEventListener('error', () => reject(request.error));
214
+ request.addEventListener('blocked', () => reject(heldElsewhere(name)));
215
+ });
216
+ }
217
+
218
+ // Makes a saved database again, at its version, with its stores, indexes
219
+ // and records.
220
+ function recreate({ name, version, stores }) {
221
+ return new Promise((resolve, reject) => {
222
+ const request = indexedDB.open(name, version);
223
+ request.addEventListener('upgradeneeded', () => {
224
+ const db = request.result;
225
+ for (const { name: storeName, keyPath, autoIncrement, indexes, keys, values, next } of stores) {
226
+ const store = db.createObjectStore(storeName, { keyPath, autoIncrement });
227
+ for (const index of indexes) {
228
+ store.createIndex(index.name, index.keyPath, { unique: index.unique, multiEntry: index.multiEntry });
229
+ }
230
+ for (let i = 0; i < keys.length; i++) {
231
+ if (keyPath === null) store.put(values[i], keys[i]);
232
+ else store.put(values[i]);
233
+ }
234
+ // The generator back where it was, past keys deleted since they
235
+ // were handed out: a key just below its next, put and deleted,
236
+ // moves it there.
237
+ if (typeof next === 'number' && next > 1 && !keys.includes(next - 1)) {
238
+ const put = keyPath === null ? store.put({}, next - 1) : store.put(placeholder(keyPath, next - 1));
239
+ put.addEventListener('error', (event) => event.preventDefault());
240
+ store.delete(next - 1);
241
+ }
242
+ }
243
+ });
244
+ request.addEventListener('blocked', () => reject(heldElsewhere(name)));
245
+ answer(request).then((db) => {
246
+ db.close();
247
+ resolve();
248
+ }, reject);
249
+ });
250
+ }
251
+
252
+ function heldElsewhere(name) {
253
+ return new DOMException(`saves.load: another tab holds the IndexedDB database ${JSON.stringify(name)} open`, 'InvalidStateError');
254
+ }
package/src/serve.js CHANGED
@@ -1,7 +1,10 @@
1
1
  // The dev server's backend host, its stand-in for Dewdrop. A thread runs
2
2
  // the project's backend.js for each room the tabs join, from the room's
3
3
  // first tab's join until five minutes after its last tab's leave, as on
4
- // Dewdrop, so a tab that comes back in that time rejoins it; the tabs are
4
+ // Dewdrop, so a tab that comes back in that time rejoins it; a room seats
5
+ // the players its first tab's page said, a player counted once however
6
+ // many tabs they play from and an unnamed tab as one each, and refuses a
7
+ // tab past them as Dewdrop refuses its connect; the tabs are
5
8
  // numbered from one per room, never twice while its thread runs; each
6
9
  // tab's frame of sends reaches its room's thread over the port
7
10
  // with the tab's number, and each of the thread's sends reaches the tab it
@@ -14,7 +17,7 @@
14
17
  import { existsSync, readFileSync } from 'node:fs';
15
18
  import { dirname, resolve } from 'node:path';
16
19
  import { Worker } from 'node:worker_threads';
17
- import { label } from './net.js';
20
+ import { MAX_NET_CLIENTS, label } from './net.js';
18
21
 
19
22
  // How long a room's backend runs on after its last tab's leave, as on
20
23
  // Dewdrop.
@@ -47,12 +50,14 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
47
50
  const rooms = new Map();
48
51
  const tabs = new Map();
49
52
 
50
- // One room's backend: its thread from the room's first tab's join until
51
- // EMPTY_ROOM_MS after its last tab's leave with none joining since, its
52
- // tabs by number from one.
53
- function createRoom(key) {
53
+ // One room's backend, seating `capacity` players: its thread from the
54
+ // room's first tab's join until EMPTY_ROOM_MS after its last tab's leave
55
+ // with none joining since, its tabs by number from one with their
56
+ // players' names.
57
+ function createRoom(key, capacity) {
54
58
  const name = key === '' ? 'the backend' : `the backend of room ${JSON.stringify(key)}`;
55
59
  const members = new Map();
60
+ const names = new Map();
56
61
  let worker = null;
57
62
  let next = 1;
58
63
  let broken = false;
@@ -68,7 +73,7 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
68
73
 
69
74
  function start() {
70
75
  files = backendFiles(entry);
71
- worker = new Worker(new URL('./backend-host.js', import.meta.url), { workerData: { entry, origin: origin() } });
76
+ worker = new Worker(new URL('./backend-host.js', import.meta.url), { workerData: { entry, origin: origin(), capacity } });
72
77
  const started = worker;
73
78
  started.on('message', (message) => {
74
79
  if (message.kind === 'send' && worker === started) deliver(message.sends);
@@ -89,6 +94,7 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
89
94
  idle = null;
90
95
  for (const client of members.keys()) tabs.delete(client);
91
96
  members.clear();
97
+ names.clear();
92
98
  next = 1;
93
99
  if (ended !== null) ended.terminate();
94
100
  }
@@ -101,13 +107,26 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
101
107
  broken = true;
102
108
  }
103
109
 
110
+ // The room's players, each once: its tabs' names, and each unnamed tab.
111
+ function seated() {
112
+ const players = [...names.values()];
113
+ return players.filter((player, at) => player === '' || players.indexOf(player) === at).length;
114
+ }
115
+
104
116
  const room = {
105
117
  join(client, player) {
106
118
  if (broken) return;
119
+ const returning = player !== '' && [...names.values()].includes(player);
120
+ if (members.size >= MAX_NET_CLIENTS || (!returning && seated() >= capacity)) {
121
+ log.error(`[dewdrop] a tab is refused ${name}: it seats ${capacity === 1 ? '1 player' : `${capacity} players`}`);
122
+ client.send('dew:full', { capacity });
123
+ return;
124
+ }
107
125
  clearTimeout(idle);
108
126
  idle = null;
109
127
  const number = next++;
110
128
  members.set(client, number);
129
+ names.set(client, player);
111
130
  tabs.set(client, room);
112
131
  if (worker === null) start();
113
132
  worker.postMessage([{ kind: 'join', client: number, player }]);
@@ -117,6 +136,7 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
117
136
  const number = members.get(client);
118
137
  if (number === undefined) return;
119
138
  members.delete(client);
139
+ names.delete(client);
120
140
  tabs.delete(client);
121
141
  worker.postMessage([{ kind: 'leave', client: number }]);
122
142
  if (members.size === 0) {
@@ -151,14 +171,17 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
151
171
  }
152
172
 
153
173
  const host = {
154
- // A tab joins the room its page named under the name it gave; a page
155
- // that named neither joins the empty room unnamed.
156
- join(client, { room = '', player = '' } = {}) {
174
+ // A tab joins the room its page named under the name it gave, a room
175
+ // it makes seating the players its page said; a page that named
176
+ // neither joins the empty room unnamed, and one that named no size a
177
+ // room seating the most.
178
+ join(client, { room = '', player = '', players = MAX_NET_CLIENTS } = {}) {
157
179
  if (tabs.has(client)) return;
158
180
  const key = typeof room === 'string' ? room : '';
159
181
  let it = rooms.get(key);
160
182
  if (it === undefined) {
161
- it = createRoom(key);
183
+ const capacity = Number.isInteger(players) && players >= 1 && players <= MAX_NET_CLIENTS ? players : MAX_NET_CLIENTS;
184
+ it = createRoom(key, capacity);
162
185
  rooms.set(key, it);
163
186
  }
164
187
  it.join(client, label(player));
@@ -171,7 +194,7 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
171
194
  },
172
195
  close,
173
196
  };
174
- ws.on('dew:join', (data, client) => host.join(client, { room: data?.room, player: data?.player }));
197
+ ws.on('dew:join', (data, client) => host.join(client, { room: data?.room, player: data?.player, players: data?.players }));
175
198
  ws.on('dew:send', (data, client) => host.receive(client, data.items ?? []));
176
199
  watcher.on('change', (file) => {
177
200
  if (files.has(resolve(file))) restart(`${file} changed`);
package/src/session.js ADDED
@@ -0,0 +1,17 @@
1
+ // `session` on a developer's machine. On Dewdrop a session can end ahead of
2
+ // time — for maintenance, or a newer version of its game published — and
3
+ // hears of it first, up to fifteen minutes before: `session.ending` is
4
+ // `{at, reason}`, `at` in milliseconds since the epoch as `Date.now()` counts
5
+ // and `reason` `'restart'` or `'update'`, and an `ending` event fires each
6
+ // time it changes, its withdrawal to null included, so a game can save and
7
+ // say so. `npm run dev` ends no session ahead of time, so here `ending`
8
+ // stays null and the event never fires; a game tries its handler by calling
9
+ // it.
10
+ import { defineHandlers } from './net.js';
11
+
12
+ class Session extends EventTarget {
13
+ get ending() { return null; }
14
+ }
15
+ defineHandlers(Session.prototype, ['ending']);
16
+
17
+ export const session = new Session();
package/src/vite.js CHANGED
@@ -5,15 +5,17 @@
5
5
  // `ui/`, and the built page opened in the document by the entry; a game
6
6
  // with a backend gets a second chunk beside it, `backend.js`. In dev: the
7
7
  // route that carries a model call to its provider with the key from the
8
- // developer's environment, and the host that runs the game's backend for
9
- // the tabs that join it. Every other build is untouched. The plugin
8
+ // developer's environment, the host that runs the game's backend for the
9
+ // tabs that join it, and the script that starts each page's storage empty
10
+ // for the game's saves. Every other build is untouched. The plugin
10
11
  // imports nothing from Vite itself, so it works linked from a checkout as
11
12
  // well as installed, on Vite 3 through 8.
12
- import { existsSync } from 'node:fs';
13
+ import { existsSync, readFileSync } from 'node:fs';
13
14
  import { resolve } from 'node:path';
14
15
  import { ASSETS_DIR, BACKEND_FILE, ENTRY_FILE, PAGE_FILE, detectViteMajor, openPage, outputOptions } from './build.js';
15
16
  import { createLlmMiddleware } from './dev.js';
16
17
  import { loadKeys } from './env.js';
18
+ import { freshScript } from './fresh.js';
17
19
  import { DEV_ROUTE } from './route.js';
18
20
  import { createBackendHost } from './serve.js';
19
21
 
@@ -36,10 +38,38 @@ export function backendEntry(root) {
36
38
  return null;
37
39
  }
38
40
 
41
+ // `html` with `script` run before anything of the page's own: first in
42
+ // the page, right after its prologue, which comes before every element
43
+ // and runs nothing, so never in a template, a comment or a script of the
44
+ // page's. A prologue is read as a browser reads it: a byte order mark,
45
+ // whitespace, comments — ended by `-->` or `--!>`, or at once as `<!-->`
46
+ // or `<!--->` — and the doctype and any other `<!` or `<?`, ended by the
47
+ // first `>`. A `<meta charset>` of UTF-8, the encoding Vite serves every
48
+ // page in, leads the script: a browser looks for one in a page's first
49
+ // bytes, which the script fills.
50
+ const PROLOGUE = /^\uFEFF?(?:[\t\n\f\r ]+|<!--(?:-?>|[\s\S]*?(?:--!?>|$))|<[!?][^>]*(?:>|$))*/;
51
+ export function withFirstScript(html, script) {
52
+ const at = PROLOGUE.exec(html)[0].length;
53
+ return `${html.slice(0, at)}<meta charset="utf-8"><script>${script}</script>${html.slice(at)}`;
54
+ }
55
+
56
+ // The game a project's dewdrop.json names, which the dev server keeps its
57
+ // saves under: a name as Dewdrop takes one, else none.
58
+ const GAME_NAME = /^[a-z][a-z0-9-]{0,47}$/;
59
+ export function projectGame(root) {
60
+ try {
61
+ const { game } = JSON.parse(readFileSync(resolve(root, 'dewdrop.json'), 'utf8'));
62
+ return typeof game === 'string' && GAME_NAME.test(game) ? game : '';
63
+ } catch {
64
+ return '';
65
+ }
66
+ }
67
+
39
68
  export default function dew() {
40
69
  let dewBuild = false;
41
70
  let serving = false;
42
71
  let entry = null;
72
+ let game = '';
43
73
  // The dev server's backend host, from the server's start to its close.
44
74
  let host = null;
45
75
  return {
@@ -49,6 +79,7 @@ export default function dew() {
49
79
  entry = backendEntry(root);
50
80
  dewBuild = env.mode === DEW_MODE;
51
81
  serving = env.command === 'serve';
82
+ game = serving ? projectGame(root) : '';
52
83
  if (!dewBuild) {
53
84
  // Served as source, so the hot context the client's `backend`
54
85
  // joins over is injected as in the game's own modules.
@@ -109,11 +140,16 @@ export default function dew() {
109
140
  },
110
141
  },
111
142
  // The page the dev server serves is told whether the game has a
112
- // backend, which the client's `backend` reads before it joins; a
113
- // build's page, which no dev server serves, is left alone.
114
- transformIndexHtml() {
143
+ // backend, which the client's `backend` reads before it joins, and
144
+ // starts with its storage empty, as on Dewdrop, from a script first
145
+ // in the page, before anything of its own.
146
+ // A build's page, which no dev server serves, is left alone.
147
+ transformIndexHtml(html) {
115
148
  if (!serving) return undefined;
116
- return [{ tag: 'meta', attrs: { name: 'dew-backend', content: entry === null ? 'no' : 'yes' }, injectTo: 'head' }];
149
+ return {
150
+ html: withFirstScript(html, freshScript(game)),
151
+ tags: [{ tag: 'meta', attrs: { name: 'dew-backend', content: entry === null ? 'no' : 'yes' }, injectTo: 'head' }],
152
+ };
117
153
  },
118
154
  configureServer(server) {
119
155
  // The keys come from the project's own .env files and the