dewdrop-sdk 0.3.1 → 0.4.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 +19 -3
- package/package.json +2 -2
- package/src/fresh.js +103 -0
- package/src/index.js +6 -1
- package/src/route.js +7 -1
- package/src/saves.js +253 -0
- package/src/serve.js +22 -8
- package/src/vite.js +54 -8
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
|
|
4
|
+
Vite plugin, model calls, multiplayer and saves.
|
|
5
5
|
|
|
6
6
|
## Set up
|
|
7
7
|
|
|
@@ -19,11 +19,27 @@ 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
|
+
each upload of your game starts with none.
|
|
32
|
+
`npm run dev` does the same in your browser: every page load starts with
|
|
33
|
+
`localStorage` and IndexedDB empty, and saves are kept in the browser, in a
|
|
34
|
+
database named `dewdrop-saves` your game doesn't see, for the game
|
|
35
|
+
`dewdrop.json` names and the player `?player=` names, across your edits. Tabs
|
|
36
|
+
of one dev server share the browser's storage, so loading one empties the
|
|
37
|
+
others'.
|
|
22
38
|
|
|
23
39
|
## Docs
|
|
24
40
|
|
|
25
|
-
How to build your game, from the look to model calls, multiplayer and
|
|
26
|
-
works on Dewdrop, is in the docs: https://alpha.dewdrop.world/docs
|
|
41
|
+
How to build your game, from the look to model calls, multiplayer, saves and
|
|
42
|
+
what works on Dewdrop, is in the docs: https://alpha.dewdrop.world/docs
|
|
27
43
|
|
|
28
44
|
## License
|
|
29
45
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dewdrop-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Build a three.js game for Dewdrop: the Vite plugin, model calls and
|
|
3
|
+
"version": "0.4.0",
|
|
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": {
|
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,16 @@
|
|
|
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,
|
|
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.
|
|
6
8
|
export { llm } from './llm.js';
|
|
7
9
|
export { backend } from './backend.js';
|
|
8
10
|
export { clients } from './clients.js';
|
|
9
11
|
import { attachBackend, joining } from './backend.js';
|
|
12
|
+
import { load, save } from './saves.js';
|
|
13
|
+
|
|
14
|
+
export const saves = Object.freeze({ load, save });
|
|
10
15
|
|
|
11
16
|
// Where the page's URL joins this tab: `player` is the label it names —
|
|
12
17
|
// on Dewdrop, the player's username — the empty string when it names none
|
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,
|
|
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,253 @@
|
|
|
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 { 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 backend, and in a page
|
|
28
|
+
// without the plugin's script.
|
|
29
|
+
function inOrder(op, run) {
|
|
30
|
+
if (side.backend) return Promise.reject(new DOMException('saves is unavailable in a backend', 'NotSupportedError'));
|
|
31
|
+
const state = globalThis[Symbol.for(SAVES_STATE)];
|
|
32
|
+
if (state === undefined) return Promise.reject(new TypeError(`${op}: ${NO_SAVES}`));
|
|
33
|
+
const call = last.then(() => state.ready).then(() => run(state));
|
|
34
|
+
last = call.catch(() => {});
|
|
35
|
+
return call;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// What `localStorage` holds, as a save keeps it.
|
|
39
|
+
function readLocal() {
|
|
40
|
+
const local = [];
|
|
41
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
42
|
+
const key = localStorage.key(i);
|
|
43
|
+
local.push([key, localStorage.getItem(key)]);
|
|
44
|
+
}
|
|
45
|
+
return local;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Keeps what `localStorage` holds and what IndexedDB has committed for
|
|
49
|
+
// this game and player, and resolves once it is stored. `localStorage` is
|
|
50
|
+
// read at the call, as Dewdrop snapshots it, unless a load is under way,
|
|
51
|
+
// whose result it then keeps.
|
|
52
|
+
export function save() {
|
|
53
|
+
const early = loads === 0 && globalThis.localStorage !== undefined ? readLocal() : null;
|
|
54
|
+
return inOrder('saves.save', async ({ game, database }) => {
|
|
55
|
+
const local = early ?? readLocal();
|
|
56
|
+
const kept = await Promise.all((await indexedDB.databases()).map(({ name }) => read(name)));
|
|
57
|
+
const snapshot = { localStorage: local, databases: kept.filter((each) => each !== null) };
|
|
58
|
+
const db = await openSaves(database);
|
|
59
|
+
try {
|
|
60
|
+
await new Promise((resolve, reject) => {
|
|
61
|
+
const transaction = db.transaction(SNAPSHOTS, 'readwrite', { durability: 'strict' });
|
|
62
|
+
transaction.addEventListener('complete', () => resolve());
|
|
63
|
+
transaction.addEventListener('abort', () => reject(transaction.error ?? new DOMException('saves.save: the save was not stored', 'AbortError')));
|
|
64
|
+
transaction.objectStore(SNAPSHOTS).put(snapshot, [game, player]);
|
|
65
|
+
});
|
|
66
|
+
} finally {
|
|
67
|
+
db.close();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Replaces `localStorage` and every IndexedDB database with what was saved
|
|
73
|
+
// for this game and player and resolves true, or empties them and resolves
|
|
74
|
+
// false when nothing was.
|
|
75
|
+
export function load() {
|
|
76
|
+
loads += 1;
|
|
77
|
+
const done = () => { loads -= 1; };
|
|
78
|
+
return inOrder('saves.load', async ({ game, database, connected }) => {
|
|
79
|
+
const db = await openSaves(database);
|
|
80
|
+
let snapshot;
|
|
81
|
+
try {
|
|
82
|
+
snapshot = await answer(db.transaction(SNAPSHOTS, 'readonly').objectStore(SNAPSHOTS).get([game, player]));
|
|
83
|
+
} finally {
|
|
84
|
+
db.close();
|
|
85
|
+
}
|
|
86
|
+
const names = (await indexedDB.databases()).map(({ name }) => name);
|
|
87
|
+
// The check and the deletes in one go, so no open of the page's comes
|
|
88
|
+
// between them.
|
|
89
|
+
if (connected()) {
|
|
90
|
+
throw new DOMException('saves.load: an IndexedDB database is open: call saves.load() before opening IndexedDB', 'InvalidStateError');
|
|
91
|
+
}
|
|
92
|
+
await Promise.all(names.map((name) => remove(name)));
|
|
93
|
+
const saved = snapshot ?? { localStorage: [], databases: [] };
|
|
94
|
+
await Promise.all(saved.databases.map((each) => recreate(each)));
|
|
95
|
+
localStorage.clear();
|
|
96
|
+
for (const [key, value] of saved.localStorage) localStorage.setItem(key, value);
|
|
97
|
+
return snapshot !== undefined;
|
|
98
|
+
}).finally(done);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// What a request answers, once it does.
|
|
102
|
+
function answer(request) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
request.addEventListener('success', () => resolve(request.result));
|
|
105
|
+
request.addEventListener('error', () => reject(request.error));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The saves' database, made on its first open.
|
|
110
|
+
function openSaves(database) {
|
|
111
|
+
const request = indexedDB.open(database, 1);
|
|
112
|
+
request.addEventListener('upgradeneeded', () => request.result.createObjectStore(SNAPSHOTS));
|
|
113
|
+
return answer(request).then((db) => {
|
|
114
|
+
db.addEventListener('versionchange', () => db.close());
|
|
115
|
+
return db;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// One database as it is committed: its version, each store's key path,
|
|
120
|
+
// key generator and indexes, and its records, read in one transaction; or
|
|
121
|
+
// null when it was deleted since it was listed. A delete or an upgrade
|
|
122
|
+
// asked for meanwhile waits only for the read.
|
|
123
|
+
function read(name) {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const request = indexedDB.open(name);
|
|
126
|
+
let gone = false;
|
|
127
|
+
request.addEventListener('upgradeneeded', () => {
|
|
128
|
+
gone = true;
|
|
129
|
+
request.transaction.abort();
|
|
130
|
+
});
|
|
131
|
+
request.addEventListener('error', () => (gone ? resolve(null) : reject(request.error)));
|
|
132
|
+
request.addEventListener('success', () => {
|
|
133
|
+
const db = request.result;
|
|
134
|
+
db.addEventListener('versionchange', () => db.close());
|
|
135
|
+
const kept = { name, version: db.version, stores: [] };
|
|
136
|
+
const names = [...db.objectStoreNames];
|
|
137
|
+
if (names.length === 0) {
|
|
138
|
+
db.close();
|
|
139
|
+
resolve(kept);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const transaction = db.transaction(names, 'readonly');
|
|
143
|
+
for (const storeName of names) {
|
|
144
|
+
const store = transaction.objectStore(storeName);
|
|
145
|
+
const indexes = [...store.indexNames].map((indexName) => {
|
|
146
|
+
const index = store.index(indexName);
|
|
147
|
+
return { name: indexName, keyPath: index.keyPath, unique: index.unique, multiEntry: index.multiEntry };
|
|
148
|
+
});
|
|
149
|
+
const entry = { name: storeName, keyPath: store.keyPath, autoIncrement: store.autoIncrement, indexes, keys: [], values: [] };
|
|
150
|
+
kept.stores.push(entry);
|
|
151
|
+
answer(store.getAllKeys()).then((keys) => { entry.keys = keys; }, () => {});
|
|
152
|
+
answer(store.getAll()).then((values) => { entry.values = values; }, () => {});
|
|
153
|
+
}
|
|
154
|
+
transaction.addEventListener('complete', () => {
|
|
155
|
+
// Each key generator's next key, which no read shows, kept beside
|
|
156
|
+
// the records, as Dewdrop keeps it.
|
|
157
|
+
const generators = kept.stores.filter((entry) => entry.autoIncrement);
|
|
158
|
+
Promise.all(generators.map(async (entry) => { entry.next = await nextKey(db, entry.name); }))
|
|
159
|
+
.then(() => {
|
|
160
|
+
db.close();
|
|
161
|
+
resolve(kept);
|
|
162
|
+
}, (error) => {
|
|
163
|
+
db.close();
|
|
164
|
+
reject(error);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
transaction.addEventListener('abort', () => {
|
|
168
|
+
db.close();
|
|
169
|
+
reject(transaction.error);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The next key the key generator of `storeName` hands out. A browser shows
|
|
176
|
+
// it to no read, so a record is added and the transaction aborted, which
|
|
177
|
+
// takes back the record and the generator's step; null when the add fails.
|
|
178
|
+
// The record is an empty object, which no index can take a key from but
|
|
179
|
+
// one on the generator's own key path.
|
|
180
|
+
function nextKey(db, storeName) {
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const transaction = db.transaction(storeName, 'readwrite');
|
|
183
|
+
let next = null;
|
|
184
|
+
const added = transaction.objectStore(storeName).add({});
|
|
185
|
+
added.addEventListener('success', () => {
|
|
186
|
+
next = added.result;
|
|
187
|
+
transaction.abort();
|
|
188
|
+
});
|
|
189
|
+
added.addEventListener('error', (event) => event.preventDefault());
|
|
190
|
+
transaction.addEventListener('abort', () => resolve(typeof next === 'number' ? next : null));
|
|
191
|
+
transaction.addEventListener('complete', () => resolve(null));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A value holding `key` where `keyPath`, a key generator's, reads it, and
|
|
196
|
+
// nothing an index could take another key from.
|
|
197
|
+
function placeholder(keyPath, key) {
|
|
198
|
+
const value = {};
|
|
199
|
+
const names = keyPath.split('.');
|
|
200
|
+
let at = value;
|
|
201
|
+
for (const name of names.slice(0, -1)) at = at[name] = {};
|
|
202
|
+
at[names[names.length - 1]] = key;
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Deletes a database, refused while another tab of the dev server holds it
|
|
207
|
+
// open: the browser deletes it once that tab lets go.
|
|
208
|
+
function remove(name) {
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
const request = indexedDB.deleteDatabase(name);
|
|
211
|
+
request.addEventListener('success', () => resolve());
|
|
212
|
+
request.addEventListener('error', () => reject(request.error));
|
|
213
|
+
request.addEventListener('blocked', () => reject(heldElsewhere(name)));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Makes a saved database again, at its version, with its stores, indexes
|
|
218
|
+
// and records.
|
|
219
|
+
function recreate({ name, version, stores }) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const request = indexedDB.open(name, version);
|
|
222
|
+
request.addEventListener('upgradeneeded', () => {
|
|
223
|
+
const db = request.result;
|
|
224
|
+
for (const { name: storeName, keyPath, autoIncrement, indexes, keys, values, next } of stores) {
|
|
225
|
+
const store = db.createObjectStore(storeName, { keyPath, autoIncrement });
|
|
226
|
+
for (const index of indexes) {
|
|
227
|
+
store.createIndex(index.name, index.keyPath, { unique: index.unique, multiEntry: index.multiEntry });
|
|
228
|
+
}
|
|
229
|
+
for (let i = 0; i < keys.length; i++) {
|
|
230
|
+
if (keyPath === null) store.put(values[i], keys[i]);
|
|
231
|
+
else store.put(values[i]);
|
|
232
|
+
}
|
|
233
|
+
// The generator back where it was, past keys deleted since they
|
|
234
|
+
// were handed out: a key just below its next, put and deleted,
|
|
235
|
+
// moves it there.
|
|
236
|
+
if (typeof next === 'number' && next > 1 && !keys.includes(next - 1)) {
|
|
237
|
+
const put = keyPath === null ? store.put({}, next - 1) : store.put(placeholder(keyPath, next - 1));
|
|
238
|
+
put.addEventListener('error', (event) => event.preventDefault());
|
|
239
|
+
store.delete(next - 1);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
request.addEventListener('blocked', () => reject(heldElsewhere(name)));
|
|
244
|
+
answer(request).then((db) => {
|
|
245
|
+
db.close();
|
|
246
|
+
resolve();
|
|
247
|
+
}, reject);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function heldElsewhere(name) {
|
|
252
|
+
return new DOMException(`saves.load: another tab holds the IndexedDB database ${JSON.stringify(name)} open`, 'InvalidStateError');
|
|
253
|
+
}
|
package/src/serve.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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
|
-
// first tab's join
|
|
4
|
-
//
|
|
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
|
|
5
|
+
// numbered from one per room, never twice while its thread runs; each
|
|
6
|
+
// tab's frame of sends reaches its room's thread over the port
|
|
5
7
|
// with the tab's number, and each of the thread's sends reaches the tab it
|
|
6
8
|
// names, or every tab of the room, over the socket. An edit to the backend
|
|
7
9
|
// restarts every room and reloads the tabs, which join afresh. A backend
|
|
@@ -14,6 +16,10 @@ import { dirname, resolve } from 'node:path';
|
|
|
14
16
|
import { Worker } from 'node:worker_threads';
|
|
15
17
|
import { label } from './net.js';
|
|
16
18
|
|
|
19
|
+
// How long a room's backend runs on after its last tab's leave, as on
|
|
20
|
+
// Dewdrop.
|
|
21
|
+
export const EMPTY_ROOM_MS = 5 * 60 * 1000;
|
|
22
|
+
|
|
17
23
|
// The files an edit to which restarts the backend: the entry and what it
|
|
18
24
|
// reaches through static relative imports.
|
|
19
25
|
export function backendFiles(entry) {
|
|
@@ -41,14 +47,17 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
|
|
|
41
47
|
const rooms = new Map();
|
|
42
48
|
const tabs = new Map();
|
|
43
49
|
|
|
44
|
-
// One room's backend: its thread from the room's first tab's join
|
|
45
|
-
// its last tab's leave
|
|
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.
|
|
46
53
|
function createRoom(key) {
|
|
47
54
|
const name = key === '' ? 'the backend' : `the backend of room ${JSON.stringify(key)}`;
|
|
48
55
|
const members = new Map();
|
|
49
56
|
let worker = null;
|
|
50
57
|
let next = 1;
|
|
51
58
|
let broken = false;
|
|
59
|
+
// While the room is empty, the timer that ends it.
|
|
60
|
+
let idle = null;
|
|
52
61
|
|
|
53
62
|
function deliver(sends) {
|
|
54
63
|
for (const [client, number] of members) {
|
|
@@ -76,6 +85,8 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
|
|
|
76
85
|
function stop() {
|
|
77
86
|
const ended = worker;
|
|
78
87
|
worker = null;
|
|
88
|
+
clearTimeout(idle);
|
|
89
|
+
idle = null;
|
|
79
90
|
for (const client of members.keys()) tabs.delete(client);
|
|
80
91
|
members.clear();
|
|
81
92
|
next = 1;
|
|
@@ -93,6 +104,8 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
|
|
|
93
104
|
const room = {
|
|
94
105
|
join(client, player) {
|
|
95
106
|
if (broken) return;
|
|
107
|
+
clearTimeout(idle);
|
|
108
|
+
idle = null;
|
|
96
109
|
const number = next++;
|
|
97
110
|
members.set(client, number);
|
|
98
111
|
tabs.set(client, room);
|
|
@@ -105,12 +118,13 @@ export function createBackendHost({ entry, ws, watcher, origin = () => '', log =
|
|
|
105
118
|
if (number === undefined) return;
|
|
106
119
|
members.delete(client);
|
|
107
120
|
tabs.delete(client);
|
|
121
|
+
worker.postMessage([{ kind: 'leave', client: number }]);
|
|
108
122
|
if (members.size === 0) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
123
|
+
idle = setTimeout(() => {
|
|
124
|
+
stop();
|
|
125
|
+
rooms.delete(key);
|
|
126
|
+
}, EMPTY_ROOM_MS);
|
|
112
127
|
}
|
|
113
|
-
worker.postMessage([{ kind: 'leave', client: number }]);
|
|
114
128
|
},
|
|
115
129
|
receive(client, items) {
|
|
116
130
|
const number = members.get(client);
|
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,
|
|
9
|
-
//
|
|
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,40 @@ 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 = '';
|
|
73
|
+
// The dev server's backend host, from the server's start to its close.
|
|
74
|
+
let host = null;
|
|
43
75
|
return {
|
|
44
76
|
name: 'dew',
|
|
45
77
|
config(userConfig, env) {
|
|
@@ -47,6 +79,7 @@ export default function dew() {
|
|
|
47
79
|
entry = backendEntry(root);
|
|
48
80
|
dewBuild = env.mode === DEW_MODE;
|
|
49
81
|
serving = env.command === 'serve';
|
|
82
|
+
game = serving ? projectGame(root) : '';
|
|
50
83
|
if (!dewBuild) {
|
|
51
84
|
// Served as source, so the hot context the client's `backend`
|
|
52
85
|
// joins over is injected as in the game's own modules.
|
|
@@ -107,11 +140,16 @@ export default function dew() {
|
|
|
107
140
|
},
|
|
108
141
|
},
|
|
109
142
|
// The page the dev server serves is told whether the game has a
|
|
110
|
-
// backend, which the client's `backend` reads before it joins
|
|
111
|
-
//
|
|
112
|
-
|
|
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) {
|
|
113
148
|
if (!serving) return undefined;
|
|
114
|
-
return
|
|
149
|
+
return {
|
|
150
|
+
html: withFirstScript(html, freshScript(game)),
|
|
151
|
+
tags: [{ tag: 'meta', attrs: { name: 'dew-backend', content: entry === null ? 'no' : 'yes' }, injectTo: 'head' }],
|
|
152
|
+
};
|
|
115
153
|
},
|
|
116
154
|
configureServer(server) {
|
|
117
155
|
// The keys come from the project's own .env files and the
|
|
@@ -120,12 +158,20 @@ export default function dew() {
|
|
|
120
158
|
const env = loadKeys(server.config.envDir ?? server.config.root, server.config.mode);
|
|
121
159
|
server.middlewares.use(DEV_ROUTE, createLlmMiddleware({ env }));
|
|
122
160
|
if (entry === null) return;
|
|
123
|
-
createBackendHost({
|
|
161
|
+
host = createBackendHost({
|
|
124
162
|
entry,
|
|
125
163
|
ws: server.ws,
|
|
126
164
|
watcher: server.watcher,
|
|
127
165
|
origin: () => server.resolvedUrls?.local?.[0]?.replace(/\/$/, '') ?? server.config.server?.origin ?? '',
|
|
128
166
|
});
|
|
129
167
|
},
|
|
168
|
+
// The dev server closing — in middleware mode too, which has no HTTP
|
|
169
|
+
// server of its own — ends every room: a backend left running for an
|
|
170
|
+
// empty room would hold the process open past the server it served.
|
|
171
|
+
// A build's close has no host to end.
|
|
172
|
+
closeBundle() {
|
|
173
|
+
host?.close();
|
|
174
|
+
host = null;
|
|
175
|
+
},
|
|
130
176
|
};
|
|
131
177
|
}
|