@jgengine/node 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
1
  # @jgengine/node
2
2
 
3
- Standalone authoritative game host for [JGengine](https://github.com/Noisemaker111/jgengine): in-memory server snapshots, tick loop, save-cadence flush, WebSocket server, memory/file persistence. AGPL-3.0-only.
3
+ Standalone authoritative game host for [JGengine](https://github.com/Noisemaker111/jgengine): in-memory server snapshots, tick loop, save-cadence flush, WebSocket server, memory/file persistence. Apache-2.0.
@@ -0,0 +1,32 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { SaveEndpointResponse } from "@jgengine/core/devtools/saveEndpoint";
3
+ /** Route the dev save middleware answers on; matches `installSaveEndpoint` defaults. */
4
+ export declare const SAVE_ENDPOINT_PATH = "/__jgengine/save";
5
+ /** Filename editor scene documents are saved under inside a game's `src/`. */
6
+ export declare const EDITOR_SCENE_FILENAME = "editor.scene.json";
7
+ type SrcDirResolver = (gameId: string) => string | null;
8
+ /** Applies one parsed save request against a resolved game `src/` directory. */
9
+ export declare function handleSaveRequest(resolveSrcDir: SrcDirResolver, body: string): SaveEndpointResponse;
10
+ interface DevSavePluginShape {
11
+ name: string;
12
+ apply: "serve";
13
+ configureServer(server: {
14
+ middlewares: {
15
+ use(route: string, handler: (req: IncomingMessage, res: ServerResponse, next: () => void) => void): void;
16
+ };
17
+ }): void;
18
+ }
19
+ /**
20
+ * Dev-only Vite middleware that gives the F2 chord family its write path:
21
+ * editor mode's Save (Ctrl+S / `save_scene`) writes `editor.scene.json` and
22
+ * debug mode's Tune "Save to source" rewrites tunable literals, both into the
23
+ * directory `resolveSrcDir(gameId)` returns.
24
+ */
25
+ export declare function devSavePlugin(resolveSrcDir: SrcDirResolver): DevSavePluginShape;
26
+ /**
27
+ * `devSavePlugin` preset for a standalone game project (one game, `src/` at
28
+ * the project root): every gameId maps to `<rootDir>/src`. Drop into the
29
+ * scaffolded `vite.config.ts` plugins array.
30
+ */
31
+ export declare function standaloneSavePlugin(rootDir?: string): DevSavePluginShape;
32
+ export {};
@@ -0,0 +1,142 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { rewriteTunableExport } from "@jgengine/core/devtools/rewriteTunables";
4
+ import { splitTunablePath } from "@jgengine/core/devtools/tunableSchema";
5
+ import { importEditorDocumentJson } from "@jgengine/core/editor/document";
6
+ /** Route the dev save middleware answers on; matches `installSaveEndpoint` defaults. */
7
+ export const SAVE_ENDPOINT_PATH = "/__jgengine/save";
8
+ /** Filename editor scene documents are saved under inside a game's `src/`. */
9
+ export const EDITOR_SCENE_FILENAME = "editor.scene.json";
10
+ const MAX_BODY_BYTES = 8 * 1024 * 1024;
11
+ function saveEditorDocument(dir, json) {
12
+ const document = importEditorDocumentJson(json);
13
+ const path = join(dir, EDITOR_SCENE_FILENAME);
14
+ writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`);
15
+ return { ok: true, path };
16
+ }
17
+ function listSourceFiles(dir) {
18
+ const out = [];
19
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
20
+ const full = join(dir, entry.name);
21
+ if (entry.isDirectory())
22
+ out.push(...listSourceFiles(full));
23
+ else if (/\.(ts|tsx)$/.test(entry.name) && !entry.name.includes(".test."))
24
+ out.push(full);
25
+ }
26
+ return out;
27
+ }
28
+ function moduleFileForTable(dir, table) {
29
+ if (table.includes(".."))
30
+ return null;
31
+ for (const extension of [".ts", ".tsx"]) {
32
+ const candidate = join(dir, `${table}${extension}`);
33
+ if (existsSync(candidate))
34
+ return candidate;
35
+ }
36
+ return null;
37
+ }
38
+ function fileExportingTable(files, table) {
39
+ const pattern = new RegExp(`export\\s+(?:const|let)\\s+${table.replace(/[$]/g, "\\$&")}\\b`);
40
+ for (const file of files) {
41
+ if (pattern.test(readFileSync(file, "utf8")))
42
+ return file;
43
+ }
44
+ return null;
45
+ }
46
+ function saveTunables(dir, deltas) {
47
+ const files = listSourceFiles(dir);
48
+ const pending = new Map();
49
+ const skipped = [];
50
+ let applied = 0;
51
+ for (const delta of deltas) {
52
+ const moduleFile = moduleFileForTable(dir, delta.table);
53
+ const file = moduleFile ?? fileExportingTable(files, delta.table);
54
+ if (file === null) {
55
+ skipped.push({ table: delta.table, key: delta.key, reason: "no source file found" });
56
+ continue;
57
+ }
58
+ const exportName = moduleFile !== null ? delta.key : delta.table;
59
+ const path = moduleFile !== null ? [] : splitTunablePath(delta.key);
60
+ const code = pending.get(file) ?? readFileSync(file, "utf8");
61
+ const rewritten = rewriteTunableExport(code, exportName, path, delta.value);
62
+ if (rewritten === null) {
63
+ skipped.push({ table: delta.table, key: delta.key, reason: "could not locate literal" });
64
+ continue;
65
+ }
66
+ pending.set(file, rewritten);
67
+ applied += 1;
68
+ }
69
+ for (const [file, code] of pending)
70
+ writeFileSync(file, code);
71
+ return { ok: true, applied, skipped };
72
+ }
73
+ function readBody(req) {
74
+ return new Promise((resolvePromise, reject) => {
75
+ let size = 0;
76
+ const chunks = [];
77
+ req.on("data", (chunk) => {
78
+ size += chunk.length;
79
+ if (size > MAX_BODY_BYTES) {
80
+ reject(new Error("request body too large"));
81
+ req.destroy();
82
+ return;
83
+ }
84
+ chunks.push(chunk);
85
+ });
86
+ req.on("end", () => resolvePromise(Buffer.concat(chunks).toString("utf8")));
87
+ req.on("error", reject);
88
+ });
89
+ }
90
+ /** Applies one parsed save request against a resolved game `src/` directory. */
91
+ export function handleSaveRequest(resolveSrcDir, body) {
92
+ const request = JSON.parse(body);
93
+ const dir = resolveSrcDir(request.gameId);
94
+ if (dir === null)
95
+ return { ok: false, error: `unknown game: ${request.gameId}` };
96
+ if (request.kind === "editor-document")
97
+ return saveEditorDocument(dir, request.json);
98
+ if (request.kind === "tunables")
99
+ return saveTunables(dir, request.deltas);
100
+ return { ok: false, error: "unknown save kind" };
101
+ }
102
+ /**
103
+ * Dev-only Vite middleware that gives the F2 chord family its write path:
104
+ * editor mode's Save (Ctrl+S / `save_scene`) writes `editor.scene.json` and
105
+ * debug mode's Tune "Save to source" rewrites tunable literals, both into the
106
+ * directory `resolveSrcDir(gameId)` returns.
107
+ */
108
+ export function devSavePlugin(resolveSrcDir) {
109
+ return {
110
+ name: "jgengine-dev-save",
111
+ apply: "serve",
112
+ configureServer(server) {
113
+ server.middlewares.use(SAVE_ENDPOINT_PATH, (req, res, next) => {
114
+ if (req.method !== "POST") {
115
+ next();
116
+ return;
117
+ }
118
+ void readBody(req)
119
+ .then((body) => {
120
+ const response = handleSaveRequest(resolveSrcDir, body);
121
+ res.statusCode = response.ok ? 200 : 400;
122
+ res.setHeader("content-type", "application/json");
123
+ res.end(JSON.stringify(response));
124
+ })
125
+ .catch((error) => {
126
+ res.statusCode = 400;
127
+ res.setHeader("content-type", "application/json");
128
+ res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
129
+ });
130
+ });
131
+ },
132
+ };
133
+ }
134
+ /**
135
+ * `devSavePlugin` preset for a standalone game project (one game, `src/` at
136
+ * the project root): every gameId maps to `<rootDir>/src`. Drop into the
137
+ * scaffolded `vite.config.ts` plugins array.
138
+ */
139
+ export function standaloneSavePlugin(rootDir = process.cwd()) {
140
+ const srcDir = resolve(rootDir, "src");
141
+ return devSavePlugin(() => (existsSync(srcDir) ? srcDir : null));
142
+ }
@@ -0,0 +1,43 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ /** One placeable model the standalone editor lists — a stable id and a URL the dev server serves it from. */
3
+ export interface EditorManifestAsset {
4
+ id: string;
5
+ url: string;
6
+ label: string;
7
+ }
8
+ /** What the standalone editor loads on boot: the on-disk scene document (if any) and every model in the asset folder. */
9
+ export interface EditorManifest {
10
+ scene: unknown | null;
11
+ assets: EditorManifestAsset[];
12
+ }
13
+ /**
14
+ * Reads the scene document and scans the asset folder — the pure core the dev server serves at `/__jgengine/manifest`.
15
+ * @internal
16
+ */
17
+ export declare function buildEditorManifest(sceneDir: string, assetsDir: string): EditorManifest;
18
+ interface DevServerLike {
19
+ middlewares: {
20
+ use(route: string, handler: (req: IncomingMessage, res: ServerResponse, next: () => void) => void): void;
21
+ };
22
+ }
23
+ interface EditorHostPluginShape {
24
+ name: string;
25
+ apply: "serve";
26
+ configureServer(server: DevServerLike): void;
27
+ }
28
+ /** Where the standalone editor reads and writes: the scene folder and the model folder it scans. */
29
+ export interface EditorHostOptions {
30
+ /** Directory the scene document lives in and Save writes back to. Default `process.cwd()`. */
31
+ dir?: string;
32
+ /** Directory scanned for placeable `.glb`/`.gltf` models. Default `<dir>/assets` if present, else `<dir>`. */
33
+ assetsDir?: string;
34
+ }
35
+ /**
36
+ * Dev-server plugin that turns any folder into a standalone-editor workspace: it serves the folder's
37
+ * `editor.scene.json` and every model under it at `/__jgengine/manifest`, streams the model bytes at
38
+ * `/__jgengine/assets/*`, and writes Save back to `editor.scene.json` — the server half of
39
+ * `jgengine editor`, drop-in for a Vite dev config.
40
+ * @internal
41
+ */
42
+ export declare function editorHostPlugin(options?: EditorHostOptions): EditorHostPluginShape;
43
+ export {};
@@ -0,0 +1,85 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { extname, join, relative, resolve, sep } from "node:path";
3
+ import { EDITOR_SCENE_FILENAME, devSavePlugin } from "./devSavePlugin.js";
4
+ const MODEL_EXT = /\.(glb|gltf)$/i;
5
+ const ASSET_ROUTE = "/__jgengine/assets/";
6
+ const MANIFEST_ROUTE = "/__jgengine/manifest";
7
+ function assetIdFromRel(rel) {
8
+ const cleaned = rel
9
+ .replace(MODEL_EXT, "")
10
+ .replace(/[^a-z0-9]+/gi, "-")
11
+ .replace(/^-+|-+$/g, "")
12
+ .toLowerCase();
13
+ return cleaned.length > 0 ? cleaned : "asset";
14
+ }
15
+ function walkModels(root, dir = root) {
16
+ if (!existsSync(dir))
17
+ return [];
18
+ const out = [];
19
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
20
+ const full = join(dir, entry.name);
21
+ if (entry.isDirectory())
22
+ out.push(...walkModels(root, full));
23
+ else if (MODEL_EXT.test(entry.name))
24
+ out.push(relative(root, full));
25
+ }
26
+ return out.sort();
27
+ }
28
+ /**
29
+ * Reads the scene document and scans the asset folder — the pure core the dev server serves at `/__jgengine/manifest`.
30
+ * @internal
31
+ */
32
+ export function buildEditorManifest(sceneDir, assetsDir) {
33
+ const scenePath = join(sceneDir, EDITOR_SCENE_FILENAME);
34
+ let scene = null;
35
+ if (existsSync(scenePath)) {
36
+ try {
37
+ scene = JSON.parse(readFileSync(scenePath, "utf8"));
38
+ }
39
+ catch {
40
+ scene = null;
41
+ }
42
+ }
43
+ const assets = walkModels(assetsDir).map((rel) => {
44
+ const web = rel.split(sep).join("/");
45
+ return { id: assetIdFromRel(web), url: ASSET_ROUTE + web, label: web };
46
+ });
47
+ return { scene, assets };
48
+ }
49
+ /**
50
+ * Dev-server plugin that turns any folder into a standalone-editor workspace: it serves the folder's
51
+ * `editor.scene.json` and every model under it at `/__jgengine/manifest`, streams the model bytes at
52
+ * `/__jgengine/assets/*`, and writes Save back to `editor.scene.json` — the server half of
53
+ * `jgengine editor`, drop-in for a Vite dev config.
54
+ * @internal
55
+ */
56
+ export function editorHostPlugin(options = {}) {
57
+ const dir = resolve(options.dir ?? process.cwd());
58
+ const assetsDir = resolve(options.assetsDir ?? (existsSync(join(dir, "assets")) ? join(dir, "assets") : dir));
59
+ const save = devSavePlugin(() => dir);
60
+ return {
61
+ name: "jgengine-editor-host",
62
+ apply: "serve",
63
+ configureServer(server) {
64
+ save.configureServer(server);
65
+ server.middlewares.use(MANIFEST_ROUTE, (req, res, next) => {
66
+ if (req.method !== "GET") {
67
+ next();
68
+ return;
69
+ }
70
+ res.setHeader("content-type", "application/json");
71
+ res.end(JSON.stringify(buildEditorManifest(dir, assetsDir)));
72
+ });
73
+ server.middlewares.use(ASSET_ROUTE, (req, res, next) => {
74
+ const rel = decodeURIComponent((req.url ?? "").split("?")[0]).replace(/^\/+/, "");
75
+ const full = resolve(assetsDir, rel);
76
+ if (!full.startsWith(assetsDir) || !existsSync(full) || !statSync(full).isFile()) {
77
+ next();
78
+ return;
79
+ }
80
+ res.setHeader("content-type", extname(full).toLowerCase() === ".glb" ? "model/gltf-binary" : "model/gltf+json");
81
+ res.end(readFileSync(full));
82
+ });
83
+ },
84
+ };
85
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./host.js";
2
2
  export * from "./persistence.js";
3
+ export * from "./shutdown.js";
3
4
  export * from "./socketIoServer.js";
4
5
  export * from "./webHandler.js";
5
6
  export * from "./worldServer.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./host.js";
2
2
  export * from "./persistence.js";
3
+ export * from "./shutdown.js";
3
4
  export * from "./socketIoServer.js";
4
5
  export * from "./webHandler.js";
5
6
  export * from "./worldServer.js";
@@ -1,4 +1,21 @@
1
1
  import type { HostPersistence } from "@jgengine/core/runtime/hostPersistence";
2
+ import { type HostedWorldStore } from "@jgengine/core/runtime/hostedWorldSession";
2
3
  export { memoryPersistence } from "@jgengine/ws/host";
4
+ /** Per-world key a {@link WorldPersistence} resolves a {@link HostedWorldStore} for. */
5
+ export interface WorldPersistenceKey {
6
+ gameId: string;
7
+ serverId: string;
8
+ }
9
+ /**
10
+ * The persistence plug-point for {@link createWorldGameServer}: resolves one {@link HostedWorldStore}
11
+ * per hosted world, called once when the world host session is created. Structural — a SQL, file, or
12
+ * Convex-backed store all conform without `node` importing a concrete driver; only a `store()` factory
13
+ * is required. Mirrors `HostPersistence` (the reducer host's persistence seam).
14
+ */
15
+ export interface WorldPersistence {
16
+ store(key: WorldPersistenceKey): HostedWorldStore;
17
+ }
18
+ /** Default {@link WorldPersistence}: an isolated in-memory {@link HostedWorldStore} per `gameId`/`serverId`, lost on process exit. */
19
+ export declare function memoryWorldPersistence(): WorldPersistence;
3
20
  export declare function filePersistence(dir: string, now?: () => number): HostPersistence;
4
21
  export declare function clearFilePersistence(dir: string): Promise<void>;
@@ -2,7 +2,23 @@ import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { applyLeaderboardRows, leaderboardRowKey, profileLeaderboardStats, topLeaderboardRows, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
5
+ import { memoryWorldStore } from "@jgengine/core/runtime/hostedWorldSession";
5
6
  export { memoryPersistence } from "@jgengine/ws/host";
7
+ /** Default {@link WorldPersistence}: an isolated in-memory {@link HostedWorldStore} per `gameId`/`serverId`, lost on process exit. */
8
+ export function memoryWorldPersistence() {
9
+ const stores = new Map();
10
+ return {
11
+ store({ gameId, serverId }) {
12
+ const key = `${gameId} ${serverId}`;
13
+ const existing = stores.get(key);
14
+ if (existing !== undefined)
15
+ return existing;
16
+ const created = memoryWorldStore();
17
+ stores.set(key, created);
18
+ return created;
19
+ },
20
+ };
21
+ }
6
22
  function isMissing(error) {
7
23
  return error?.code === "ENOENT";
8
24
  }
@@ -26,7 +42,11 @@ async function stageJson(path, value) {
26
42
  async function writeJson(path, value) {
27
43
  await rename(await stageJson(path, value), path);
28
44
  }
29
- const enc = encodeURIComponent;
45
+ function encodeSegment(key) {
46
+ const encoded = encodeURIComponent(key);
47
+ return encoded === "." || encoded === ".." ? encoded.replaceAll(".", "%2E") : encoded;
48
+ }
49
+ const enc = encodeSegment;
30
50
  export function filePersistence(dir, now = Date.now) {
31
51
  const serversDir = join(dir, "servers");
32
52
  const profilesDir = join(dir, "profiles");
@@ -130,8 +150,15 @@ export function filePersistence(dir, now = Date.now) {
130
150
  await writeJson(path, entries);
131
151
  return entries;
132
152
  });
133
- feedLocks.set(path, run.catch(() => undefined));
134
- return run;
153
+ const tracked = run.catch(() => undefined);
154
+ feedLocks.set(path, tracked);
155
+ try {
156
+ return await run;
157
+ }
158
+ finally {
159
+ if (feedLocks.get(path) === tracked)
160
+ feedLocks.delete(path);
161
+ }
135
162
  },
136
163
  async applyLeaderboardIncrements(gameId, entries) {
137
164
  const run = leaderboardLock.then(async () => {
@@ -0,0 +1,23 @@
1
+ /** A live signal listener installed by {@link installShutdownHook}; call `remove()` to uninstall it (tests, embedders opting out). */
2
+ export interface ShutdownHook {
3
+ remove(): void;
4
+ }
5
+ /** Config for {@link installShutdownHook}. */
6
+ export interface InstallShutdownHookOptions {
7
+ /** Signals to listen for; default `["SIGINT", "SIGTERM"]`. */
8
+ signals?: readonly NodeJS.Signals[];
9
+ /** Bound on how long `shutdown()` may run before the hook gives up and exits anyway; default 5000ms. */
10
+ timeoutMs?: number;
11
+ /** Called with `0` on a clean shutdown, `1` on error or timeout; default `process.exit`. Override in tests to avoid killing the test process. */
12
+ exit?: (code: number) => void;
13
+ /** Called when `shutdown()` rejects or times out; default logs to `console.error`. */
14
+ onError?: (error: unknown) => void;
15
+ }
16
+ /**
17
+ * Wires `SIGINT`/`SIGTERM` (or a custom signal list) to a clean-shutdown callback — e.g.
18
+ * `() => worldServer.close()` or `() => Promise.all([wsServer.close(), host.stop()])`. Bounded by
19
+ * `timeoutMs` so a stuck flush can't hang the process forever; idempotent — a second signal delivered
20
+ * mid-shutdown reuses the same in-flight run instead of flushing twice. Returns a {@link ShutdownHook}
21
+ * whose `remove()` uninstalls the listeners, for tests and embedders that want their own handling.
22
+ */
23
+ export declare function installShutdownHook(shutdown: () => Promise<void> | void, options?: InstallShutdownHookOptions): ShutdownHook;
@@ -0,0 +1,50 @@
1
+ const DEFAULT_SIGNALS = ["SIGINT", "SIGTERM"];
2
+ const DEFAULT_TIMEOUT_MS = 5_000;
3
+ /**
4
+ * Wires `SIGINT`/`SIGTERM` (or a custom signal list) to a clean-shutdown callback — e.g.
5
+ * `() => worldServer.close()` or `() => Promise.all([wsServer.close(), host.stop()])`. Bounded by
6
+ * `timeoutMs` so a stuck flush can't hang the process forever; idempotent — a second signal delivered
7
+ * mid-shutdown reuses the same in-flight run instead of flushing twice. Returns a {@link ShutdownHook}
8
+ * whose `remove()` uninstalls the listeners, for tests and embedders that want their own handling.
9
+ */
10
+ export function installShutdownHook(shutdown, options = {}) {
11
+ const signals = options.signals ?? DEFAULT_SIGNALS;
12
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
13
+ const exit = options.exit ?? ((code) => process.exit(code));
14
+ const onError = options.onError ?? ((error) => console.error("shutdown failed:", error));
15
+ let inFlight = null;
16
+ function run() {
17
+ inFlight ??= (async () => {
18
+ let timer;
19
+ try {
20
+ await Promise.race([
21
+ Promise.resolve(shutdown()),
22
+ new Promise((_resolve, reject) => {
23
+ timer = setTimeout(() => reject(new Error(`shutdown exceeded ${timeoutMs}ms`)), timeoutMs);
24
+ }),
25
+ ]);
26
+ exit(0);
27
+ }
28
+ catch (error) {
29
+ onError(error);
30
+ exit(1);
31
+ }
32
+ finally {
33
+ if (timer !== undefined)
34
+ clearTimeout(timer);
35
+ }
36
+ })();
37
+ return inFlight;
38
+ }
39
+ function handler() {
40
+ void run();
41
+ }
42
+ for (const signal of signals)
43
+ process.on(signal, handler);
44
+ return {
45
+ remove() {
46
+ for (const signal of signals)
47
+ process.off(signal, handler);
48
+ },
49
+ };
50
+ }
@@ -2,6 +2,7 @@ import type { GameDefinition } from "@jgengine/core/game/defineGame";
2
2
  import type { GameContextContent } from "@jgengine/core/runtime/gameContext";
3
3
  import type { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
4
4
  import { type WorldGameHost } from "@jgengine/ws/worldHost";
5
+ import { type WorldPersistence } from "./persistence.js";
5
6
  import { type GameWsServer, type GameWsServerOptions } from "./wsServer.js";
6
7
  /** A game the world server can host — its authoritative {@link GameDefinition} and the content lookup a `GameContext` reads. */
7
8
  export interface HostedGameDefinition {
@@ -14,6 +15,8 @@ export interface WorldGameServerOptions extends Omit<GameWsServerOptions, "host"
14
15
  resolveGame(gameId: string): HostedGameDefinition | null;
15
16
  /** Ticks per second for the real-clock loop {@link WorldGameServer.start} drives; default 30. Tests call {@link WorldGameServer.tick} manually instead. */
16
17
  tickHz?: number;
18
+ /** Where each hosted world's authoritative state persists; defaults to an isolated in-memory store per world (lost on exit). Inject a file/SQL/Convex-backed {@link WorldPersistence} to survive a redeploy or crash. */
19
+ persistence?: WorldPersistence;
17
20
  }
18
21
  /** A runnable ws host for GameContext worlds: {@link createWorldGameHost} + {@link createGameWsServer} + a tick loop, with a manual `tick(dt)` seam so a fake clock can drive it in tests. */
19
22
  export interface WorldGameServer {
@@ -25,7 +28,9 @@ export interface WorldGameServer {
25
28
  start(): void;
26
29
  /** Stop the tick interval (idempotent). */
27
30
  stop(): void;
28
- /** Stop the tick loop and tear down the ws server. */
31
+ /** Force-persist every live world's current state via the injected {@link WorldPersistence} (idempotent — safe to call repeatedly). */
32
+ flush(): void;
33
+ /** Stop the tick loop, flush persistence, and tear down the ws server — the clean-shutdown path for a SIGINT/SIGTERM handler. */
29
34
  close(): Promise<void>;
30
35
  /** The bound ws port. */
31
36
  port(): number;
@@ -1,18 +1,27 @@
1
1
  import { createHostedWorldSession } from "@jgengine/core/runtime/hostedWorldSession";
2
2
  import { createWorldGameHost } from "@jgengine/ws/worldHost";
3
+ import { memoryWorldPersistence } from "./persistence.js";
3
4
  import { createGameWsServer } from "./wsServer.js";
4
5
  const DEFAULT_TICK_HZ = 30;
5
6
  /** Build a {@link WorldGameServer} — one process hosting authoritative GameContext worlds over ws, ready for two-client play once {@link WorldGameServer.start} runs. */
6
7
  export function createWorldGameServer(options) {
7
- const { resolveGame, tickHz = DEFAULT_TICK_HZ, ...serverOptions } = options;
8
+ const { resolveGame, tickHz = DEFAULT_TICK_HZ, persistence = memoryWorldPersistence(), ...serverOptions } = options;
8
9
  const clock = options.now ?? (() => Date.now());
10
+ const liveSessions = new Map();
9
11
  const host = createWorldGameHost({
10
12
  now: clock,
11
- session: ({ gameId }) => {
13
+ session: ({ gameId, serverId }) => {
12
14
  const resolved = resolveGame(gameId);
13
15
  if (resolved === null)
14
16
  return null;
15
- return createHostedWorldSession({ definition: resolved.game, content: resolved.content, now: clock });
17
+ const session = createHostedWorldSession({
18
+ definition: resolved.game,
19
+ content: resolved.content,
20
+ now: clock,
21
+ store: persistence.store({ gameId, serverId }),
22
+ });
23
+ liveSessions.set(serverId, session);
24
+ return session;
16
25
  },
17
26
  });
18
27
  const ws = createGameWsServer({ ...serverOptions, host });
@@ -27,11 +36,16 @@ export function createWorldGameServer(options) {
27
36
  interval = null;
28
37
  }
29
38
  }
39
+ function flush() {
40
+ for (const session of liveSessions.values())
41
+ session.save();
42
+ }
30
43
  return {
31
44
  host,
32
45
  ws,
33
46
  tick,
34
47
  stop,
48
+ flush,
35
49
  port: ws.port,
36
50
  start() {
37
51
  if (interval !== null)
@@ -45,6 +59,7 @@ export function createWorldGameServer(options) {
45
59
  },
46
60
  async close() {
47
61
  stop();
62
+ flush();
48
63
  await ws.close();
49
64
  },
50
65
  };
@@ -2,10 +2,22 @@ import type { Server as HttpServer } from "node:http";
2
2
  import { WebSocketServer } from "ws";
3
3
  import { type HostRouterOptions, type RewoundPosition } from "@jgengine/ws/hostRouter";
4
4
  export type { RewoundPosition };
5
+ /** Default per-message payload cap (bytes) — `ws` closes the socket with 1009 past this. */
6
+ export declare const DEFAULT_MAX_PAYLOAD_BYTES = 1048576;
7
+ /** Default max concurrent sockets this server accepts before rejecting new ones. */
8
+ export declare const DEFAULT_MAX_CONNECTIONS = 10000;
9
+ /** Default ping/pong interval; a socket that misses one round-trip is terminated. */
10
+ export declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
5
11
  export type GameWsServerOptions = HostRouterOptions & {
6
12
  server?: HttpServer;
7
13
  port?: number;
8
14
  path?: string;
15
+ /** Per-message payload cap in bytes. Defaults to {@link DEFAULT_MAX_PAYLOAD_BYTES}. */
16
+ maxPayloadBytes?: number;
17
+ /** Max concurrent sockets accepted; connections beyond this are closed immediately. Defaults to {@link DEFAULT_MAX_CONNECTIONS}. */
18
+ maxConnections?: number;
19
+ /** Ping/pong heartbeat interval in ms; a socket that misses a round-trip is terminated. Defaults to {@link DEFAULT_HEARTBEAT_INTERVAL_MS}. */
20
+ heartbeatIntervalMs?: number;
9
21
  };
10
22
  export type GameWsServer = {
11
23
  wss: WebSocketServer;
package/dist/wsServer.js CHANGED
@@ -1,11 +1,40 @@
1
1
  import { WebSocketServer } from "ws";
2
2
  import { createHostRouter } from "@jgengine/ws/hostRouter";
3
+ /** Default per-message payload cap (bytes) — `ws` closes the socket with 1009 past this. */
4
+ export const DEFAULT_MAX_PAYLOAD_BYTES = 1_048_576;
5
+ /** Default max concurrent sockets this server accepts before rejecting new ones. */
6
+ export const DEFAULT_MAX_CONNECTIONS = 10_000;
7
+ /** Default ping/pong interval; a socket that misses one round-trip is terminated. */
8
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
3
9
  export function createGameWsServer(options) {
4
10
  const router = createHostRouter(options);
11
+ const maxPayload = options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
12
+ const maxConnections = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
13
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
5
14
  const wss = options.server !== undefined
6
- ? new WebSocketServer({ server: options.server, path: options.path })
7
- : new WebSocketServer({ port: options.port ?? 0, path: options.path });
15
+ ? new WebSocketServer({ server: options.server, path: options.path, maxPayload })
16
+ : new WebSocketServer({ port: options.port ?? 0, path: options.path, maxPayload });
17
+ const heartbeat = setInterval(() => {
18
+ for (const client of wss.clients) {
19
+ const socket = client;
20
+ if (socket.isAlive === false) {
21
+ socket.terminate();
22
+ continue;
23
+ }
24
+ socket.isAlive = false;
25
+ socket.ping();
26
+ }
27
+ }, heartbeatIntervalMs);
28
+ wss.on("close", () => clearInterval(heartbeat));
8
29
  wss.on("connection", (socket) => {
30
+ if (wss.clients.size > maxConnections) {
31
+ socket.close(1013, "Server at capacity");
32
+ return;
33
+ }
34
+ socket.isAlive = true;
35
+ socket.on("pong", () => {
36
+ socket.isAlive = true;
37
+ });
9
38
  const connection = router.connect({
10
39
  send: (data) => {
11
40
  if (socket.readyState !== socket.OPEN)
@@ -28,6 +57,7 @@ export function createGameWsServer(options) {
28
57
  },
29
58
  rewind: router.rewind,
30
59
  close: () => new Promise((resolve) => {
60
+ clearInterval(heartbeat);
31
61
  router.close();
32
62
  for (const client of wss.clients) {
33
63
  client.terminate();