dewdrop-sdk 0.3.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/LICENSE +21 -0
- package/README.md +30 -0
- package/package.json +29 -0
- package/src/backend-host.js +17 -0
- package/src/backend.js +87 -0
- package/src/build.js +55 -0
- package/src/clients.js +95 -0
- package/src/dev.js +122 -0
- package/src/env.js +40 -0
- package/src/index.js +29 -0
- package/src/llm.js +96 -0
- package/src/net.js +159 -0
- package/src/providers.js +20 -0
- package/src/route.js +30 -0
- package/src/serve.js +166 -0
- package/src/vite.js +131 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dewdrop Labs, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# dewdrop-sdk
|
|
2
|
+
|
|
3
|
+
The package that builds a three.js game for Dewdrop and runs it locally: the
|
|
4
|
+
Vite plugin, model calls and multiplayer.
|
|
5
|
+
|
|
6
|
+
## Set up
|
|
7
|
+
|
|
8
|
+
In a Vite 6 or later project:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install -D dewdrop-sdk
|
|
12
|
+
npm install --save-exact three@0.185.1
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
// vite.config.js
|
|
17
|
+
import dew from 'dewdrop-sdk/vite';
|
|
18
|
+
export default { plugins: [dew()] };
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`npx dewctl init` sets up a new game this way, with the Dewdrop CLI beside it.
|
|
22
|
+
|
|
23
|
+
## Docs
|
|
24
|
+
|
|
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
|
|
27
|
+
|
|
28
|
+
## License
|
|
29
|
+
|
|
30
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dewdrop-sdk",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Build a three.js game for Dewdrop: the Vite plugin, model calls and multiplayer.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./vite": "./src/vite.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=24"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"vite": ">=3"
|
|
20
|
+
},
|
|
21
|
+
"peerDependenciesMeta": {
|
|
22
|
+
"vite": {
|
|
23
|
+
"optional": true
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test tests/*.test.mjs"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The thread the plugin runs a project's backend.js in: it serves the
|
|
2
|
+
// clients over the port to the plugin, tells the model call where the dev
|
|
3
|
+
// server is, loads the game's backend, whose `dewdrop-sdk` resolves to this
|
|
4
|
+
// package as the plugin's own does, and only then starts delivering what
|
|
5
|
+
// the clients sent, so the first join reaches the handlers the module
|
|
6
|
+
// registered. A throw while the backend evaluates ends the thread, which
|
|
7
|
+
// the host reports and stops; a throw in a handler or a timer after that
|
|
8
|
+
// is logged and the backend goes on.
|
|
9
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import { attachClients } from './clients.js';
|
|
12
|
+
|
|
13
|
+
process.env.DEW_DEV_ORIGIN = workerData.origin;
|
|
14
|
+
const start = attachClients(parentPort);
|
|
15
|
+
await import(pathToFileURL(workerData.entry).href);
|
|
16
|
+
process.on('uncaughtException', (error) => console.error(`uncaught ${error?.stack ?? error}`));
|
|
17
|
+
start();
|
package/src/backend.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// `backend` on a developer's machine: the client's side of the messages a
|
|
2
|
+
// game exchanges with its backend, carried here over the Vite dev server's
|
|
3
|
+
// socket to the backend.js the plugin runs beside it, so a game's code is
|
|
4
|
+
// the same here and on Dewdrop. A frame's sends leave together at the next
|
|
5
|
+
// animation frame; what the backend sends arrives at the next animation
|
|
6
|
+
// frame as `message` events, or one `gap` where the inbox dropped messages
|
|
7
|
+
// past its cap. The page's URL says which room the tab joins and under
|
|
8
|
+
// what name; on Dewdrop the game's page says which room, and the name is
|
|
9
|
+
// the player's username.
|
|
10
|
+
import { MAX_NET_CLIENT_FRAME_BYTES, createInbox, createOutbox, defineHandlers, label, side, unpack } from './net.js';
|
|
11
|
+
import { NO_CHANNEL } from './route.js';
|
|
12
|
+
|
|
13
|
+
let channel = null;
|
|
14
|
+
let hasBackend = false;
|
|
15
|
+
let frame = null;
|
|
16
|
+
const outbox = createOutbox(MAX_NET_CLIENT_FRAME_BYTES);
|
|
17
|
+
const inbox = createInbox();
|
|
18
|
+
let flushing = false;
|
|
19
|
+
let draining = false;
|
|
20
|
+
|
|
21
|
+
class Backend extends EventTarget {
|
|
22
|
+
send(data) {
|
|
23
|
+
if (side.backend) throw new DOMException('backend is unavailable in a backend', 'NotSupportedError');
|
|
24
|
+
if (channel === null) throw new TypeError(`backend.send: ${NO_CHANNEL}`);
|
|
25
|
+
if (!hasBackend) throw new DOMException('backend is unavailable: this game has no backend.js', 'NotSupportedError');
|
|
26
|
+
outbox.push('backend.send', 0, data);
|
|
27
|
+
if (flushing) return;
|
|
28
|
+
flushing = true;
|
|
29
|
+
frame(() => {
|
|
30
|
+
flushing = false;
|
|
31
|
+
channel.send('dew:send', { items: outbox.flush() });
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
defineHandlers(Backend.prototype, ['message', 'gap']);
|
|
36
|
+
|
|
37
|
+
export const backend = new Backend();
|
|
38
|
+
|
|
39
|
+
function deliver() {
|
|
40
|
+
draining = false;
|
|
41
|
+
for (const record of inbox.drain()) {
|
|
42
|
+
backend.dispatchEvent(record.kind === 'gap' ? new Event('gap') : new MessageEvent('message', { data: unpack(record) }));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A room as Dewdrop names one: lowercase letters, digits, and dashes, up
|
|
47
|
+
// to 32, a name typed in capitals or with space around it being the same
|
|
48
|
+
// room; anything else names no room.
|
|
49
|
+
const ROOM_KEY = /^[a-z0-9-]{1,32}$/;
|
|
50
|
+
function room(text) {
|
|
51
|
+
const key = text.trim().toLowerCase();
|
|
52
|
+
return ROOM_KEY.test(key) ? key : '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A name as Dewdrop takes one: control characters removed, trimmed, then
|
|
56
|
+
// cut to the label's cap.
|
|
57
|
+
function name(text) {
|
|
58
|
+
return label(text.replace(/\p{Cc}/gu, '').trim());
|
|
59
|
+
}
|
|
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
|
|
63
|
+
// one, or with one that names no room; unnamed without a name, or with one
|
|
64
|
+
// that is nothing but space and control characters.
|
|
65
|
+
export function joining(search) {
|
|
66
|
+
const params = new URLSearchParams(search);
|
|
67
|
+
return { room: room(params.get('room') ?? ''), player: name(params.get('player') ?? '') };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 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 = '' }) {
|
|
75
|
+
if (!routed) return;
|
|
76
|
+
channel = hot;
|
|
77
|
+
hasBackend = present;
|
|
78
|
+
frame = schedule;
|
|
79
|
+
if (!present) return;
|
|
80
|
+
hot.on('dew:recv', ({ items }) => {
|
|
81
|
+
inbox.receive(items.map((item) => ({ kind: 'message', ...item })));
|
|
82
|
+
if (draining) return;
|
|
83
|
+
draining = true;
|
|
84
|
+
frame(deliver);
|
|
85
|
+
});
|
|
86
|
+
hot.send('dew:join', { room, player });
|
|
87
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// What a Dewdrop build asks of the bundler: one chunk of ES modules,
|
|
2
|
+
// named in the words of the Vite the project has installed.
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
// The Dewdrop build's entry: the one chunk, written as `main.js` at the
|
|
9
|
+
// top of the output beside the index.html a browser would open, so the
|
|
10
|
+
// built folder is ready to upload as it is; a game with a backend gets a
|
|
11
|
+
// second chunk beside it, `backend.js`.
|
|
12
|
+
export const ENTRY_FILE = 'main.js';
|
|
13
|
+
export const BACKEND_FILE = 'backend.js';
|
|
14
|
+
|
|
15
|
+
// The page a browser would open, as the build emits it beside the entry.
|
|
16
|
+
export const PAGE_FILE = 'index.html';
|
|
17
|
+
|
|
18
|
+
// Where Vite writes the files it emits — the stylesheets a game imports,
|
|
19
|
+
// and the images, fonts, and models it imports above the inline limit —
|
|
20
|
+
// `ui/`, where what the page shows must be on Dewdrop.
|
|
21
|
+
export const ASSETS_DIR = 'ui';
|
|
22
|
+
|
|
23
|
+
// The block that opens the built page in the document, as a browser would
|
|
24
|
+
// have before the entry ran: on Dewdrop the game starts from the entry
|
|
25
|
+
// rather than the page, so the page's head (the stylesheets Vite linked,
|
|
26
|
+
// the title) and its body (a loading screen, a HUD's containers) reach the
|
|
27
|
+
// document through the entry, the page's scripts dropped.
|
|
28
|
+
export function openPage(html) {
|
|
29
|
+
return `{ const page = new DOMParser().parseFromString(${JSON.stringify(html)}, 'text/html'); `
|
|
30
|
+
+ `for (const script of page.querySelectorAll('script')) script.remove(); `
|
|
31
|
+
+ `document.head.append(...page.head.childNodes); document.body.append(...page.body.childNodes); }\n`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The output settings for one chunk of ES modules named as the Dewdrop
|
|
35
|
+
// build's entry: Rollup, which Vite used through 7, folds dynamic imports
|
|
36
|
+
// with `inlineDynamicImports`; Rolldown, under Vite 8, calls the same
|
|
37
|
+
// thing `codeSplitting: false` and warns about the old name.
|
|
38
|
+
export function outputOptions(viteMajor, entryFileNames = ENTRY_FILE) {
|
|
39
|
+
const oneChunk = viteMajor !== undefined && viteMajor >= 8
|
|
40
|
+
? { codeSplitting: false }
|
|
41
|
+
: { inlineDynamicImports: true };
|
|
42
|
+
return { format: 'es', entryFileNames, ...oneChunk };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The major version of the Vite the project at `root` has installed, or
|
|
46
|
+
// undefined when none can be found.
|
|
47
|
+
export function detectViteMajor(root) {
|
|
48
|
+
try {
|
|
49
|
+
const require = createRequire(pathToFileURL(join(root, 'package.json')));
|
|
50
|
+
const { version } = JSON.parse(readFileSync(require.resolve('vite/package.json'), 'utf8'));
|
|
51
|
+
return Number(version.split('.')[0]);
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/clients.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
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:
|
|
5
|
+
// each tick delivers what the clients sent as `join`, `leave`, `message`,
|
|
6
|
+
// and `gap` events — a join naming the player's label — runs the
|
|
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';
|
|
9
|
+
|
|
10
|
+
const outbox = createOutbox(MAX_NET_BACKEND_FRAME_BYTES);
|
|
11
|
+
const inbox = createInbox();
|
|
12
|
+
let port = null;
|
|
13
|
+
|
|
14
|
+
// A client number as the backend was told it: a whole number from one.
|
|
15
|
+
function number(op, value) {
|
|
16
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 0xffffffff) {
|
|
17
|
+
throw new TypeError(`${op}: client is not a client number`);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class Clients extends EventTarget {
|
|
23
|
+
send(to, data) {
|
|
24
|
+
if (port === null) throw new DOMException('clients is unavailable in a client', 'NotSupportedError');
|
|
25
|
+
outbox.push('clients.send', number('clients.send', to), data);
|
|
26
|
+
}
|
|
27
|
+
broadcast(data) {
|
|
28
|
+
if (port === null) throw new DOMException('clients is unavailable in a client', 'NotSupportedError');
|
|
29
|
+
outbox.push('clients.broadcast', 0, data);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
defineHandlers(Clients.prototype, ['join', 'leave', 'message', 'gap']);
|
|
33
|
+
|
|
34
|
+
export const clients = new Clients();
|
|
35
|
+
|
|
36
|
+
// The animation-frame callbacks, run once each tick; installed when the
|
|
37
|
+
// thread has none of its own.
|
|
38
|
+
const callbacks = new Map();
|
|
39
|
+
let nextCallback = 1;
|
|
40
|
+
function requestAnimationFrame(fn) {
|
|
41
|
+
if (typeof fn !== 'function') throw new TypeError("Failed to execute 'requestAnimationFrame': parameter 1 is not of type 'Function'");
|
|
42
|
+
const id = nextCallback++;
|
|
43
|
+
callbacks.set(id, fn);
|
|
44
|
+
return id;
|
|
45
|
+
}
|
|
46
|
+
function cancelAnimationFrame(id) {
|
|
47
|
+
callbacks.delete(id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function frame() {
|
|
51
|
+
for (const record of inbox.drain()) {
|
|
52
|
+
switch (record.kind) {
|
|
53
|
+
case 'join': clients.dispatchEvent(new ClientEvent('join', { client: record.client, player: record.player ?? '' })); break;
|
|
54
|
+
case 'leave': clients.dispatchEvent(new ClientEvent('leave', { client: record.client })); break;
|
|
55
|
+
case 'message': clients.dispatchEvent(new ClientEvent('message', { client: record.client, data: unpack(record) })); break;
|
|
56
|
+
default: clients.dispatchEvent(new Event('gap'));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const due = [...callbacks.values()];
|
|
60
|
+
callbacks.clear();
|
|
61
|
+
const now = performance.now();
|
|
62
|
+
for (const fn of due) {
|
|
63
|
+
// A callback that throws is logged and the rest run, as in a browser.
|
|
64
|
+
try {
|
|
65
|
+
fn(now);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error(`uncaught ${error?.stack ?? error}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const sends = outbox.flush();
|
|
71
|
+
if (sends.length !== 0) port.postMessage({ kind: 'send', sends });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Serves the clients over `channel`, the port to the plugin's host, from
|
|
75
|
+
// now on: what comes in is a record per message — `{kind, client, ...}` —
|
|
76
|
+
// held until the returned function starts the ticks, which the host calls
|
|
77
|
+
// once the backend has evaluated, so the first tick delivers what arrived
|
|
78
|
+
// 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) } = {}) {
|
|
81
|
+
port = channel;
|
|
82
|
+
side.backend = true;
|
|
83
|
+
if (typeof globalThis.requestAnimationFrame !== 'function') {
|
|
84
|
+
globalThis.requestAnimationFrame = requestAnimationFrame;
|
|
85
|
+
globalThis.cancelAnimationFrame = cancelAnimationFrame;
|
|
86
|
+
}
|
|
87
|
+
// A backend has no view: its viewport reads zero.
|
|
88
|
+
globalThis.innerWidth ??= 0;
|
|
89
|
+
globalThis.innerHeight ??= 0;
|
|
90
|
+
port.on('message', (records) => inbox.receive(records));
|
|
91
|
+
return function start() {
|
|
92
|
+
frame();
|
|
93
|
+
tick(frame);
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/dev.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The dev server's model-call route, its stand-in for Dewdrop. It takes
|
|
2
|
+
// the envelope `llm.fetch` posts — the provider URL, method, headers, and
|
|
3
|
+
// body — adds the key from the developer's environment, sends the call,
|
|
4
|
+
// and answers with the provider's status, content type, and body, or a
|
|
5
|
+
// reason in the `x-dew-error` header for a call that got no response, as
|
|
6
|
+
// Dewdrop refuses one. Asked with GET, it names the providers it holds
|
|
7
|
+
// keys for.
|
|
8
|
+
import { LLM_TIMEOUT_MS, MAX_LLM_REQUEST_BYTES, MAX_LLM_RESPONSE_BYTES, PROVIDERS } from './providers.js';
|
|
9
|
+
import { ROUTE_HEADER } from './route.js';
|
|
10
|
+
|
|
11
|
+
// Headers the transport sets for itself, dropped from the game's request.
|
|
12
|
+
const HOP_HEADERS = ['host', 'content-length', 'transfer-encoding', 'connection', 'expect'];
|
|
13
|
+
|
|
14
|
+
function readBody(req, limit) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const chunks = [];
|
|
17
|
+
let length = 0;
|
|
18
|
+
req.on('data', (chunk) => {
|
|
19
|
+
length += chunk.length;
|
|
20
|
+
if (length > limit) {
|
|
21
|
+
reject(new Error(`a call is at most ${MAX_LLM_REQUEST_BYTES} bytes with its headers and body`));
|
|
22
|
+
req.destroy();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
chunks.push(chunk);
|
|
26
|
+
});
|
|
27
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
28
|
+
req.on('error', reject);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function refuse(res, status, reason) {
|
|
33
|
+
res.statusCode = status;
|
|
34
|
+
res.setHeader('x-dew-error', reason);
|
|
35
|
+
res.setHeader('content-type', 'text/plain');
|
|
36
|
+
res.end(reason);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Where a host's calls go: the provider's own origin, or a test's listener.
|
|
40
|
+
function originFor(origins, host) {
|
|
41
|
+
return origins[host] ?? `https://${host}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// `env` holds the keys by variable name; `fetch` sends the call; `origins`
|
|
45
|
+
// maps a provider host to a stand-in for tests. The returned function is
|
|
46
|
+
// connect middleware, as Vite's server takes it.
|
|
47
|
+
export function createLlmMiddleware({ env = process.env, fetch = globalThis.fetch, origins = {} } = {}) {
|
|
48
|
+
return async (req, res) => {
|
|
49
|
+
// Every answer says it came from this route, refusals included.
|
|
50
|
+
res.setHeader(ROUTE_HEADER, 'llm');
|
|
51
|
+
// GET names the providers with a key; POST carries a call.
|
|
52
|
+
if (req.method === 'GET') {
|
|
53
|
+
const hosts = PROVIDERS.filter((provider) => env[provider.keyVar]).map((provider) => provider.host);
|
|
54
|
+
res.statusCode = 200;
|
|
55
|
+
res.setHeader('content-type', 'application/json');
|
|
56
|
+
res.end(JSON.stringify({ hosts }));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (req.method !== 'POST') {
|
|
60
|
+
refuse(res, 405, 'the model-call route takes GET or POST');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let envelope;
|
|
64
|
+
try {
|
|
65
|
+
// The envelope carries the body as base64, so the byte budget leaves
|
|
66
|
+
// room for its expansion and the JSON around it.
|
|
67
|
+
envelope = JSON.parse((await readBody(req, MAX_LLM_REQUEST_BYTES * 2)).toString('utf8'));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
refuse(res, 400, error.message);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let url;
|
|
73
|
+
try {
|
|
74
|
+
url = new URL(envelope.url);
|
|
75
|
+
} catch {
|
|
76
|
+
refuse(res, 400, `${envelope.url} is not a URL`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const provider = url.protocol === 'https:' && !url.port && !url.username && !url.password
|
|
80
|
+
? PROVIDERS.find((each) => each.host === url.hostname)
|
|
81
|
+
: undefined;
|
|
82
|
+
if (provider === undefined) {
|
|
83
|
+
refuse(res, 502, `${url.origin} is not a model provider Dewdrop carries calls to`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const key = env[provider.keyVar];
|
|
87
|
+
if (!key) {
|
|
88
|
+
refuse(res, 502, `no key for ${provider.host}: set ${provider.keyVar}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const headers = new Headers();
|
|
92
|
+
for (const [name, value] of envelope.headers ?? []) {
|
|
93
|
+
const lower = String(name).toLowerCase();
|
|
94
|
+
if (HOP_HEADERS.includes(lower) || lower === provider.header) continue;
|
|
95
|
+
headers.set(lower, String(value));
|
|
96
|
+
}
|
|
97
|
+
headers.set(provider.header, provider.prefix + key);
|
|
98
|
+
const body = Buffer.from(String(envelope.body ?? ''), 'base64');
|
|
99
|
+
let upstream;
|
|
100
|
+
try {
|
|
101
|
+
upstream = await fetch(originFor(origins, provider.host) + url.pathname + url.search, {
|
|
102
|
+
method: envelope.method,
|
|
103
|
+
headers,
|
|
104
|
+
body: body.length > 0 ? body : undefined,
|
|
105
|
+
redirect: 'manual',
|
|
106
|
+
signal: AbortSignal.timeout(LLM_TIMEOUT_MS),
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
refuse(res, 502, `${provider.host}: ${error.message}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const answer = Buffer.from(await upstream.arrayBuffer());
|
|
113
|
+
if (answer.length > MAX_LLM_RESPONSE_BYTES) {
|
|
114
|
+
refuse(res, 502, `${provider.host}: the response is past ${MAX_LLM_RESPONSE_BYTES} bytes`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
res.statusCode = upstream.status;
|
|
118
|
+
const type = upstream.headers.get('content-type');
|
|
119
|
+
if (type !== null) res.setHeader('content-type', type);
|
|
120
|
+
res.end(answer);
|
|
121
|
+
};
|
|
122
|
+
}
|
package/src/env.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The keys the dev route reads: the project's .env files in Vite's order,
|
|
2
|
+
// then the environment, parsed here so the plugin imports nothing from
|
|
3
|
+
// Vite and works linked from a checkout as well as installed.
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
// The variables in one `.env` file: `KEY=VALUE` lines, `#` comments,
|
|
8
|
+
// optional quotes; nothing expanded.
|
|
9
|
+
export function parseDotenv(text) {
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
12
|
+
const line = raw.trim();
|
|
13
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
14
|
+
const at = line.indexOf('=');
|
|
15
|
+
if (at <= 0) continue;
|
|
16
|
+
const key = line.slice(0, at).trim().replace(/^export\s+/, '');
|
|
17
|
+
let value = line.slice(at + 1).trim();
|
|
18
|
+
// A quoted value runs to its closing quote, whatever follows; a bare
|
|
19
|
+
// one runs to a comment.
|
|
20
|
+
const quoted = value.match(/^(["'])(.*?)\1/);
|
|
21
|
+
if (quoted) value = quoted[2];
|
|
22
|
+
else {
|
|
23
|
+
const comment = value.indexOf(' #');
|
|
24
|
+
if (comment >= 0) value = value.slice(0, comment).trim();
|
|
25
|
+
}
|
|
26
|
+
out[key] = value;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The environment the route reads keys from: the project's `.env` files
|
|
32
|
+
// in Vite's order, then the process's own, which wins.
|
|
33
|
+
export function loadKeys(root, mode) {
|
|
34
|
+
const env = {};
|
|
35
|
+
for (const name of ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]) {
|
|
36
|
+
const path = join(root, name);
|
|
37
|
+
if (existsSync(path)) Object.assign(env, parseDotenv(readFileSync(path, 'utf8')));
|
|
38
|
+
}
|
|
39
|
+
return { ...env, ...process.env };
|
|
40
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// `dewdrop-sdk` on a developer's machine. On Dewdrop, Dewdrop supplies
|
|
2
|
+
// `dewdrop-sdk` itself, so a game written against this runs unchanged there;
|
|
3
|
+
// here each export carries its call through the Vite dev server: `llm`
|
|
4
|
+
// over its route, `backend` over its socket to the backend the plugin
|
|
5
|
+
// runs, and `clients`, in that backend, over its port back.
|
|
6
|
+
export { llm } from './llm.js';
|
|
7
|
+
export { backend } from './backend.js';
|
|
8
|
+
export { clients } from './clients.js';
|
|
9
|
+
import { attachBackend, joining } from './backend.js';
|
|
10
|
+
|
|
11
|
+
// Where the page's URL joins this tab: `player` is the label it names —
|
|
12
|
+
// on Dewdrop, the player's username — the empty string when it names none
|
|
13
|
+
// and always in a backend's thread, where there is no location.
|
|
14
|
+
const joined = joining(typeof location === 'undefined' ? '' : location.search);
|
|
15
|
+
export const player = joined.player;
|
|
16
|
+
|
|
17
|
+
// A page served by the dev server joins its room's backend as it loads,
|
|
18
|
+
// told by the plugin whether the game has one; a built game, or one
|
|
19
|
+
// served by something else, has no channel and says so when it sends.
|
|
20
|
+
if (typeof document !== 'undefined' && import.meta.hot) {
|
|
21
|
+
const meta = document.querySelector('meta[name="dew-backend"]');
|
|
22
|
+
attachBackend(import.meta.hot, {
|
|
23
|
+
channel: meta !== null,
|
|
24
|
+
hasBackend: meta?.content === 'yes',
|
|
25
|
+
frame: (fn) => requestAnimationFrame(fn),
|
|
26
|
+
room: joined.room,
|
|
27
|
+
player: joined.player,
|
|
28
|
+
});
|
|
29
|
+
}
|
package/src/llm.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// `llm.fetch` and `llm.providers` on a developer's machine: a model call
|
|
2
|
+
// carried by the Vite dev server's route (see ./vite.js) with the key from
|
|
3
|
+
// the developer's own environment, so a game's code is the same here and
|
|
4
|
+
// on Dewdrop and the key never reaches the browser; and the providers that
|
|
5
|
+
// route holds keys for. The checks and their messages are Dewdrop's, so a
|
|
6
|
+
// call that fails here fails there the same way.
|
|
7
|
+
import { MAX_LLM_HEADERS, MAX_LLM_INFLIGHT, MAX_LLM_REQUEST_BYTES } from './providers.js';
|
|
8
|
+
import { NO_ROUTE, ROUTE_HEADER, routeUrl } from './route.js';
|
|
9
|
+
|
|
10
|
+
let inflight = 0;
|
|
11
|
+
|
|
12
|
+
// The bytes a call counts against its cap: its method, URL, headers, and
|
|
13
|
+
// body, plus four bytes for the call and four for each length and count.
|
|
14
|
+
function requestBytes(method, url, headers, bodyLength) {
|
|
15
|
+
const utf8 = (text) => new TextEncoder().encode(text).length;
|
|
16
|
+
let size = 4 + 4 + utf8(method) + 4 + utf8(url) + 4;
|
|
17
|
+
for (const [name, value] of headers) size += 4 + utf8(name) + 4 + utf8(value);
|
|
18
|
+
return size + 4 + bodyLength;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function base64(bytes) {
|
|
22
|
+
let binary = '';
|
|
23
|
+
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
24
|
+
return btoa(binary);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The checks a call fails before it is posted are made as it is made, as
|
|
28
|
+
// on Dewdrop, so a fifth call while four are outstanding is refused
|
|
29
|
+
// whether or not the four have posted yet. A call counts as outstanding
|
|
30
|
+
// from its acceptance until the route answers, an aborted one included:
|
|
31
|
+
// aborting rejects the caller at once and drops the answer, but the call
|
|
32
|
+
// itself has gone.
|
|
33
|
+
function fetchModel(input, init) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
let request;
|
|
36
|
+
try { request = new Request(input, init); } catch (error) { reject(error); return; }
|
|
37
|
+
if (request.signal.aborted) { reject(request.signal.reason); return; }
|
|
38
|
+
// Only HTTPS leaves the game; whether the host is a provider is the
|
|
39
|
+
// route's to say, and it refuses a call it will not carry with the
|
|
40
|
+
// reason.
|
|
41
|
+
const url = new URL(request.url);
|
|
42
|
+
if (url.protocol !== 'https:') {
|
|
43
|
+
reject(new TypeError(`llm.fetch: ${url.origin} is not a model provider Dewdrop carries calls to`));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const headers = [...request.headers];
|
|
47
|
+
if (headers.length > MAX_LLM_HEADERS) {
|
|
48
|
+
reject(new RangeError(`llm.fetch: a call names at most ${MAX_LLM_HEADERS} headers`));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (inflight >= MAX_LLM_INFLIGHT) {
|
|
52
|
+
reject(new RangeError(`llm.fetch: at most ${MAX_LLM_INFLIGHT} calls are outstanding at once`));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
inflight += 1;
|
|
56
|
+
const onAbort = () => reject(request.signal.reason);
|
|
57
|
+
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
58
|
+
carry(request, headers).then(resolve, reject).finally(() => {
|
|
59
|
+
inflight -= 1;
|
|
60
|
+
request.signal.removeEventListener('abort', onAbort);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Posts one accepted call to the route and returns its answer.
|
|
66
|
+
async function carry(request, headers) {
|
|
67
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
68
|
+
if (requestBytes(request.method, request.url, headers, body.length) > MAX_LLM_REQUEST_BYTES) {
|
|
69
|
+
throw new RangeError(`llm.fetch: a call is at most ${MAX_LLM_REQUEST_BYTES} bytes with its headers and body`);
|
|
70
|
+
}
|
|
71
|
+
const response = await globalThis.fetch(routeUrl(), {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'content-type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({ url: request.url, method: request.method, headers, body: base64(body) }),
|
|
75
|
+
});
|
|
76
|
+
if (!response.headers.has(ROUTE_HEADER)) throw new TypeError(`llm.fetch: ${NO_ROUTE}`);
|
|
77
|
+
const reason = response.headers.get('x-dew-error');
|
|
78
|
+
if (reason !== null) throw new TypeError(`llm.fetch: ${reason}`);
|
|
79
|
+
return response;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The providers' hosts the dev server holds keys for: asked of its route,
|
|
83
|
+
// once, since the browser cannot read the developer's environment.
|
|
84
|
+
let known = null;
|
|
85
|
+
async function providers() {
|
|
86
|
+
if (known === null) {
|
|
87
|
+
const response = await globalThis.fetch(routeUrl());
|
|
88
|
+
if (!response.headers.has(ROUTE_HEADER)) throw new TypeError(`llm.providers: ${NO_ROUTE}`);
|
|
89
|
+
const reason = response.headers.get('x-dew-error');
|
|
90
|
+
if (reason !== null) throw new TypeError(`llm.providers: ${reason}`);
|
|
91
|
+
known = Object.freeze((await response.json()).hosts.map(String));
|
|
92
|
+
}
|
|
93
|
+
return known;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const llm = Object.freeze({ fetch: fetchModel, providers });
|
package/src/net.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The message surface's shared half: the caps, a message measured as
|
|
2
|
+
// Dewdrop measures it, the envelope a frame's sends travel in over the dev
|
|
3
|
+
// server's socket, and the outbox and inbox that hold a game's messages to
|
|
4
|
+
// the caps. The checks and their messages are Dewdrop's, so a send that
|
|
5
|
+
// fails here fails on Dewdrop the same way.
|
|
6
|
+
|
|
7
|
+
// The limits Dewdrop holds a game to.
|
|
8
|
+
export const MAX_NET_MESSAGE_BYTES = 64 << 10;
|
|
9
|
+
export const MAX_NET_CLIENT_FRAME_BYTES = 16 << 10;
|
|
10
|
+
export const MAX_NET_BACKEND_FRAME_BYTES = 256 << 10;
|
|
11
|
+
export const MAX_NET_INBOX_BYTES = 256 << 10;
|
|
12
|
+
// What each message costs beyond its bytes, toward its frame's cap and
|
|
13
|
+
// the inbox's, an empty message included.
|
|
14
|
+
export const NET_RECORD_HEADER_BYTES = 16;
|
|
15
|
+
// The most bytes a player's label takes: the name the player joined
|
|
16
|
+
// under, which the backend hears on the join.
|
|
17
|
+
export const MAX_NET_PLAYER_BYTES = 64;
|
|
18
|
+
// How often the local backend ticks: sixty times a second.
|
|
19
|
+
export const NET_TICK_MS = 1000 / 60;
|
|
20
|
+
|
|
21
|
+
// A message's kind and bytes: a string as UTF-8 text, an ArrayBuffer or a
|
|
22
|
+
// view of one as binary; anything else is refused, `op` naming the call.
|
|
23
|
+
export function measure(op, data) {
|
|
24
|
+
if (typeof data === 'string') return { text: true, bytes: new TextEncoder().encode(data) };
|
|
25
|
+
if (Object.prototype.toString.call(data) === '[object ArrayBuffer]') return { text: false, bytes: new Uint8Array(data) };
|
|
26
|
+
if (ArrayBuffer.isView(data)) return { text: false, bytes: new Uint8Array(data.buffer, data.byteOffset, data.byteLength) };
|
|
27
|
+
throw new TypeError(`${op}: a message is a string, an ArrayBuffer, or a view of one`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Bytes as base64, as the dev server's socket carries them.
|
|
31
|
+
function base64(bytes) {
|
|
32
|
+
let binary = '';
|
|
33
|
+
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
34
|
+
return btoa(binary);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function unbase64(text) {
|
|
38
|
+
const binary = atob(text);
|
|
39
|
+
const bytes = new Uint8Array(binary.length);
|
|
40
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
41
|
+
return bytes.buffer;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// One message as the envelope carries it: text as itself, bytes as base64.
|
|
45
|
+
export function pack(op, data) {
|
|
46
|
+
const { text, bytes } = measure(op, data);
|
|
47
|
+
return text ? { t: 's', d: data, n: bytes.length } : { t: 'b', d: base64(bytes), n: bytes.length };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A message out of the envelope: a string, or a fresh ArrayBuffer.
|
|
51
|
+
export function unpack(item) {
|
|
52
|
+
return item.t === 's' ? item.d : unbase64(item.d);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A player's or a backend's sends for one frame, held to the message cap
|
|
56
|
+
// and to its `frameCap`, each message costing its bytes plus
|
|
57
|
+
// `NET_RECORD_HEADER_BYTES`: `push` queues one message for the frame,
|
|
58
|
+
// refusing past either cap, and `flush` takes the frame's items and starts
|
|
59
|
+
// the next.
|
|
60
|
+
export function createOutbox(frameCap) {
|
|
61
|
+
let sent = 0;
|
|
62
|
+
let items = [];
|
|
63
|
+
return {
|
|
64
|
+
push(op, to, data) {
|
|
65
|
+
const item = pack(op, data);
|
|
66
|
+
if (item.n > MAX_NET_MESSAGE_BYTES) throw new RangeError(`${op}: a message is at most ${MAX_NET_MESSAGE_BYTES} bytes`);
|
|
67
|
+
const cost = NET_RECORD_HEADER_BYTES + item.n;
|
|
68
|
+
if (sent + cost > frameCap) throw new RangeError(`${op}: a frame sends at most ${frameCap} bytes`);
|
|
69
|
+
sent += cost;
|
|
70
|
+
items.push({ to, ...item });
|
|
71
|
+
},
|
|
72
|
+
flush() {
|
|
73
|
+
const frame = items;
|
|
74
|
+
items = [];
|
|
75
|
+
sent = 0;
|
|
76
|
+
return frame;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A receiver's inbox, held to its cap: `receive` keeps the records handed
|
|
82
|
+
// over — messages, and for a backend joins and leaves — and past the cap
|
|
83
|
+
// drops every message not yet drained for one gap, joins and leaves kept;
|
|
84
|
+
// `drain` takes what is held, in order.
|
|
85
|
+
export function createInbox() {
|
|
86
|
+
let held = [];
|
|
87
|
+
let bytes = 0;
|
|
88
|
+
const size = (record) => NET_RECORD_HEADER_BYTES + (record.n ?? 0);
|
|
89
|
+
return {
|
|
90
|
+
receive(records) {
|
|
91
|
+
for (const record of records) {
|
|
92
|
+
const needed = size(record);
|
|
93
|
+
if (record.kind === 'message' && bytes + needed > MAX_NET_INBOX_BYTES) {
|
|
94
|
+
held = held.filter((each) => each.kind === 'join' || each.kind === 'leave');
|
|
95
|
+
held.push({ kind: 'gap' });
|
|
96
|
+
bytes = held.reduce((sum, each) => sum + size(each), 0);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
held.push(record);
|
|
100
|
+
bytes += needed;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
drain() {
|
|
104
|
+
const records = held;
|
|
105
|
+
held = [];
|
|
106
|
+
bytes = 0;
|
|
107
|
+
return records;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A player's label held to its cap: text cut at the cap on a character
|
|
113
|
+
// boundary, since a cut through a character would name no one; anything
|
|
114
|
+
// but a string is the empty label.
|
|
115
|
+
export function label(text) {
|
|
116
|
+
if (typeof text !== 'string') return '';
|
|
117
|
+
const { read } = new TextEncoder().encodeInto(text, new Uint8Array(MAX_NET_PLAYER_BYTES));
|
|
118
|
+
return text.slice(0, read);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Whether this is the backend's thread, which `attachClients` says: the
|
|
122
|
+
// client's surface is unavailable there, as on Dewdrop.
|
|
123
|
+
export const side = { backend: false };
|
|
124
|
+
|
|
125
|
+
// The `on<event>` handler properties of an EventTarget, as a browser
|
|
126
|
+
// defines them: set to a function, it listens; set to anything else, it
|
|
127
|
+
// stops.
|
|
128
|
+
export function defineHandlers(proto, types) {
|
|
129
|
+
const bound = new WeakMap();
|
|
130
|
+
for (const type of types) {
|
|
131
|
+
Object.defineProperty(proto, `on${type}`, {
|
|
132
|
+
configurable: true,
|
|
133
|
+
enumerable: true,
|
|
134
|
+
get() { return bound.get(this)?.[type]?.fn ?? null; },
|
|
135
|
+
set(fn) {
|
|
136
|
+
const mine = bound.get(this) ?? {};
|
|
137
|
+
bound.set(this, mine);
|
|
138
|
+
if (mine[type]) this.removeEventListener(type, mine[type].listener);
|
|
139
|
+
delete mine[type];
|
|
140
|
+
if (typeof fn !== 'function') return;
|
|
141
|
+
const listener = (event) => fn.call(this, event);
|
|
142
|
+
mine[type] = { fn, listener };
|
|
143
|
+
this.addEventListener(type, listener);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// An event about one client, as a backend hears them: a message event, as
|
|
150
|
+
// on Dewdrop, with `client` the number the backend knows the client by,
|
|
151
|
+
// `data` the message when there is one, and on a join `player`, the label
|
|
152
|
+
// the client joined under.
|
|
153
|
+
export class ClientEvent extends MessageEvent {
|
|
154
|
+
constructor(type, { client, data = null, player } = {}) {
|
|
155
|
+
super(type, { data });
|
|
156
|
+
this.client = client;
|
|
157
|
+
if (player !== undefined) this.player = player;
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/providers.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The providers Dewdrop carries a game's model calls to, each with the
|
|
2
|
+
// variable the dev server reads its key from and the header that carries
|
|
3
|
+
// the key, and the limits Dewdrop holds a call to.
|
|
4
|
+
|
|
5
|
+
export const PROVIDERS = [
|
|
6
|
+
{ host: 'api.openai.com', keyVar: 'OPENAI_API_KEY', header: 'authorization', prefix: 'Bearer ' },
|
|
7
|
+
{ host: 'api.anthropic.com', keyVar: 'ANTHROPIC_API_KEY', header: 'x-api-key', prefix: '' },
|
|
8
|
+
{ host: 'generativelanguage.googleapis.com', keyVar: 'GEMINI_API_KEY', header: 'x-goog-api-key', prefix: '' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
// The most bytes one call carries with its method, URL, headers, and body.
|
|
12
|
+
export const MAX_LLM_REQUEST_BYTES = 64 << 10;
|
|
13
|
+
// The most headers one call names.
|
|
14
|
+
export const MAX_LLM_HEADERS = 16;
|
|
15
|
+
// The most bytes of a provider's response body; a longer one fails whole.
|
|
16
|
+
export const MAX_LLM_RESPONSE_BYTES = 32 << 10;
|
|
17
|
+
// The most calls a game has outstanding at once.
|
|
18
|
+
export const MAX_LLM_INFLIGHT = 4;
|
|
19
|
+
// How long a call may take from sending to its last byte.
|
|
20
|
+
export const LLM_TIMEOUT_MS = 60_000;
|
package/src/route.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The contract between the package's two halves: the dev server route
|
|
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.
|
|
4
|
+
|
|
5
|
+
// The dev server route the local `llm.fetch` posts to, and the header
|
|
6
|
+
// every answer from it carries, so a page served without the route — a
|
|
7
|
+
// dev server missing the plugin, a static host — is told apart from a
|
|
8
|
+
// provider's own 404.
|
|
9
|
+
export const DEV_ROUTE = '/__dew/llm';
|
|
10
|
+
export const ROUTE_HEADER = 'x-dew-route';
|
|
11
|
+
export const NO_ROUTE = `no model-call route at ${DEV_ROUTE}: add the dew() plugin to vite.config.js, or run the game on Dewdrop`;
|
|
12
|
+
|
|
13
|
+
import { side } from './net.js';
|
|
14
|
+
|
|
15
|
+
// Where `llm.fetch` posts from where it runs: the page's own origin in a
|
|
16
|
+
// browser, and from the backend's thread the dev server's, which the
|
|
17
|
+
// plugin's host tells it; a host that could not learn it — a dev server
|
|
18
|
+
// in middleware mode with no `server.origin` — is said so.
|
|
19
|
+
export function routeUrl() {
|
|
20
|
+
if (typeof window !== 'undefined') return DEV_ROUTE;
|
|
21
|
+
const origin = process.env.DEW_DEV_ORIGIN ?? '';
|
|
22
|
+
if (side.backend && origin === '') {
|
|
23
|
+
throw new TypeError("the dev server's address is unknown to the backend: set server.origin in vite.config.js");
|
|
24
|
+
}
|
|
25
|
+
return origin + DEV_ROUTE;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The message a client sends without a channel to its backend: a page
|
|
29
|
+
// served without the plugin, or a build served by a static host.
|
|
30
|
+
export const NO_CHANNEL = 'no backend channel: add the dew() plugin to vite.config.js, or run the game on Dewdrop';
|
package/src/serve.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The dev server's backend host, its stand-in for Dewdrop. A thread runs
|
|
2
|
+
// the project's backend.js for each room the tabs join, from the room's
|
|
3
|
+
// first tab's join to its last tab's leave, the tabs numbered from one per
|
|
4
|
+
// room; each tab's frame of sends reaches its room's thread over the port
|
|
5
|
+
// with the tab's number, and each of the thread's sends reaches the tab it
|
|
6
|
+
// names, or every tab of the room, over the socket. An edit to the backend
|
|
7
|
+
// restarts every room and reloads the tabs, which join afresh. A backend
|
|
8
|
+
// that throws or ends on its own is stopped and the reason logged, and its
|
|
9
|
+
// room stays stopped until the next edit: restarting it would only throw
|
|
10
|
+
// again, and reloading the tabs at each throw would leave nothing to read
|
|
11
|
+
// the error in.
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, resolve } from 'node:path';
|
|
14
|
+
import { Worker } from 'node:worker_threads';
|
|
15
|
+
import { label } from './net.js';
|
|
16
|
+
|
|
17
|
+
// The files an edit to which restarts the backend: the entry and what it
|
|
18
|
+
// reaches through static relative imports.
|
|
19
|
+
export function backendFiles(entry) {
|
|
20
|
+
const files = new Set();
|
|
21
|
+
const visit = (file) => {
|
|
22
|
+
if (files.has(file) || !existsSync(file)) return;
|
|
23
|
+
files.add(file);
|
|
24
|
+
const text = readFileSync(file, 'utf8');
|
|
25
|
+
for (const match of text.matchAll(/^\s*(?:import|export)\b[^;'"]*?from\s+['"](\.\.?\/[^'"]+)['"]/gm)) {
|
|
26
|
+
visit(resolve(dirname(file), match[1]));
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
visit(entry);
|
|
30
|
+
return files;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Runs the backends at `entry` for the tabs that join over `ws`, the dev
|
|
34
|
+
// server's socket, one per room; `watcher` reports edits, `origin` is the
|
|
35
|
+
// dev server's address for a backend's model calls, and `log` hears why a
|
|
36
|
+
// backend ended.
|
|
37
|
+
export function createBackendHost({ entry, ws, watcher, origin = () => '', log = console }) {
|
|
38
|
+
// Read afresh at each start, so a file the backend imports since is watched too.
|
|
39
|
+
let files = backendFiles(entry);
|
|
40
|
+
// Each room's backend by the room's key, and each joined tab's room.
|
|
41
|
+
const rooms = new Map();
|
|
42
|
+
const tabs = new Map();
|
|
43
|
+
|
|
44
|
+
// One room's backend: its thread from the room's first tab's join to
|
|
45
|
+
// its last tab's leave, its tabs by number from one.
|
|
46
|
+
function createRoom(key) {
|
|
47
|
+
const name = key === '' ? 'the backend' : `the backend of room ${JSON.stringify(key)}`;
|
|
48
|
+
const members = new Map();
|
|
49
|
+
let worker = null;
|
|
50
|
+
let next = 1;
|
|
51
|
+
let broken = false;
|
|
52
|
+
|
|
53
|
+
function deliver(sends) {
|
|
54
|
+
for (const [client, number] of members) {
|
|
55
|
+
const items = sends.filter((send) => send.to === 0 || send.to === number);
|
|
56
|
+
if (items.length !== 0) client.send('dew:recv', { items });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function start() {
|
|
61
|
+
files = backendFiles(entry);
|
|
62
|
+
worker = new Worker(new URL('./backend-host.js', import.meta.url), { workerData: { entry, origin: origin() } });
|
|
63
|
+
const started = worker;
|
|
64
|
+
started.on('message', (message) => {
|
|
65
|
+
if (message.kind === 'send' && worker === started) deliver(message.sends);
|
|
66
|
+
});
|
|
67
|
+
started.on('error', (error) => {
|
|
68
|
+
if (worker === started) broke(`${name} threw: ${error.stack ?? error}`);
|
|
69
|
+
});
|
|
70
|
+
started.on('exit', (code) => {
|
|
71
|
+
if (worker === started) broke(`${name} ended with ${code}`);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Ends the thread and forgets every tab, which join afresh next time.
|
|
76
|
+
function stop() {
|
|
77
|
+
const ended = worker;
|
|
78
|
+
worker = null;
|
|
79
|
+
for (const client of members.keys()) tabs.delete(client);
|
|
80
|
+
members.clear();
|
|
81
|
+
next = 1;
|
|
82
|
+
if (ended !== null) ended.terminate();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A thread that threw or ended: stopped, said so, and not started
|
|
86
|
+
// again for a join until an edit lands.
|
|
87
|
+
function broke(reason) {
|
|
88
|
+
log.error(`[dewdrop] ${reason}; ${name} is stopped until it is edited`);
|
|
89
|
+
stop();
|
|
90
|
+
broken = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const room = {
|
|
94
|
+
join(client, player) {
|
|
95
|
+
if (broken) return;
|
|
96
|
+
const number = next++;
|
|
97
|
+
members.set(client, number);
|
|
98
|
+
tabs.set(client, room);
|
|
99
|
+
if (worker === null) start();
|
|
100
|
+
worker.postMessage([{ kind: 'join', client: number, player }]);
|
|
101
|
+
client.socket.once('close', () => host.leave(client));
|
|
102
|
+
},
|
|
103
|
+
leave(client) {
|
|
104
|
+
const number = members.get(client);
|
|
105
|
+
if (number === undefined) return;
|
|
106
|
+
members.delete(client);
|
|
107
|
+
tabs.delete(client);
|
|
108
|
+
if (members.size === 0) {
|
|
109
|
+
stop();
|
|
110
|
+
rooms.delete(key);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
worker.postMessage([{ kind: 'leave', client: number }]);
|
|
114
|
+
},
|
|
115
|
+
receive(client, items) {
|
|
116
|
+
const number = members.get(client);
|
|
117
|
+
if (number === undefined || worker === null) return;
|
|
118
|
+
worker.postMessage(items.map((item) => ({ kind: 'message', client: number, ...item })));
|
|
119
|
+
},
|
|
120
|
+
stop,
|
|
121
|
+
};
|
|
122
|
+
return room;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Ends every room's thread and forgets every tab.
|
|
126
|
+
function close() {
|
|
127
|
+
for (const room of rooms.values()) room.stop();
|
|
128
|
+
rooms.clear();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// An edit: every room's backend ends and the tabs reload, joining
|
|
132
|
+
// fresh ones.
|
|
133
|
+
function restart(reason) {
|
|
134
|
+
log.error(`[dewdrop] ${reason}; restarting the backend`);
|
|
135
|
+
close();
|
|
136
|
+
ws.send({ type: 'full-reload', path: '*' });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const host = {
|
|
140
|
+
// A tab joins the room its page named under the name it gave; a page
|
|
141
|
+
// that named neither joins the empty room unnamed.
|
|
142
|
+
join(client, { room = '', player = '' } = {}) {
|
|
143
|
+
if (tabs.has(client)) return;
|
|
144
|
+
const key = typeof room === 'string' ? room : '';
|
|
145
|
+
let it = rooms.get(key);
|
|
146
|
+
if (it === undefined) {
|
|
147
|
+
it = createRoom(key);
|
|
148
|
+
rooms.set(key, it);
|
|
149
|
+
}
|
|
150
|
+
it.join(client, label(player));
|
|
151
|
+
},
|
|
152
|
+
leave(client) {
|
|
153
|
+
tabs.get(client)?.leave(client);
|
|
154
|
+
},
|
|
155
|
+
receive(client, items) {
|
|
156
|
+
tabs.get(client)?.receive(client, items);
|
|
157
|
+
},
|
|
158
|
+
close,
|
|
159
|
+
};
|
|
160
|
+
ws.on('dew:join', (data, client) => host.join(client, { room: data?.room, player: data?.player }));
|
|
161
|
+
ws.on('dew:send', (data, client) => host.receive(client, data.items ?? []));
|
|
162
|
+
watcher.on('change', (file) => {
|
|
163
|
+
if (files.has(resolve(file))) restart(`${file} changed`);
|
|
164
|
+
});
|
|
165
|
+
return host;
|
|
166
|
+
}
|
package/src/vite.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The Vite plugin. For a Dewdrop build, `vite build --mode dew`: three and
|
|
2
|
+
// the SDK left as imports for Dewdrop to supply, one chunk of ES modules
|
|
3
|
+
// with dynamic imports folded in, written as `main.js` at the top of the
|
|
4
|
+
// output, no module preload, a root base, the files Vite emits under
|
|
5
|
+
// `ui/`, and the built page opened in the document by the entry; a game
|
|
6
|
+
// with a backend gets a second chunk beside it, `backend.js`. In dev: the
|
|
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
|
|
10
|
+
// imports nothing from Vite itself, so it works linked from a checkout as
|
|
11
|
+
// well as installed, on Vite 3 through 8.
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
import { ASSETS_DIR, BACKEND_FILE, ENTRY_FILE, PAGE_FILE, detectViteMajor, openPage, outputOptions } from './build.js';
|
|
15
|
+
import { createLlmMiddleware } from './dev.js';
|
|
16
|
+
import { loadKeys } from './env.js';
|
|
17
|
+
import { DEV_ROUTE } from './route.js';
|
|
18
|
+
import { createBackendHost } from './serve.js';
|
|
19
|
+
|
|
20
|
+
// The imports Dewdrop supplies, left in the output: three and the paths
|
|
21
|
+
// into it, and the SDK by its name. Dewdrop supplies nothing else, a path
|
|
22
|
+
// into the SDK included, so the build takes every other import in.
|
|
23
|
+
export function isRuntimeModule(id) {
|
|
24
|
+
return id === 'three' || id.startsWith('three/') || id === 'dewdrop-sdk';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEW_MODE = 'dew';
|
|
28
|
+
|
|
29
|
+
// Where a project's backend is: `backend.js` at the root, else
|
|
30
|
+
// `src/backend.js`, else none.
|
|
31
|
+
export function backendEntry(root) {
|
|
32
|
+
for (const name of [BACKEND_FILE, `src/${BACKEND_FILE}`]) {
|
|
33
|
+
const file = resolve(root, name);
|
|
34
|
+
if (existsSync(file)) return file;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function dew() {
|
|
40
|
+
let dewBuild = false;
|
|
41
|
+
let serving = false;
|
|
42
|
+
let entry = null;
|
|
43
|
+
return {
|
|
44
|
+
name: 'dew',
|
|
45
|
+
config(userConfig, env) {
|
|
46
|
+
const root = userConfig.root ?? process.cwd();
|
|
47
|
+
entry = backendEntry(root);
|
|
48
|
+
dewBuild = env.mode === DEW_MODE;
|
|
49
|
+
serving = env.command === 'serve';
|
|
50
|
+
if (!dewBuild) {
|
|
51
|
+
// Served as source, so the hot context the client's `backend`
|
|
52
|
+
// joins over is injected as in the game's own modules.
|
|
53
|
+
return env.command === 'serve' ? { optimizeDeps: { exclude: ['dewdrop-sdk'] } } : undefined;
|
|
54
|
+
}
|
|
55
|
+
const major = detectViteMajor(root);
|
|
56
|
+
const config = {
|
|
57
|
+
base: '/',
|
|
58
|
+
build: {
|
|
59
|
+
modulePreload: false,
|
|
60
|
+
assetsDir: ASSETS_DIR,
|
|
61
|
+
rollupOptions: {
|
|
62
|
+
external: isRuntimeModule,
|
|
63
|
+
output: outputOptions(major),
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
if (entry === null) return config;
|
|
68
|
+
// The backend is a second build of its own, since one chunk with its
|
|
69
|
+
// dynamic imports folded in is one input: an environment beside the
|
|
70
|
+
// client's, built after it into the same output, on the app builder
|
|
71
|
+
// Vite 6 brought.
|
|
72
|
+
if (major === undefined) throw new Error('a backend build needs Vite 6 or later, and no Vite was found under node_modules');
|
|
73
|
+
if (major < 6) throw new Error('a backend build needs Vite 6 or later');
|
|
74
|
+
config.environments = {
|
|
75
|
+
backend: {
|
|
76
|
+
build: {
|
|
77
|
+
emptyOutDir: false,
|
|
78
|
+
copyPublicDir: false,
|
|
79
|
+
rollupOptions: {
|
|
80
|
+
input: entry,
|
|
81
|
+
output: outputOptions(major, BACKEND_FILE),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
config.builder = {
|
|
87
|
+
async buildApp(builder) {
|
|
88
|
+
await builder.build(builder.environments.client);
|
|
89
|
+
await builder.build(builder.environments.backend);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
return config;
|
|
93
|
+
},
|
|
94
|
+
// The built page, its URLs rewritten and its stylesheets linked as Vite
|
|
95
|
+
// made it for a browser, is opened at the top of the entry. Last of the
|
|
96
|
+
// hooks, since Vite emits the page in its own generateBundle.
|
|
97
|
+
generateBundle: {
|
|
98
|
+
order: 'post',
|
|
99
|
+
handler(_options, bundle) {
|
|
100
|
+
if (!dewBuild) return;
|
|
101
|
+
const chunk = bundle[ENTRY_FILE];
|
|
102
|
+
if (chunk === undefined || chunk.type !== 'chunk') return;
|
|
103
|
+
const page = bundle[PAGE_FILE];
|
|
104
|
+
if (page?.type !== 'asset') return;
|
|
105
|
+
const html = page.source instanceof Uint8Array ? new TextDecoder().decode(page.source) : String(page.source);
|
|
106
|
+
chunk.code = openPage(html) + chunk.code;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
// The page the dev server serves is told whether the game has a
|
|
110
|
+
// backend, which the client's `backend` reads before it joins; a
|
|
111
|
+
// build's page, which no dev server serves, is left alone.
|
|
112
|
+
transformIndexHtml() {
|
|
113
|
+
if (!serving) return undefined;
|
|
114
|
+
return [{ tag: 'meta', attrs: { name: 'dew-backend', content: entry === null ? 'no' : 'yes' }, injectTo: 'head' }];
|
|
115
|
+
},
|
|
116
|
+
configureServer(server) {
|
|
117
|
+
// The keys come from the project's own .env files and the
|
|
118
|
+
// environment, every variable rather than the VITE_-prefixed ones a
|
|
119
|
+
// page may see.
|
|
120
|
+
const env = loadKeys(server.config.envDir ?? server.config.root, server.config.mode);
|
|
121
|
+
server.middlewares.use(DEV_ROUTE, createLlmMiddleware({ env }));
|
|
122
|
+
if (entry === null) return;
|
|
123
|
+
createBackendHost({
|
|
124
|
+
entry,
|
|
125
|
+
ws: server.ws,
|
|
126
|
+
watcher: server.watcher,
|
|
127
|
+
origin: () => server.resolvedUrls?.local?.[0]?.replace(/\/$/, '') ?? server.config.server?.origin ?? '',
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|