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.
- package/client/fetcher.js +4 -1
- package/client/hooks/swr-compat.js +4 -2
- package/client/hooks/use-api-paginated.js +74 -0
- package/client/index.js +1 -0
- package/package.json +4 -3
- package/server/boot/index.js +22 -6
- package/server/build.js +73 -4
- package/server/cache/index.js +12 -2
- package/server/cache/ttl.js +24 -0
- package/server/config/duration.js +49 -0
- package/server/config/loader.js +4 -0
- package/server/config/modules/cache.js +7 -1
- package/server/config/modules/jobs.js +14 -1
- package/server/config/modules/mail.js +16 -1
- package/server/config/modules/mcp.js +5 -1
- package/server/config/modules/pages.js +1 -0
- package/server/config/modules/plugins.js +18 -0
- package/server/config/modules/queue.js +7 -1
- package/server/config/modules/secrets.js +145 -0
- package/server/config/modules/server.js +13 -1
- package/server/config/modules/session.js +5 -1
- package/server/config/modules/websocket.js +8 -0
- package/server/context.js +61 -2
- package/server/db/index.js +39 -9
- package/server/discovery.js +45 -3
- package/server/events/drivers/memory.js +22 -4
- package/server/events/drivers/redis.js +20 -5
- package/server/events/handler.js +21 -7
- package/server/events/index.js +2 -2
- package/server/index.js +2 -1
- package/server/internals.js +8 -0
- package/server/jobs/drivers/run-handler.js +3 -0
- package/server/jobs/runner.js +13 -4
- package/server/jobs/worker-config.js +58 -0
- package/server/jobs/worker-entry.js +7 -12
- package/server/lib/vault/index.js +20 -0
- package/server/lib/vault/secrets.js +149 -0
- package/server/meta.js +106 -0
- package/server/pages/build-plugins.js +2 -1
- package/server/pages/handler.js +23 -5
- package/server/pages/out-dir.js +103 -2
- package/server/pages/pages-router.js +3 -2
- package/server/pages/ssr.js +55 -18
- package/server/plugins/capabilities.js +66 -0
- package/server/plugins/discovery.js +111 -0
- package/server/plugins/graph.js +114 -0
- package/server/plugins/manager.js +210 -0
- package/server/plugins/manifest.js +94 -0
- package/server/router/api-router.js +41 -16
- package/server/router/routes.js +14 -2
- package/server/server.js +13 -1
- package/server/session/index.js +5 -1
- package/server/static/index.js +5 -2
- package/server/web-server.js +3 -3
- package/server/ws/realtime.js +24 -8
- 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(
|
|
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(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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,
|
|
269
|
-
|
|
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,
|
|
298
|
-
|
|
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
|
|
package/server/router/routes.js
CHANGED
|
@@ -36,11 +36,21 @@ function matchPattern(items, pathname) {
|
|
|
36
36
|
return null;
|
|
37
37
|
}
|
|
38
38
|
const compilePattern = compileRoutePattern;
|
|
39
|
-
|
|
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') {
|
package/server/session/index.js
CHANGED
|
@@ -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:
|
|
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,
|
package/server/static/index.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/server/web-server.js
CHANGED
|
@@ -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 (
|
|
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;
|
package/server/ws/realtime.js
CHANGED
|
@@ -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',
|
|
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,
|
|
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
|
|
276
|
+
const normalized = normalizeWsMsg(parsed);
|
|
277
|
+
const method = normalized.method.toUpperCase();
|
|
262
278
|
if (method === 'SUBSCRIBE') {
|
|
263
|
-
await handleSubscribe(client, { ...
|
|
279
|
+
await handleSubscribe(client, { ...normalized, method });
|
|
264
280
|
} else if (method === 'UNSUBSCRIBE') {
|
|
265
|
-
await handleUnsubscribe(client, { ...
|
|
281
|
+
await handleUnsubscribe(client, { ...normalized, method });
|
|
266
282
|
} else if (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
267
|
-
await handleMethodCall(client, { ...
|
|
283
|
+
await handleMethodCall(client, { ...normalized, method });
|
|
268
284
|
} else {
|
|
269
285
|
sendToClient(ws, {
|
|
270
|
-
path:
|
|
286
|
+
path: normalized.path,
|
|
271
287
|
error: { code: 'INVALID_METHOD', message: `Unsupported method: ${method}` },
|
|
272
288
|
});
|
|
273
289
|
}
|
package/server/ws/ws-router.js
CHANGED
|
@@ -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
|
|
165
|
+
const normalized = normalizeWsMsg(parsed);
|
|
166
|
+
const method = normalized.method.toUpperCase();
|
|
153
167
|
if (method === 'SUBSCRIBE') {
|
|
154
|
-
await this._handleSubscribe(client, { ...
|
|
168
|
+
await this._handleSubscribe(client, { ...normalized, method });
|
|
155
169
|
} else if (method === 'UNSUBSCRIBE') {
|
|
156
|
-
await this._handleUnsubscribe(client, { ...
|
|
170
|
+
await this._handleUnsubscribe(client, { ...normalized, method });
|
|
157
171
|
} else if (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
158
|
-
await this._handleMethodCall(client, { ...
|
|
172
|
+
await this._handleMethodCall(client, { ...normalized, method });
|
|
159
173
|
} else {
|
|
160
174
|
this._send(client, {
|
|
161
|
-
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',
|
|
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,
|
|
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,
|