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 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
+ }
@@ -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
+ }