@colyseus/playground 0.17.12 → 0.18.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.
@@ -0,0 +1,72 @@
1
+ // src-backend/serve-static.ts
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ var MIME = {
5
+ ".html": "text/html; charset=utf-8",
6
+ ".js": "application/javascript; charset=utf-8",
7
+ ".mjs": "application/javascript; charset=utf-8",
8
+ ".css": "text/css; charset=utf-8",
9
+ ".json": "application/json; charset=utf-8",
10
+ ".svg": "image/svg+xml",
11
+ ".png": "image/png",
12
+ ".jpg": "image/jpeg",
13
+ ".jpeg": "image/jpeg",
14
+ ".gif": "image/gif",
15
+ ".ico": "image/x-icon",
16
+ ".woff": "font/woff",
17
+ ".woff2": "font/woff2",
18
+ ".ttf": "font/ttf",
19
+ ".map": "application/json; charset=utf-8",
20
+ ".txt": "text/plain; charset=utf-8"
21
+ };
22
+ async function serveStatic(root, relPath) {
23
+ const safe = sanitize(relPath ?? "");
24
+ const filePath = safe ? path.join(root, safe) : path.join(root, "index.html");
25
+ const resolved = path.resolve(filePath);
26
+ const resolvedRoot = path.resolve(root);
27
+ if (!resolved.startsWith(resolvedRoot + path.sep) && resolved !== resolvedRoot) {
28
+ return new Response("forbidden", { status: 403 });
29
+ }
30
+ const data = await tryRead(resolved);
31
+ if (data) {
32
+ return fileResponse(data, mimeOf(resolved));
33
+ }
34
+ if (!path.extname(safe)) {
35
+ const index = await tryRead(path.join(resolvedRoot, "index.html"));
36
+ if (index) {
37
+ return fileResponse(index, "text/html; charset=utf-8");
38
+ }
39
+ }
40
+ return new Response("not found", { status: 404 });
41
+ }
42
+ function sanitize(p) {
43
+ return p.replace(/^\/+/, "").split("/").filter((seg) => seg && seg !== ".." && seg !== ".").join("/");
44
+ }
45
+ function mimeOf(filePath) {
46
+ return MIME[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
47
+ }
48
+ async function tryRead(filePath) {
49
+ try {
50
+ const stat = await fs.stat(filePath);
51
+ if (!stat.isFile()) {
52
+ return null;
53
+ }
54
+ return await fs.readFile(filePath);
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+ function fileResponse(buf, contentType) {
60
+ return new Response(new Uint8Array(buf), {
61
+ status: 200,
62
+ headers: {
63
+ "content-type": contentType,
64
+ "content-length": String(buf.length),
65
+ "cache-control": "no-cache",
66
+ "connection": "close"
67
+ }
68
+ });
69
+ }
70
+ export {
71
+ serveStatic
72
+ };
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src-backend/serve-static.ts"],
4
+ "sourcesContent": ["// Static-file helper for the built playground SPA. Mirrors the helper used by\n// @colyseus/admin (kept duplicated for now \u2014 both consumers are framework-owned\n// and the helper is small + stable).\nimport fs from 'fs/promises';\nimport path from 'path';\n\nconst MIME: Record<string, string> = {\n '.html': 'text/html; charset=utf-8',\n '.js': 'application/javascript; charset=utf-8',\n '.mjs': 'application/javascript; charset=utf-8',\n '.css': 'text/css; charset=utf-8',\n '.json': 'application/json; charset=utf-8',\n '.svg': 'image/svg+xml',\n '.png': 'image/png',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.gif': 'image/gif',\n '.ico': 'image/x-icon',\n '.woff': 'font/woff',\n '.woff2': 'font/woff2',\n '.ttf': 'font/ttf',\n '.map': 'application/json; charset=utf-8',\n '.txt': 'text/plain; charset=utf-8',\n};\n\nexport async function serveStatic(root: string, relPath: string | undefined): Promise<Response> {\n const safe = sanitize(relPath ?? '');\n const filePath = safe ? path.join(root, safe) : path.join(root, 'index.html');\n\n const resolved = path.resolve(filePath);\n const resolvedRoot = path.resolve(root);\n if (!resolved.startsWith(resolvedRoot + path.sep) && resolved !== resolvedRoot) {\n return new Response('forbidden', { status: 403 });\n }\n\n const data = await tryRead(resolved);\n if (data) { return fileResponse(data, mimeOf(resolved)); }\n\n // SPA fallback \u2014 serve index.html for any non-asset request\n if (!path.extname(safe)) {\n const index = await tryRead(path.join(resolvedRoot, 'index.html'));\n if (index) { return fileResponse(index, 'text/html; charset=utf-8'); }\n }\n\n return new Response('not found', { status: 404 });\n}\n\nfunction sanitize(p: string): string {\n return p.replace(/^\\/+/, '').split('/').filter((seg) => seg && seg !== '..' && seg !== '.').join('/');\n}\n\nfunction mimeOf(filePath: string): string {\n return MIME[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';\n}\n\nasync function tryRead(filePath: string): Promise<Buffer | null> {\n try {\n const stat = await fs.stat(filePath);\n if (!stat.isFile()) { return null; }\n return await fs.readFile(filePath);\n } catch {\n return null;\n }\n}\n\nfunction fileResponse(buf: Buffer, contentType: string): Response {\n return new Response(new Uint8Array(buf) as any, {\n status: 200,\n headers: {\n 'content-type': contentType,\n 'content-length': String(buf.length),\n 'cache-control': 'no-cache',\n 'connection': 'close',\n },\n });\n}\n"],
5
+ "mappings": ";AAGA,OAAO,QAAQ;AACf,OAAO,UAAU;AAEjB,IAAM,OAA+B;AAAA,EACnC,SAAS;AAAA,EACT,OAAS;AAAA,EACT,QAAS;AAAA,EACT,QAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAS;AAAA,EACT,QAAS;AAAA,EACT,QAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAS;AAAA,EACT,QAAS;AAAA,EACT,SAAS;AAAA,EACT,UAAU;AAAA,EACV,QAAS;AAAA,EACT,QAAS;AAAA,EACT,QAAS;AACX;AAEA,eAAsB,YAAY,MAAc,SAAgD;AAC9F,QAAM,OAAO,SAAS,WAAW,EAAE;AACnC,QAAM,WAAW,OAAO,KAAK,KAAK,MAAM,IAAI,IAAI,KAAK,KAAK,MAAM,YAAY;AAE5E,QAAM,WAAW,KAAK,QAAQ,QAAQ;AACtC,QAAM,eAAe,KAAK,QAAQ,IAAI;AACtC,MAAI,CAAC,SAAS,WAAW,eAAe,KAAK,GAAG,KAAK,aAAa,cAAc;AAC9E,WAAO,IAAI,SAAS,aAAa,EAAE,QAAQ,IAAI,CAAC;AAAA,EAClD;AAEA,QAAM,OAAO,MAAM,QAAQ,QAAQ;AACnC,MAAI,MAAM;AAAE,WAAO,aAAa,MAAM,OAAO,QAAQ,CAAC;AAAA,EAAG;AAGzD,MAAI,CAAC,KAAK,QAAQ,IAAI,GAAG;AACvB,UAAM,QAAQ,MAAM,QAAQ,KAAK,KAAK,cAAc,YAAY,CAAC;AACjE,QAAI,OAAO;AAAE,aAAO,aAAa,OAAO,0BAA0B;AAAA,IAAG;AAAA,EACvE;AAEA,SAAO,IAAI,SAAS,aAAa,EAAE,QAAQ,IAAI,CAAC;AAClD;AAEA,SAAS,SAAS,GAAmB;AACnC,SAAO,EAAE,QAAQ,QAAQ,EAAE,EAAE,MAAM,GAAG,EAAE,OAAO,CAAC,QAAQ,OAAO,QAAQ,QAAQ,QAAQ,GAAG,EAAE,KAAK,GAAG;AACtG;AAEA,SAAS,OAAO,UAA0B;AACxC,SAAO,KAAK,KAAK,QAAQ,QAAQ,EAAE,YAAY,CAAC,KAAK;AACvD;AAEA,eAAe,QAAQ,UAA0C;AAC/D,MAAI;AACF,UAAM,OAAO,MAAM,GAAG,KAAK,QAAQ;AACnC,QAAI,CAAC,KAAK,OAAO,GAAG;AAAE,aAAO;AAAA,IAAM;AACnC,WAAO,MAAM,GAAG,SAAS,QAAQ;AAAA,EACnC,QAAE;AACA,WAAO;AAAA,EACT;AACF;AAEA,SAAS,aAAa,KAAa,aAA+B;AAChE,SAAO,IAAI,SAAS,IAAI,WAAW,GAAG,GAAU;AAAA,IAC9C,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,gBAAgB;AAAA,MAChB,kBAAkB,OAAO,IAAI,MAAM;AAAA,MACnC,iBAAiB;AAAA,MACjB,cAAc;AAAA,IAChB;AAAA,EACF,CAAC;AACH;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colyseus/playground",
3
- "version": "0.17.12",
3
+ "version": "0.18.1",
4
4
  "main": "./build/index.cjs",
5
5
  "module": "./build/index.mjs",
6
6
  "typings": "./build/index.d.ts",
@@ -23,12 +23,15 @@
23
23
  "src-backend"
24
24
  ],
25
25
  "peerDependencies": {
26
- "express": ">=4.16.0",
26
+ "cpupro": "^0.7.0",
27
27
  "zod": "^4.1.12",
28
- "@colyseus/auth": "^0.17.8",
29
- "@colyseus/core": "^0.17.38"
28
+ "@colyseus/auth": "^0.18.1",
29
+ "@colyseus/core": "^0.18.1"
30
30
  },
31
31
  "peerDependenciesMeta": {
32
+ "cpupro": {
33
+ "optional": true
34
+ },
32
35
  "zod": {
33
36
  "optional": true
34
37
  }
@@ -58,6 +61,7 @@
58
61
  "@types/react-syntax-highlighter": "^15.5.13",
59
62
  "@vitejs/plugin-react": "^4.2.1",
60
63
  "autoprefixer": "^10.4.16",
64
+ "cpupro": "^0.7.0",
61
65
  "esbuild": "^0.17.19",
62
66
  "fast-glob": "^3.2.12",
63
67
  "jsoneditor": "^9.10.2",
@@ -71,19 +75,22 @@
71
75
  "react-transition-group": "^4.4.5",
72
76
  "react18-json-view": "^0.0.8",
73
77
  "tailwindcss": "^3.3.2",
74
- "typescript": "^5.9.3",
78
+ "typescript": "^6.0.3",
75
79
  "vite": "^5.0.11",
76
80
  "vitest": "^1.1.3",
77
81
  "web-vitals": "^2.1.4",
78
- "@colyseus/sdk": "^0.17.34"
82
+ "@colyseus/sdk": "^0.18.1"
79
83
  },
80
84
  "dependencies": {
81
85
  "@colyseus/better-call": "^1.3.1"
82
86
  },
87
+ "publishConfig": {
88
+ "tag": "next"
89
+ },
83
90
  "scripts": {
84
91
  "start": "vite",
85
92
  "build": "vite build && node build.mjs",
86
93
  "preview": "vite preview",
87
- "test-todo": "vitest"
94
+ "test": "vitest run"
88
95
  }
89
96
  }
@@ -1,6 +1,14 @@
1
1
  import { Room, Client, ClientState, ClientPrivate, AuthContext } from '@colyseus/core';
2
2
 
3
+ let __patched = false;
4
+
3
5
  export async function applyMonkeyPatch() {
6
+ // Idempotent: playground() may be invoked multiple times (e.g. once for
7
+ // createRouter spread, once for the express middleware) — stacking
8
+ // _onJoin wrappers would leak.
9
+ if (__patched) { return; }
10
+ __patched = true;
11
+
4
12
  /**
5
13
  * Optional: if zod is available, we can use toJSONSchema() for body and query types
6
14
  */
@@ -1,11 +1,15 @@
1
+ import fs from 'fs/promises';
1
2
  import path from 'path';
2
- import express, { Router } from 'express';
3
+ import { fileURLToPath } from 'url';
4
+ import inspector from 'node:inspector/promises';
5
+ import { spawn } from 'node:child_process';
6
+ import os from 'node:os';
7
+ import { createRequire } from 'node:module';
8
+ import type { IncomingMessage, ServerResponse } from 'http';
9
+ import { createEndpoint, dualModeEndpoints, isDevMode, logger, matchMaker, Server, type IRoomCache, type Endpoint } from '@colyseus/core';
3
10
  import { auth, JWT } from '@colyseus/auth';
4
- import { matchMaker, IRoomCache, __globalEndpoints } from '@colyseus/core';
5
- import type { Endpoint } from "@colyseus/better-call";
6
11
  import { applyMonkeyPatch } from './colyseus.ext.js';
7
-
8
- import { fileURLToPath } from 'url'; // required for ESM build (see build.mjs)
12
+ import { serveStatic } from './serve-static.js';
9
13
 
10
14
  export type AuthConfig = {
11
15
  oauth: string[],
@@ -13,62 +17,291 @@ export type AuthConfig = {
13
17
  anonymous: boolean,
14
18
  };
15
19
 
16
- export function playground(): Router {
17
- applyMonkeyPatch();
18
-
19
- const router = express.Router();
20
+ export interface PlaygroundOptions {
21
+ /** Mount prefix used when spread into `createRouter`. Ignored in express-middleware mode (express strips its mount path). Defaults to `''`. */
22
+ prefix?: string;
23
+ /** Better-call middleware applied to every playground endpoint — use for auth gating. */
24
+ use?: any[];
25
+ }
20
26
 
21
- // serve static frontend
22
- router.use("/", express.static(path.resolve(__dirname, "..", "build")));
27
+ const SPA_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'build');
23
28
 
24
- // expose matchmaking stats
25
- router.get("/rooms", async (req, res) => {
26
- const rooms = await matchMaker.driver.query({});
29
+ // CPU profiling state — single process, single active session. The captured
30
+ // `.cpuprofile` and the cpupro-generated standalone HTML report are kept in
31
+ // memory (latest only); a process restart clears them, which is fine for a
32
+ // dev tool. See the `/profiling/*` endpoints below.
33
+ let profilingSession: inspector.Session | null = null;
34
+ let profilingStartedAt = 0;
35
+ let lastProfile: string | null = null;
36
+ let lastReportHtml: string | null = null;
37
+ let lastProfilingError: string | null = null;
27
38
 
28
- const roomsByType: { [roomName: string]: number } = {};
29
- const roomsById: { [roomName: string]: IRoomCache } = {};
39
+ // cpupro is an OPTIONAL peer dependency it pulls in a native addon
40
+ // (v8-profiler-next) we don't use, so it's opt-in. Resolve its CLI bin if
41
+ // installed, else null. cpupro's `exports` map hides `bin/cpupro`, so resolve
42
+ // the package dir via its (exported) package.json and join the bin path.
43
+ function resolveCpuproBin(): string | null {
44
+ try {
45
+ const require = createRequire(import.meta.url);
46
+ return path.join(path.dirname(require.resolve('cpupro/package.json')), 'bin', 'cpupro');
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
30
51
 
31
- rooms.forEach((room) => {
32
- if (!roomsByType[room.name]) { roomsByType[room.name] = 0; }
33
- roomsByType[room.name]++;
34
- roomsById[room.roomId] = room;
52
+ // Render an existing `.cpuprofile` (JSON string) into a standalone,
53
+ // self-contained cpupro HTML report by shelling out to the cpupro CLI. The
54
+ // CLI is the documented, format-stable path and is agnostic to our cjs/esm
55
+ // dual build.
56
+ async function generateCpuproReport(profileJson: string): Promise<string> {
57
+ const bin = resolveCpuproBin();
58
+ if (!bin) {
59
+ throw new Error("cpupro is not installed — run `npm install cpupro` to render reports in the playground. The .cpuprofile is still available to download.");
60
+ }
61
+ const stamp = Date.now();
62
+ const profilePath = path.join(os.tmpdir(), `colyseus-profile-${stamp}.cpuprofile`);
63
+ const reportName = `colyseus-report-${stamp}.html`;
64
+ const reportPath = path.join(os.tmpdir(), reportName);
65
+ await fs.writeFile(profilePath, profileJson);
66
+ try {
67
+ await new Promise<void>((resolve, reject) => {
68
+ const child = spawn(process.execPath, [bin, profilePath, '-n', '-o', os.tmpdir(), '-f', reportName], { stdio: 'ignore' });
69
+ child.on('error', reject);
70
+ child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`cpupro exited with code ${code}`)));
35
71
  });
72
+ return patchCpuproReport(await fs.readFile(reportPath, 'utf8'));
73
+ } finally {
74
+ await fs.rm(profilePath, { force: true }).catch(() => {});
75
+ await fs.rm(reportPath, { force: true }).catch(() => {});
76
+ }
77
+ }
36
78
 
37
- res.json({
38
- rooms: Object.keys(matchMaker.getAllHandlers()),
79
+ // cpupro@0.7.0 ships a `build/report.html` template configured to fetch its
80
+ // data from a sibling `model.data` URL (`dataSource:"url"`), yet `report.js`
81
+ // appends the actual profile inline as `discoveryLoader.push()` chunks — a
82
+ // mechanism only wired up when `dataSource==="push"`. As shipped the file is
83
+ // therefore not self-contained: it requests `model.data` (404s through our
84
+ // SPA) and throws `discoveryLoader is not defined`. Flip the data source to
85
+ // `push` so the embedded chunks are consumed and the report stands alone.
86
+ function patchCpuproReport(html: string): string {
87
+ return html.replace(/dataSource:"url",data:[A-Za-z_$][\w$]*\.model\.data/, 'dataSource:"push",data:1');
88
+ }
39
89
 
40
- roomsByType,
41
- roomsById,
90
+ export function playground(opts: PlaygroundOptions = {}) {
91
+ applyMonkeyPatch();
42
92
 
43
- auth: {
44
- // list of OAuth providers
45
- oauth: Object.keys(auth.oauth.providers),
46
- register: typeof(auth.settings.onRegisterWithEmailAndPassword) === "function",
47
- anonymous: typeof(JWT.settings.secret) === "string",
48
- } as AuthConfig
49
- });
50
- });
93
+ const prefix = opts.prefix ?? '';
94
+ const use = opts.use ?? [];
51
95
 
52
- // serve API docs for playground
53
- // (workaround to use better-call route inside express.Router)
54
- router.get("/__apidocs", async (_, res) => {
55
- /**
56
- * Optional: if zod is available, we can use toJSONSchema() for body and query types
57
- */
58
- let z: any = undefined;
59
- try { z = await import("zod"); } catch (e: any) { /* zod not installed */ }
96
+ // Data endpoints leak internals (room listing + auth config, API schemas,
97
+ // CPU profiles/stack frames). Open during local dev (devMode or a
98
+ // non-production NODE_ENV); on production mounts require a `use:` guard —
99
+ // the guard is the opt-in. 404 hides the routes' existence.
100
+ let warnedGuardedProd = false;
101
+ const guardedDenied = () => {
102
+ if (isDevMode || process.env.NODE_ENV !== 'production') { return null; }
103
+ if (use.length === 0) { return new Response('Not found', { status: 404 }); }
104
+ if (!warnedGuardedProd) {
105
+ warnedGuardedProd = true;
106
+ logger.warn('@colyseus/playground: serving in production behind `use:` middleware — make sure at least one enforces authentication.');
107
+ }
108
+ return null;
109
+ };
60
110
 
61
- res.json(Object.values(__globalEndpoints).map((endpoint: Endpoint) => {
111
+ const endpoints: Record<string, Endpoint> = {
112
+ 'playground-rooms': createEndpoint(`${prefix}/rooms`, { method: 'GET', use }, async () => {
113
+ const denied = guardedDenied(); if (denied) { return denied; }
114
+ const rooms = await matchMaker.driver.query({});
115
+ const roomsByType: Record<string, number> = {};
116
+ const roomsById: Record<string, IRoomCache> = {};
117
+ rooms.forEach((room) => {
118
+ roomsByType[room.name] = (roomsByType[room.name] ?? 0) + 1;
119
+ roomsById[room.roomId] = room;
120
+ });
62
121
  return {
63
- method: endpoint.options.method,
64
- path: endpoint.path,
65
- body: z && endpoint.options.body && z.toJSONSchema(endpoint.options.body),
66
- query: z && endpoint.options.query && z.toJSONSchema(endpoint.options.query),
67
- metadata: endpoint.options.metadata,
68
- description: endpoint.options.metadata?.openapi?.description,
122
+ rooms: Object.keys(matchMaker.getAllHandlers()),
123
+ roomsByType,
124
+ roomsById,
125
+ auth: {
126
+ oauth: Object.keys(auth.oauth.providers),
127
+ register: typeof auth.settings.onRegisterWithEmailAndPassword === 'function',
128
+ anonymous: typeof JWT.settings.secret === 'string',
129
+ } as AuthConfig,
69
130
  };
70
- }));
71
- });
131
+ }),
132
+
133
+ 'playground-apidocs': createEndpoint(`${prefix}/__apidocs`, { method: 'GET', use }, async () => {
134
+ const denied = guardedDenied(); if (denied) { return denied; }
135
+
136
+ let z: any;
137
+ try { z = await import('zod'); } catch { /* zod is an optional peer */ }
138
+ const routerEndpoints: Record<string, any> = (Server.current?.router as any)?.endpoints ?? {};
139
+ return Object.values(routerEndpoints)
140
+ // SERVER_ONLY endpoints aren't routable — don't list them either
141
+ .filter((endpoint: any) => !endpoint.options.metadata?.SERVER_ONLY)
142
+ .map((endpoint: any) => ({
143
+ method: endpoint.options.method,
144
+ path: endpoint.path,
145
+ body: z && endpoint.options.body && z.toJSONSchema(endpoint.options.body),
146
+ query: z && endpoint.options.query && z.toJSONSchema(endpoint.options.query),
147
+ metadata: endpoint.options.metadata,
148
+ description: endpoint.options.metadata?.openapi?.description,
149
+ }));
150
+ }),
151
+
152
+ 'profiling-start': createEndpoint(`${prefix}/profiling/start`, { method: 'POST', use }, async (ctx) => {
153
+ const denied = guardedDenied(); if (denied) { return denied; }
154
+ if (profilingSession) {
155
+ return { status: 'running', startedAt: profilingStartedAt, error: 'already running' };
156
+ }
157
+ const interval = Number((ctx.query as any)?.interval) || 100;
158
+ const session = new inspector.Session();
159
+ session.connect();
160
+ await session.post('Profiler.enable');
161
+ await session.post('Profiler.setSamplingInterval', { interval });
162
+ await session.post('Profiler.start');
163
+ profilingSession = session;
164
+ profilingStartedAt = Date.now();
165
+ lastProfile = null;
166
+ lastReportHtml = null;
167
+ lastProfilingError = null;
168
+ return { status: 'started', startedAt: profilingStartedAt, interval };
169
+ }),
170
+
171
+ 'profiling-stop': createEndpoint(`${prefix}/profiling/stop`, { method: 'POST', use }, async () => {
172
+ const denied = guardedDenied(); if (denied) { return denied; }
173
+ if (!profilingSession) {
174
+ return { status: 'idle', error: 'not running' };
175
+ }
176
+ const session = profilingSession;
177
+ profilingSession = null;
178
+ const durationMs = Date.now() - profilingStartedAt;
179
+ const { profile } = await session.post('Profiler.stop');
180
+ session.disconnect();
181
+ lastProfile = JSON.stringify(profile);
182
+ lastReportHtml = null;
183
+ lastProfilingError = null;
184
+ try {
185
+ lastReportHtml = await generateCpuproReport(lastProfile);
186
+ } catch (e: any) {
187
+ lastProfilingError = e?.message ?? String(e);
188
+ }
189
+ return {
190
+ status: 'stopped',
191
+ durationMs,
192
+ profileBytes: lastProfile.length,
193
+ reportReady: !!lastReportHtml,
194
+ error: lastProfilingError,
195
+ };
196
+ }),
72
197
 
73
- return router;
198
+ 'profiling-status': createEndpoint(`${prefix}/profiling/status`, { method: 'GET', use }, async () => {
199
+ const denied = guardedDenied(); if (denied) { return denied; }
200
+ return {
201
+ running: !!profilingSession,
202
+ startedAt: profilingSession ? profilingStartedAt : 0,
203
+ hasProfile: !!lastProfile,
204
+ hasReport: !!lastReportHtml,
205
+ // false when the optional `cpupro` peer dep isn't installed — the UI
206
+ // uses this to warn up front that captures are download-only.
207
+ reportSupported: !!resolveCpuproBin(),
208
+ error: lastProfilingError,
209
+ };
210
+ }),
211
+
212
+ 'profiling-profile': createEndpoint(`${prefix}/profiling/profile.cpuprofile`, { method: 'GET', use }, async () => {
213
+ const denied = guardedDenied(); if (denied) { return denied; }
214
+ if (!lastProfile) { return new Response('No profile captured', { status: 404 }); }
215
+ const buf = Buffer.from(lastProfile, 'utf8');
216
+ return new Response(new Uint8Array(buf) as any, {
217
+ status: 200,
218
+ headers: {
219
+ 'content-type': 'application/json',
220
+ 'content-length': String(buf.length),
221
+ 'content-disposition': 'attachment; filename="profile.cpuprofile"',
222
+ 'cache-control': 'no-cache',
223
+ // serve-static.ts sets this for the same reason: better-call's node
224
+ // adapter doesn't call res.end() on a backpressured single-chunk
225
+ // body, so a keep-alive response is left unterminated and browsers
226
+ // spin. Closing the connection terminates it cleanly.
227
+ 'connection': 'close',
228
+ },
229
+ });
230
+ }),
231
+
232
+ 'profiling-report': createEndpoint(`${prefix}/profiling/report.html`, { method: 'GET', use }, async () => {
233
+ const denied = guardedDenied(); if (denied) { return denied; }
234
+ if (!lastReportHtml) { return new Response('No report available', { status: 404 }); }
235
+ const buf = Buffer.from(lastReportHtml, 'utf8');
236
+ return new Response(new Uint8Array(buf) as any, {
237
+ status: 200,
238
+ headers: {
239
+ 'content-type': 'text/html; charset=utf-8',
240
+ 'content-length': String(buf.length),
241
+ 'cache-control': 'no-cache',
242
+ // see profiling-profile above — `connection: close` so the browser
243
+ // doesn't spin on better-call's unterminated keep-alive response.
244
+ 'connection': 'close',
245
+ },
246
+ });
247
+ }),
248
+
249
+ 'playground-index': createEndpoint(`${prefix}/`, { method: 'GET', use }, async () => {
250
+ return serveStatic(SPA_DIST, '');
251
+ }),
252
+
253
+ 'playground-static': createEndpoint(`${prefix}/**:splat`, { method: 'GET', use }, async (ctx) => {
254
+ return serveStatic(SPA_DIST, (ctx.params as any).splat);
255
+ }),
256
+ };
257
+
258
+ const SPA_DIST_RESOLVED = path.resolve(SPA_DIST);
259
+
260
+ return dualModeEndpoints(endpoints, {
261
+ catchAllKey: 'playground-static',
262
+ buildMiddleware: ({ fullHandler }) => {
263
+ // Strip express's `baseUrl` so the inner router sees the request
264
+ // relative to its mount point — playground's endpoint paths assume
265
+ // a `''` prefix, so a sub-mount like `app.use("/foo", playground())`
266
+ // needs the leading `/foo` stripped before dispatch.
267
+ const dispatch = (req: IncomingMessage, res: ServerResponse, next: (e?: any) => void) => {
268
+ const stripped = Object.create(req, {
269
+ baseUrl: { value: '', enumerable: true, configurable: true },
270
+ originalUrl: { value: req.url, enumerable: true, configurable: true },
271
+ });
272
+ fullHandler(stripped as any, res as any).catch(next);
273
+ };
274
+
275
+ return (req, res, next) => {
276
+ const url = (req.url ?? '').split('?')[0]!;
277
+ const isProfiling = url.startsWith('/profiling');
278
+
279
+ // Profiling start/stop are POSTs; everything else here is GET-only.
280
+ if (req.method === 'POST') {
281
+ return isProfiling ? dispatch(req, res, next) : next();
282
+ }
283
+ if (req.method !== 'GET') { return next(); }
284
+
285
+ if (url === '/' || url === '/rooms' || url === '/__apidocs' || isProfiling) {
286
+ return dispatch(req, res, next);
287
+ }
288
+
289
+ // Asset request — only delegate if the file actually exists on
290
+ // disk. Avoids the catch-all's SPA fallback (which would serve
291
+ // index.html for unknown paths, masking sibling express routes).
292
+ const rel = url.replace(/^\/+/, '');
293
+ if (!rel || rel.includes('..')) { return next(); }
294
+ const filePath = path.resolve(SPA_DIST_RESOLVED, rel);
295
+ if (!filePath.startsWith(SPA_DIST_RESOLVED + path.sep)) { return next(); }
296
+
297
+ fs.stat(filePath).then((stat) => {
298
+ if (stat.isFile()) {
299
+ dispatch(req, res, next);
300
+ } else {
301
+ next();
302
+ }
303
+ }).catch(() => next());
304
+ };
305
+ },
306
+ });
74
307
  }
@@ -0,0 +1,76 @@
1
+ // Static-file helper for the built playground SPA. Mirrors the helper used by
2
+ // @colyseus/admin (kept duplicated for now — both consumers are framework-owned
3
+ // and the helper is small + stable).
4
+ import fs from 'fs/promises';
5
+ import path from 'path';
6
+
7
+ const MIME: Record<string, string> = {
8
+ '.html': 'text/html; charset=utf-8',
9
+ '.js': 'application/javascript; charset=utf-8',
10
+ '.mjs': 'application/javascript; charset=utf-8',
11
+ '.css': 'text/css; charset=utf-8',
12
+ '.json': 'application/json; charset=utf-8',
13
+ '.svg': 'image/svg+xml',
14
+ '.png': 'image/png',
15
+ '.jpg': 'image/jpeg',
16
+ '.jpeg': 'image/jpeg',
17
+ '.gif': 'image/gif',
18
+ '.ico': 'image/x-icon',
19
+ '.woff': 'font/woff',
20
+ '.woff2': 'font/woff2',
21
+ '.ttf': 'font/ttf',
22
+ '.map': 'application/json; charset=utf-8',
23
+ '.txt': 'text/plain; charset=utf-8',
24
+ };
25
+
26
+ export async function serveStatic(root: string, relPath: string | undefined): Promise<Response> {
27
+ const safe = sanitize(relPath ?? '');
28
+ const filePath = safe ? path.join(root, safe) : path.join(root, 'index.html');
29
+
30
+ const resolved = path.resolve(filePath);
31
+ const resolvedRoot = path.resolve(root);
32
+ if (!resolved.startsWith(resolvedRoot + path.sep) && resolved !== resolvedRoot) {
33
+ return new Response('forbidden', { status: 403 });
34
+ }
35
+
36
+ const data = await tryRead(resolved);
37
+ if (data) { return fileResponse(data, mimeOf(resolved)); }
38
+
39
+ // SPA fallback — serve index.html for any non-asset request
40
+ if (!path.extname(safe)) {
41
+ const index = await tryRead(path.join(resolvedRoot, 'index.html'));
42
+ if (index) { return fileResponse(index, 'text/html; charset=utf-8'); }
43
+ }
44
+
45
+ return new Response('not found', { status: 404 });
46
+ }
47
+
48
+ function sanitize(p: string): string {
49
+ return p.replace(/^\/+/, '').split('/').filter((seg) => seg && seg !== '..' && seg !== '.').join('/');
50
+ }
51
+
52
+ function mimeOf(filePath: string): string {
53
+ return MIME[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
54
+ }
55
+
56
+ async function tryRead(filePath: string): Promise<Buffer | null> {
57
+ try {
58
+ const stat = await fs.stat(filePath);
59
+ if (!stat.isFile()) { return null; }
60
+ return await fs.readFile(filePath);
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function fileResponse(buf: Buffer, contentType: string): Response {
67
+ return new Response(new Uint8Array(buf) as any, {
68
+ status: 200,
69
+ headers: {
70
+ 'content-type': contentType,
71
+ 'content-length': String(buf.length),
72
+ 'cache-control': 'no-cache',
73
+ 'connection': 'close',
74
+ },
75
+ });
76
+ }