arcway 0.1.26 → 0.1.28

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.
Files changed (56) hide show
  1. package/client/fetcher.js +4 -1
  2. package/client/hooks/swr-compat.js +4 -2
  3. package/client/hooks/use-api-paginated.js +74 -0
  4. package/client/index.js +1 -0
  5. package/package.json +4 -3
  6. package/server/boot/index.js +22 -6
  7. package/server/build.js +73 -4
  8. package/server/cache/index.js +12 -2
  9. package/server/cache/ttl.js +24 -0
  10. package/server/config/duration.js +49 -0
  11. package/server/config/loader.js +4 -0
  12. package/server/config/modules/cache.js +7 -1
  13. package/server/config/modules/jobs.js +14 -1
  14. package/server/config/modules/mail.js +16 -1
  15. package/server/config/modules/mcp.js +5 -1
  16. package/server/config/modules/pages.js +1 -0
  17. package/server/config/modules/plugins.js +18 -0
  18. package/server/config/modules/queue.js +7 -1
  19. package/server/config/modules/secrets.js +145 -0
  20. package/server/config/modules/server.js +13 -1
  21. package/server/config/modules/session.js +5 -1
  22. package/server/config/modules/websocket.js +8 -0
  23. package/server/context.js +61 -2
  24. package/server/db/index.js +39 -9
  25. package/server/discovery.js +45 -3
  26. package/server/events/drivers/memory.js +22 -4
  27. package/server/events/drivers/redis.js +20 -5
  28. package/server/events/handler.js +21 -7
  29. package/server/events/index.js +2 -2
  30. package/server/index.js +2 -1
  31. package/server/internals.js +8 -0
  32. package/server/jobs/drivers/run-handler.js +3 -0
  33. package/server/jobs/runner.js +13 -4
  34. package/server/jobs/worker-config.js +58 -0
  35. package/server/jobs/worker-entry.js +7 -12
  36. package/server/lib/vault/index.js +20 -0
  37. package/server/lib/vault/secrets.js +149 -0
  38. package/server/meta.js +106 -0
  39. package/server/pages/build-plugins.js +2 -1
  40. package/server/pages/handler.js +23 -5
  41. package/server/pages/out-dir.js +103 -2
  42. package/server/pages/pages-router.js +3 -2
  43. package/server/pages/ssr.js +55 -18
  44. package/server/plugins/capabilities.js +66 -0
  45. package/server/plugins/discovery.js +111 -0
  46. package/server/plugins/graph.js +114 -0
  47. package/server/plugins/manager.js +210 -0
  48. package/server/plugins/manifest.js +94 -0
  49. package/server/router/api-router.js +41 -16
  50. package/server/router/routes.js +14 -2
  51. package/server/server.js +13 -1
  52. package/server/session/index.js +5 -1
  53. package/server/static/index.js +5 -2
  54. package/server/web-server.js +3 -3
  55. package/server/ws/realtime.js +24 -8
  56. package/server/ws/ws-router.js +24 -8
@@ -0,0 +1,94 @@
1
+ const ID_PATTERN = /^[a-z][a-z0-9-]*$/;
2
+ const KINDS = new Set(['core', 'feature']);
3
+ const SCOPES = new Set(['global', 'workspace']);
4
+
5
+ function assertPlainObject(value, label) {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
7
+ throw new Error(`${label} must be an object`);
8
+ }
9
+ }
10
+
11
+ function normalizeStringArray(value, label) {
12
+ if (value === undefined) return [];
13
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item === '')) {
14
+ throw new Error(`${label} must be an array of non-empty strings`);
15
+ }
16
+ return [...value];
17
+ }
18
+
19
+ function normalizeRequires(value) {
20
+ if (value === undefined) return {};
21
+ assertPlainObject(value, 'plugin requires');
22
+ return { ...value };
23
+ }
24
+
25
+ function normalizeContributes(value) {
26
+ if (value === undefined) return {};
27
+ assertPlainObject(value, 'plugin contributes');
28
+ return {
29
+ ...value,
30
+ tools: value.tools ?? [],
31
+ routes: value.routes ?? [],
32
+ jobs: value.jobs ?? [],
33
+ migrations: value.migrations,
34
+ ui: value.ui,
35
+ };
36
+ }
37
+
38
+ function normalizeLifecycle(value) {
39
+ if (value === undefined) return {};
40
+ assertPlainObject(value, 'plugin lifecycle');
41
+ for (const name of ['onLoad', 'onEnable', 'onDisable']) {
42
+ if (
43
+ value[name] !== undefined &&
44
+ typeof value[name] !== 'function' &&
45
+ typeof value[name] !== 'string'
46
+ ) {
47
+ throw new Error(`plugin lifecycle.${name} must be a function or module export name`);
48
+ }
49
+ }
50
+ return { ...value };
51
+ }
52
+
53
+ function validateManifest(rawManifest, { packageName } = {}) {
54
+ assertPlainObject(rawManifest, 'plugin manifest');
55
+
56
+ const manifest = {
57
+ id: rawManifest.id ?? packageName,
58
+ name: rawManifest.name ?? rawManifest.id ?? packageName,
59
+ version: rawManifest.version,
60
+ kind: rawManifest.kind ?? 'feature',
61
+ scope: rawManifest.scope ?? 'workspace',
62
+ defaultEnabled: rawManifest.defaultEnabled === true,
63
+ provides: normalizeStringArray(rawManifest.provides, 'plugin provides'),
64
+ requires: normalizeRequires(rawManifest.requires),
65
+ contributes: normalizeContributes(rawManifest.contributes),
66
+ lifecycle: normalizeLifecycle(rawManifest.lifecycle),
67
+ };
68
+
69
+ if (typeof manifest.id !== 'string' || !ID_PATTERN.test(manifest.id)) {
70
+ throw new Error('plugin id must be lowercase kebab-case');
71
+ }
72
+ if (typeof manifest.name !== 'string' || manifest.name === '') {
73
+ throw new Error(`plugin ${manifest.id} name must be a non-empty string`);
74
+ }
75
+ if (typeof manifest.version !== 'string' || manifest.version === '') {
76
+ throw new Error(`plugin ${manifest.id} version must be a non-empty string`);
77
+ }
78
+ if (!KINDS.has(manifest.kind)) {
79
+ throw new Error(`plugin ${manifest.id} kind must be core or feature`);
80
+ }
81
+ if (!SCOPES.has(manifest.scope)) {
82
+ throw new Error(`plugin ${manifest.id} scope must be global or workspace`);
83
+ }
84
+
85
+ return manifest;
86
+ }
87
+
88
+ function requiredCapabilities(requires) {
89
+ return Object.entries(requires ?? {})
90
+ .filter(([, requirement]) => requirement !== false && requirement !== null)
91
+ .map(([capability]) => capability);
92
+ }
93
+
94
+ export { requiredCapabilities, validateManifest };
@@ -39,6 +39,7 @@ class ApiRouter {
39
39
  _appContext;
40
40
  _sessionConfig;
41
41
  _redis;
42
+ _pluginManager;
42
43
  _routes = [];
43
44
  _middleware = [];
44
45
  _prefix = '';
@@ -53,6 +54,7 @@ class ApiRouter {
53
54
  this._appContext = appContext ?? null;
54
55
  this._sessionConfig = sessionConfig ?? null;
55
56
  this._redis = appContext?.redis ?? null;
57
+ this._pluginManager = appContext?.plugins ?? null;
56
58
  }
57
59
 
58
60
  get routes() {
@@ -101,6 +103,7 @@ class ApiRouter {
101
103
  const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
102
104
  const ctx = this._buildCtx(reqInfo);
103
105
  try {
106
+ if (route.plugin) this._pluginManager?.injectCapabilities(ctx, route.plugin.requires);
104
107
  return await chainedHandler(ctx);
105
108
  } catch (err) {
106
109
  this._log.error(`Handler error in ${route.method} ${route.pattern}`, {
@@ -133,7 +136,10 @@ class ApiRouter {
133
136
  );
134
137
  }
135
138
 
136
- _emitCanonicalLog(ctx, { method, path, route, status, startTime, middlewareNames, error, session }) {
139
+ _emitCanonicalLog(
140
+ ctx,
141
+ { method, path, route, status, startTime, middlewareNames, error, session },
142
+ ) {
137
143
  const durationMs = Date.now() - startTime;
138
144
  const dbStats = ctx._dbStats ?? { queries: 0, durationMs: 0 };
139
145
  const extra = ctx.log.getCanonicalContext?.() ?? {};
@@ -204,17 +210,22 @@ class ApiRouter {
204
210
  });
205
211
  if (!rl.allowed) {
206
212
  const retryAfter = Math.max(1, rl.resetAt - Math.ceil(Date.now() / 1000));
207
- sendJson(res, 429, {
208
- error: {
209
- code: ErrorCodes.RATE_LIMITED,
210
- message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
213
+ sendJson(
214
+ res,
215
+ 429,
216
+ {
217
+ error: {
218
+ code: ErrorCodes.RATE_LIMITED,
219
+ message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
220
+ },
221
+ },
222
+ {
223
+ 'X-RateLimit-Limit': String(max),
224
+ 'X-RateLimit-Remaining': '0',
225
+ 'X-RateLimit-Reset': String(rl.resetAt),
226
+ 'Retry-After': String(retryAfter),
211
227
  },
212
- }, {
213
- 'X-RateLimit-Limit': String(max),
214
- 'X-RateLimit-Remaining': '0',
215
- 'X-RateLimit-Reset': String(rl.resetAt),
216
- 'Retry-After': String(retryAfter),
217
- });
228
+ );
218
229
  return true;
219
230
  }
220
231
  res.setHeader('X-RateLimit-Limit', String(max));
@@ -254,6 +265,7 @@ class ApiRouter {
254
265
 
255
266
  let response;
256
267
  try {
268
+ if (route.plugin) this._pluginManager?.injectCapabilities(ctx, route.plugin.requires);
257
269
  response = await chainedHandler(ctx);
258
270
  } catch (err) {
259
271
  const errorMsg = toErrorMessage(err);
@@ -265,8 +277,14 @@ class ApiRouter {
265
277
  : String(err)
266
278
  : 'An internal error occurred';
267
279
  this._emitCanonicalLog(ctx, {
268
- method, path: pathname, route: route.pattern, status: 500,
269
- startTime, middlewareNames, error: errorMsg, session: reqInfo.session,
280
+ method,
281
+ path: pathname,
282
+ route: route.pattern,
283
+ status: 500,
284
+ startTime,
285
+ middlewareNames,
286
+ error: errorMsg,
287
+ session: reqInfo.session,
270
288
  });
271
289
  sendJson(res, 500, {
272
290
  error: { code: ErrorCodes.HANDLER_ERROR, message: errorMessage },
@@ -294,8 +312,13 @@ class ApiRouter {
294
312
  const statusCode = response.status ?? (response.error ? 400 : 200);
295
313
  await serializeResponse(res, response, responseHeaders, statusCode);
296
314
  this._emitCanonicalLog(ctx, {
297
- method, path: pathname, route: route.pattern, status: statusCode,
298
- startTime, middlewareNames, session: response.session !== void 0 ? response.session : reqInfo.session,
315
+ method,
316
+ path: pathname,
317
+ route: route.pattern,
318
+ status: statusCode,
319
+ startTime,
320
+ middlewareNames,
321
+ session: response.session !== void 0 ? response.session : reqInfo.session,
299
322
  });
300
323
 
301
324
  return true;
@@ -331,10 +354,12 @@ class ApiRouter {
331
354
  }
332
355
 
333
356
  async _discover() {
334
- const [routes, middleware] = await Promise.all([
357
+ const [routes, middleware, pluginRoutes] = await Promise.all([
335
358
  discoverRoutes(this._config.dir),
336
359
  discoverMiddleware(this._config.dir),
360
+ this._pluginManager?.discoverRoutes?.() ?? [],
337
361
  ]);
362
+ routes.push(...pluginRoutes);
338
363
 
339
364
  applyPrefix(routes, middleware, this._prefix);
340
365
 
@@ -36,11 +36,21 @@ function matchPattern(items, pathname) {
36
36
  return null;
37
37
  }
38
38
  const compilePattern = compileRoutePattern;
39
- async function discoverRoutes(apiDir) {
39
+ function normalizeRoutePrefix(rawPrefix) {
40
+ if (!rawPrefix) return '';
41
+ return ('/' + rawPrefix.replace(/^\/+|\/+$/g, '')).replace(/^\/$/, '');
42
+ }
43
+ function prefixRoutePattern(pattern, prefix) {
44
+ const normalized = normalizeRoutePrefix(prefix);
45
+ if (!normalized) return pattern;
46
+ if (pattern === '/') return normalized;
47
+ return normalized + pattern;
48
+ }
49
+ async function discoverRoutes(apiDir, { prefix = '', plugin } = {}) {
40
50
  const entries = await discoverModules(apiDir, { recursive: true, label: 'route file' });
41
51
  const routes = [];
42
52
  for (const { filePath, relativePath, module } of entries) {
43
- const urlPattern = filePathToPattern(relativePath);
53
+ const urlPattern = prefixRoutePattern(filePathToPattern(relativePath), prefix);
44
54
  const { regex, paramNames, catchAllParam } = compilePattern(urlPattern);
45
55
  for (const method of HTTP_METHODS) {
46
56
  const config = module[method];
@@ -66,6 +76,7 @@ async function discoverRoutes(apiDir) {
66
76
  paramNames,
67
77
  ...(catchAllParam ? { catchAllParam } : {}),
68
78
  config,
79
+ ...(plugin ? { plugin } : {}),
69
80
  ...(method === 'GET' && typeof config.ws === 'function' ? { wsEnabled: true } : {}),
70
81
  });
71
82
  }
@@ -100,5 +111,6 @@ export {
100
111
  matchPattern,
101
112
  matchRoute,
102
113
  matchRoutesByPath,
114
+ prefixRoutePattern,
103
115
  sortBySpecificity,
104
116
  };
package/server/server.js CHANGED
@@ -1,12 +1,24 @@
1
1
  import { createServer } from 'node:http';
2
- function createHttpServer(handler) {
2
+ function createHttpServer(handler, logger) {
3
3
  const server = createServer(handler);
4
4
  const connections = new Set();
5
5
  server.on('connection', (socket) => {
6
6
  connections.add(socket);
7
+ // A peer that RSTs an idle keep-alive socket (or one mid/after response)
8
+ // makes Node emit 'error' on the socket; with no listener that throws an
9
+ // unhandled error and kills the whole process. ECONNRESET/EPIPE are normal
10
+ // peer disconnects — swallow them (debug only), never rethrow.
11
+ socket.on('error', (err) => {
12
+ logger?.debug?.('client socket error', { code: err?.code });
13
+ });
7
14
  socket.once('close', () => connections.delete(socket));
8
15
  });
9
16
  server.__trackedConnections = connections;
17
+ // Malformed request line / oversized headers surface as 'clientError'; destroy
18
+ // the offending socket instead of letting the HTTP parser crash the process.
19
+ server.on('clientError', (_err, socket) => {
20
+ if (socket.writable) socket.destroy();
21
+ });
10
22
  return server;
11
23
  }
12
24
  function listen(server, port, host = '0.0.0.0') {
@@ -2,6 +2,7 @@ import { sealData, unsealData } from 'iron-session';
2
2
  import { serialize as serializeCookie } from 'cookie';
3
3
  import { SESSION_COOKIE_MAX_AGE_SKEW } from '../constants.js';
4
4
  import { toErrorMessage } from '../helpers.js';
5
+ import { normalizeDurationSeconds } from '../config/duration.js';
5
6
  const MIN_PASSWORD_LENGTH = 32;
6
7
  function resolveSessionConfig(config, mode) {
7
8
  if (!config.password) {
@@ -27,7 +28,10 @@ function resolveSessionConfig(config, mode) {
27
28
  return {
28
29
  password: config.password,
29
30
  cookieName: config.cookieName ?? 'arcway.session',
30
- ttl: config.ttl ?? 14 * 24 * 3600,
31
+ ttl:
32
+ config.ttl === undefined
33
+ ? 14 * 24 * 3600
34
+ : normalizeDurationSeconds(config.ttl, 'session.ttl'),
31
35
  mode: mode ?? 'production',
32
36
  cookie: {
33
37
  httpOnly: config.cookie?.httpOnly ?? true,
@@ -4,11 +4,13 @@ import { serveStaticAsset, servePublicFile } from '../pages/static.js';
4
4
 
5
5
  class StaticRouter {
6
6
  outDir;
7
+ resolveOutDir;
7
8
  publicDir;
8
9
  hasPublicDir;
9
10
 
10
- constructor({ outDir, publicDir }) {
11
+ constructor({ outDir, resolveOutDir, publicDir }) {
11
12
  this.outDir = outDir;
13
+ this.resolveOutDir = resolveOutDir ?? null;
12
14
  this.publicDir = publicDir;
13
15
  this.hasPublicDir = publicDir ? fs.existsSync(publicDir) : false;
14
16
  }
@@ -21,7 +23,8 @@ class StaticRouter {
21
23
 
22
24
  // Built bundles: /static/client/*
23
25
  if (pathname.startsWith('/static/client/')) {
24
- return serveStaticAsset(pathname, res, this.outDir, req);
26
+ const outDir = this.resolveOutDir ? this.resolveOutDir() : this.outDir;
27
+ return serveStaticAsset(pathname, res, outDir, req);
25
28
  }
26
29
 
27
30
  // Public files: /* (fallback)
@@ -97,7 +97,7 @@ class WebServer {
97
97
  });
98
98
  };
99
99
 
100
- this.server = createHttpServer(requestHandler);
100
+ this.server = createHttpServer(requestHandler, log);
101
101
 
102
102
  // ── WebSocket upgrade ──
103
103
  this.server.on('upgrade', async (req, socket, head) => {
@@ -141,8 +141,8 @@ class WebServer {
141
141
  // Query string
142
142
  req.query = parseQuery(req.url ?? '/');
143
143
 
144
- // Body (POST/PUT/PATCH only)
145
- if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
144
+ // Body (methods that may carry a request payload)
145
+ if (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE') {
146
146
  req.rawBody = await readBody(req, maxBodySize);
147
147
  const parsed = parseBody(req.rawBody, req.headers['content-type'] ?? '');
148
148
  if (parsed !== undefined) req.body = parsed;
@@ -6,6 +6,19 @@ import { parseCookiesFromHeader, resolveSession, flattenHeaders } from '../sessi
6
6
  import { toErrorMessage } from '../helpers.js';
7
7
  import { buildContext } from '../context.js';
8
8
  import { registerWsServer, unregisterWsServer } from './registry.js';
9
+
10
+ function normalizeWsMsg(wsMsg) {
11
+ const rawPath = wsMsg.path;
12
+ const url = new URL(rawPath, 'http://localhost');
13
+ const query = Object.fromEntries(url.searchParams.entries());
14
+
15
+ return {
16
+ ...wsMsg,
17
+ path: rawPath,
18
+ pathname: url.pathname,
19
+ query: { ...query, ...(wsMsg.query ?? {}) },
20
+ };
21
+ }
9
22
  function createRealtimeServer(options) {
10
23
  const {
11
24
  server,
@@ -72,7 +85,7 @@ function createRealtimeServer(options) {
72
85
  return {
73
86
  id: randomUUID(),
74
87
  method: wsMsg.method,
75
- path: wsMsg.path,
88
+ path: wsMsg.pathname ?? wsMsg.path,
76
89
  query: mergedQuery,
77
90
  body: wsMsg.body,
78
91
  headers: client.headers,
@@ -98,6 +111,7 @@ function createRealtimeServer(options) {
98
111
  }
99
112
  }
100
113
  async function handleSubscribe(client, wsMsg) {
114
+ wsMsg = wsMsg.pathname ? wsMsg : normalizeWsMsg(wsMsg);
101
115
  const path = wsMsg.path;
102
116
  if (client.subscriptions.has(path)) {
103
117
  sendToClient(client.ws, {
@@ -106,7 +120,7 @@ function createRealtimeServer(options) {
106
120
  });
107
121
  return;
108
122
  }
109
- const matched = matchRoute(routes, 'GET', path);
123
+ const matched = matchRoute(routes, 'GET', wsMsg.pathname);
110
124
  if (!matched) {
111
125
  sendToClient(client.ws, {
112
126
  path,
@@ -173,8 +187,9 @@ function createRealtimeServer(options) {
173
187
  client.subscriptions.delete(path);
174
188
  }
175
189
  async function handleMethodCall(client, wsMsg) {
190
+ wsMsg = wsMsg.pathname ? wsMsg : normalizeWsMsg(wsMsg);
176
191
  const { path, method } = wsMsg;
177
- const matched = matchRoute(routes, method, path);
192
+ const matched = matchRoute(routes, method, wsMsg.pathname);
178
193
  if (!matched) {
179
194
  sendToClient(client.ws, {
180
195
  path,
@@ -258,16 +273,17 @@ function createRealtimeServer(options) {
258
273
  });
259
274
  return;
260
275
  }
261
- const method = parsed.method.toUpperCase();
276
+ const normalized = normalizeWsMsg(parsed);
277
+ const method = normalized.method.toUpperCase();
262
278
  if (method === 'SUBSCRIBE') {
263
- await handleSubscribe(client, { ...parsed, method });
279
+ await handleSubscribe(client, { ...normalized, method });
264
280
  } else if (method === 'UNSUBSCRIBE') {
265
- await handleUnsubscribe(client, { ...parsed, method });
281
+ await handleUnsubscribe(client, { ...normalized, method });
266
282
  } else if (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
267
- await handleMethodCall(client, { ...parsed, method });
283
+ await handleMethodCall(client, { ...normalized, method });
268
284
  } else {
269
285
  sendToClient(ws, {
270
- path: parsed.path,
286
+ path: normalized.path,
271
287
  error: { code: 'INVALID_METHOD', message: `Unsupported method: ${method}` },
272
288
  });
273
289
  }
@@ -5,6 +5,19 @@ import { parseCookies, resolveSession, flattenHeaders } from '../session/helpers
5
5
  import { getMiddlewareForRoute } from '../router/middleware.js';
6
6
  import { toErrorMessage } from '../helpers.js';
7
7
 
8
+ function normalizeWsMsg(wsMsg) {
9
+ const rawPath = wsMsg.path;
10
+ const url = new URL(rawPath, 'http://localhost');
11
+ const query = Object.fromEntries(url.searchParams.entries());
12
+
13
+ return {
14
+ ...wsMsg,
15
+ path: rawPath,
16
+ pathname: url.pathname,
17
+ query: { ...query, ...(wsMsg.query ?? {}) },
18
+ };
19
+ }
20
+
8
21
  class WsRouter {
9
22
  _apiRouter;
10
23
  _log;
@@ -149,16 +162,17 @@ class WsRouter {
149
162
  return;
150
163
  }
151
164
 
152
- const method = parsed.method.toUpperCase();
165
+ const normalized = normalizeWsMsg(parsed);
166
+ const method = normalized.method.toUpperCase();
153
167
  if (method === 'SUBSCRIBE') {
154
- await this._handleSubscribe(client, { ...parsed, method });
168
+ await this._handleSubscribe(client, { ...normalized, method });
155
169
  } else if (method === 'UNSUBSCRIBE') {
156
- await this._handleUnsubscribe(client, { ...parsed, method });
170
+ await this._handleUnsubscribe(client, { ...normalized, method });
157
171
  } else if (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
158
- await this._handleMethodCall(client, { ...parsed, method });
172
+ await this._handleMethodCall(client, { ...normalized, method });
159
173
  } else {
160
174
  this._send(client, {
161
- path: parsed.path,
175
+ path: normalized.path,
162
176
  error: { code: 'INVALID_METHOD', message: `Unsupported method: ${method}` },
163
177
  });
164
178
  }
@@ -167,6 +181,7 @@ class WsRouter {
167
181
  // ── Subscribe / Unsubscribe ──
168
182
 
169
183
  async _handleSubscribe(client, wsMsg) {
184
+ wsMsg = wsMsg.pathname ? wsMsg : normalizeWsMsg(wsMsg);
170
185
  const msgPath = wsMsg.path;
171
186
  if (client.subscriptions.has(msgPath)) {
172
187
  this._send(client, {
@@ -176,7 +191,7 @@ class WsRouter {
176
191
  return;
177
192
  }
178
193
 
179
- const matched = this._apiRouter.findRoute('GET', msgPath);
194
+ const matched = this._apiRouter.findRoute('GET', wsMsg.pathname);
180
195
  if (!matched) {
181
196
  this._send(client, {
182
197
  path: msgPath,
@@ -296,8 +311,9 @@ class WsRouter {
296
311
  // ── Method calls (GET/POST/PUT/PATCH/DELETE) ──
297
312
 
298
313
  async _handleMethodCall(client, wsMsg) {
314
+ wsMsg = wsMsg.pathname ? wsMsg : normalizeWsMsg(wsMsg);
299
315
  const { path: msgPath, method } = wsMsg;
300
- const matched = this._apiRouter.findRoute(method, msgPath);
316
+ const matched = this._apiRouter.findRoute(method, wsMsg.pathname);
301
317
 
302
318
  if (!matched) {
303
319
  this._send(client, {
@@ -330,7 +346,7 @@ class WsRouter {
330
346
  return {
331
347
  id: randomUUID(),
332
348
  method: wsMsg.method,
333
- path: wsMsg.path,
349
+ path: wsMsg.pathname ?? wsMsg.path,
334
350
  query: { ...(wsMsg.query ?? {}), ...params },
335
351
  body: wsMsg.body,
336
352
  headers: client.headers,