arcway 0.1.25 → 0.1.27

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 (55) hide show
  1. package/client/fetcher.js +4 -1
  2. package/client/hooks/swr-compat.js +27 -0
  3. package/client/hooks/use-api-paginated.js +74 -0
  4. package/client/hooks/use-api.js +1 -4
  5. package/client/hooks/use-graphql.js +1 -2
  6. package/client/hooks/use-mutation.js +1 -1
  7. package/client/hooks/web/use-local-storage.js +54 -16
  8. package/client/index.js +16 -55
  9. package/client/router.js +51 -29
  10. package/client/ui/index.js +62 -380
  11. package/package.json +4 -3
  12. package/server/bin/commands/build.js +3 -0
  13. package/server/boot/index.js +12 -6
  14. package/server/build.js +63 -5
  15. package/server/cache/index.js +12 -2
  16. package/server/cache/ttl.js +24 -0
  17. package/server/config/duration.js +49 -0
  18. package/server/config/modules/cache.js +7 -1
  19. package/server/config/modules/jobs.js +14 -1
  20. package/server/config/modules/mail.js +16 -1
  21. package/server/config/modules/pages.js +3 -2
  22. package/server/config/modules/queue.js +7 -1
  23. package/server/config/modules/server.js +13 -1
  24. package/server/config/modules/websocket.js +8 -0
  25. package/server/context.js +61 -2
  26. package/server/db/index.js +6 -1
  27. package/server/events/drivers/memory.js +22 -4
  28. package/server/events/drivers/redis.js +20 -5
  29. package/server/events/handler.js +21 -7
  30. package/server/events/index.js +2 -2
  31. package/server/index.js +7 -33
  32. package/server/jobs/runner.js +3 -2
  33. package/server/jobs/worker-config.js +58 -0
  34. package/server/jobs/worker-entry.js +7 -12
  35. package/server/meta.js +106 -0
  36. package/server/pages/build-client.js +1 -1
  37. package/server/pages/build-plugins.js +2 -1
  38. package/server/pages/chunk-graph.js +1 -0
  39. package/server/pages/fonts.js +14 -1
  40. package/server/pages/handler.js +25 -7
  41. package/server/pages/lazy-context.js +2 -2
  42. package/server/pages/out-dir.js +104 -3
  43. package/server/pages/pages-router.js +3 -2
  44. package/server/pages/ssr.js +55 -18
  45. package/server/pages/vite-dev.js +38 -3
  46. package/server/router/api-router.js +71 -2
  47. package/server/router/ratelimit.js +50 -0
  48. package/server/router/routes.js +10 -0
  49. package/server/server.js +13 -1
  50. package/server/session/index.js +5 -1
  51. package/server/static/index.js +5 -2
  52. package/server/web-server.js +3 -3
  53. package/server/ws/realtime.js +24 -8
  54. package/server/ws/ws-router.js +24 -8
  55. package/client/hooks/use-form.js +0 -86
@@ -4,6 +4,13 @@ import { createRequire } from 'node:module';
4
4
  import { setSSRHeadData, clearSSRHeadData, renderHeadToString } from '#client/head.js';
5
5
  import { collectPublicEnv, buildEnvScriptTag } from '#client/env.js';
6
6
  import { buildHmrScript } from './hmr.js';
7
+
8
+ function getHtmlHeaders() {
9
+ return {
10
+ 'Content-Type': 'text/html; charset=utf-8',
11
+ 'Cache-Control': 'no-store',
12
+ };
13
+ }
7
14
  const REACT_INTERNALS_KEY = '__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE';
8
15
  function syncReactInternals(projectReactModule) {
9
16
  try {
@@ -154,7 +161,7 @@ async function renderPage(
154
161
  cacheVersion,
155
162
  );
156
163
  if (!Component || typeof Component !== 'function') {
157
- res.writeHead(500, { 'Content-Type': 'text/html' });
164
+ res.writeHead(500, getHtmlHeaders());
158
165
  res.end('<h1>500 - Page component is not a valid React component</h1>');
159
166
  return;
160
167
  }
@@ -188,10 +195,12 @@ async function renderPage(
188
195
  }
189
196
  element = wrapWithProviders(createElement, element, pathname, params);
190
197
  const { PassThrough } = await import('node:stream');
191
- const { pipe } = renderToPipeableStream(element, {
198
+ let pass = null;
199
+ let closed = false;
200
+ const { pipe, abort } = renderToPipeableStream(element, {
192
201
  onAllReady() {
193
- if (res.headersSent) return;
194
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
202
+ if (res.headersSent || closed) return;
203
+ res.writeHead(200, getHtmlHeaders());
195
204
  const headHtml = renderHeadToString(headData);
196
205
  const shell = buildHtmlShell({
197
206
  headHtml,
@@ -205,24 +214,39 @@ ${scriptTags}
205
214
  ${liveReloadTag}`,
206
215
  });
207
216
  res.write(shell.head);
208
- const pass = new PassThrough();
209
- pass.on('data', (chunk) => res.write(chunk));
217
+ pass = new PassThrough();
218
+ pass.on('data', (chunk) => {
219
+ if (!closed && !res.writableEnded) res.write(chunk);
220
+ });
210
221
  pass.on('end', () => {
211
- res.write(shell.tail);
222
+ if (!closed && !res.writableEnded) {
223
+ res.write(shell.tail);
224
+ res.end();
225
+ }
212
226
  clearSSRHeadData();
213
- res.end();
214
227
  });
215
228
  pipe(pass);
216
229
  },
217
230
  onError(err) {
218
231
  clearSSRHeadData();
232
+ // A client disconnect aborts the render and surfaces here; that's a normal
233
+ // peer disconnect, not a server fault — don't log it or write to a dead res.
234
+ if (closed) return;
219
235
  console.error('SSR render error:', err);
220
236
  if (!res.headersSent) {
221
- res.writeHead(500, { 'Content-Type': 'text/html' });
237
+ res.writeHead(500, getHtmlHeaders());
222
238
  res.end('<h1>500 - Render Error</h1>');
223
239
  }
224
240
  },
225
241
  });
242
+ // Client gone (mid-stream, or before the shell was ready): stop React from
243
+ // rendering into a dead response and tear down the PassThrough.
244
+ res.on('close', () => {
245
+ if (closed) return;
246
+ closed = true;
247
+ abort();
248
+ if (pass) pass.destroy();
249
+ });
226
250
  }
227
251
  async function renderErrorPage(
228
252
  bundlePath,
@@ -244,7 +268,7 @@ async function renderErrorPage(
244
268
  cacheVersion,
245
269
  );
246
270
  if (!Component || typeof Component !== 'function') {
247
- res.writeHead(statusCode, { 'Content-Type': 'text/html' });
271
+ res.writeHead(statusCode, getHtmlHeaders());
248
272
  res.end(
249
273
  `<h1>${statusCode} - ${statusCode === 404 ? 'Not Found' : 'Internal Server Error'}</h1>`,
250
274
  );
@@ -262,33 +286,46 @@ async function renderErrorPage(
262
286
  {},
263
287
  );
264
288
  const { PassThrough } = await import('node:stream');
265
- const { pipe } = renderToPipeableStream(element, {
289
+ let pass = null;
290
+ let closed = false;
291
+ const { pipe, abort } = renderToPipeableStream(element, {
266
292
  onAllReady() {
267
- if (res.headersSent) return;
293
+ if (res.headersSent || closed) return;
268
294
  const headHtml = renderHeadToString(headData);
269
- res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8' });
295
+ res.writeHead(statusCode, getHtmlHeaders());
270
296
  const shell = buildHtmlShell({ headHtml, fontPreloadTags, cssLinkTag });
271
297
  res.write(shell.head);
272
- const pass = new PassThrough();
273
- pass.on('data', (chunk) => res.write(chunk));
298
+ pass = new PassThrough();
299
+ pass.on('data', (chunk) => {
300
+ if (!closed && !res.writableEnded) res.write(chunk);
301
+ });
274
302
  pass.on('end', () => {
275
- res.write(shell.tail);
303
+ if (!closed && !res.writableEnded) {
304
+ res.write(shell.tail);
305
+ res.end();
306
+ }
276
307
  clearSSRHeadData();
277
- res.end();
278
308
  });
279
309
  pipe(pass);
280
310
  },
281
311
  onError(err) {
282
312
  clearSSRHeadData();
313
+ if (closed) return;
283
314
  console.error('Error page render error:', err);
284
315
  if (!res.headersSent) {
285
- res.writeHead(statusCode, { 'Content-Type': 'text/html' });
316
+ res.writeHead(statusCode, getHtmlHeaders());
286
317
  res.end(
287
318
  `<h1>${statusCode} - ${statusCode === 404 ? 'Not Found' : 'Internal Server Error'}</h1>`,
288
319
  );
289
320
  }
290
321
  },
291
322
  });
323
+ res.on('close', () => {
324
+ if (closed) return;
325
+ closed = true;
326
+ abort();
327
+ if (pass) pass.destroy();
328
+ });
292
329
  }
293
330
  export {
294
331
  buildCssLinkTag,
@@ -24,11 +24,16 @@ function getViteEntryPath(outDir, pattern) {
24
24
  return path.join(outDir, '.vite-entries', `${patternToFileName(pattern)}.tsx`);
25
25
  }
26
26
 
27
+ function getViteFontsPath(outDir) {
28
+ return path.join(outDir, '.vite-entries', '_arcway-fonts.css');
29
+ }
30
+
27
31
  function buildViteHydrationEntry({
28
32
  componentPath,
29
33
  layouts = [],
30
34
  loadings = [],
31
35
  pattern = '/',
36
+ globalFontStylesPath,
32
37
  globalStylesPath,
33
38
  }) {
34
39
  const imports = [
@@ -36,6 +41,10 @@ function buildViteHydrationEntry({
36
41
  `import { ApiProvider, Router } from 'arcway/lib/client';`,
37
42
  ];
38
43
 
44
+ if (globalFontStylesPath) {
45
+ imports.push(`import '${toPosixPath(path.resolve(globalFontStylesPath))}';`);
46
+ }
47
+
39
48
  if (globalStylesPath) {
40
49
  imports.push(`import '${toPosixPath(path.resolve(globalStylesPath))}';`);
41
50
  }
@@ -78,11 +87,21 @@ if (!rootOwner[ROOT_KEY]) {
78
87
  `;
79
88
  }
80
89
 
81
- async function syncViteHydrationEntries({ manifest, outDir }) {
90
+ async function syncViteHydrationEntries({ manifest, outDir, fontFaceCss }) {
82
91
  const viteDir = path.join(outDir, '.vite-entries');
83
92
  await fs.mkdir(viteDir, { recursive: true });
84
93
 
85
94
  const expected = new Set();
95
+ let globalFontStylesPath = null;
96
+
97
+ const fontsPath = getViteFontsPath(outDir);
98
+ if (fontFaceCss) {
99
+ await fs.writeFile(fontsPath, `${fontFaceCss}\n`);
100
+ globalFontStylesPath = fontsPath;
101
+ expected.add(toPosixPath(path.relative(viteDir, fontsPath)));
102
+ } else {
103
+ await fs.rm(fontsPath, { force: true }).catch(() => {});
104
+ }
86
105
 
87
106
  for (const entry of manifest.entries) {
88
107
  const entryPath = getViteEntryPath(outDir, entry.pattern);
@@ -95,6 +114,7 @@ async function syncViteHydrationEntries({ manifest, outDir }) {
95
114
  .map((dirPath) => manifest.loadings.get(dirPath)?.srcPath)
96
115
  .filter((value) => value !== undefined),
97
116
  pattern: entry.pattern,
117
+ globalFontStylesPath,
98
118
  globalStylesPath: manifest.stylesPath,
99
119
  });
100
120
  await fs.mkdir(path.dirname(entryPath), { recursive: true });
@@ -183,7 +203,7 @@ function resolveAppAliases(rootDir) {
183
203
  return aliases;
184
204
  }
185
205
 
186
- async function createViteDevRouter({ rootDir, log, config }) {
206
+ async function createViteDevRouter({ rootDir, log }) {
187
207
  const appAliases = resolveAppAliases(rootDir);
188
208
  const vite = await createViteServer({
189
209
  root: rootDir,
@@ -195,8 +215,22 @@ async function createViteDevRouter({ rootDir, log, config }) {
195
215
  alias: appAliases,
196
216
  dedupe: ['react', 'react-dom', 'swr'],
197
217
  },
218
+ css: {
219
+ devSourcemap: true,
220
+ },
221
+ esbuild: {
222
+ keepNames: true,
223
+ },
198
224
  optimizeDeps: {
199
- include: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'swr'],
225
+ include: [
226
+ 'react',
227
+ 'react-dom',
228
+ 'react/jsx-runtime',
229
+ 'react/jsx-dev-runtime',
230
+ 'swr',
231
+ 'use-sync-external-store/shim',
232
+ '@radix-ui/react-use-is-hydrated',
233
+ ],
200
234
  },
201
235
  plugins: [reactPlugin(), tailwindcss()],
202
236
  clearScreen: false,
@@ -241,6 +275,7 @@ export {
241
275
  buildViteRoute,
242
276
  createViteDevRouter,
243
277
  getViteEntryPath,
278
+ getViteFontsPath,
244
279
  syncViteHydrationEntries,
245
280
  toViteModuleUrl,
246
281
  };
@@ -2,6 +2,7 @@ import { discoverRoutes, matchRoute, compilePattern } from './routes.js';
2
2
  import { discoverMiddleware, getMiddlewareForRoute, buildMiddlewareChain } from './middleware.js';
3
3
  import { sendJson, serializeResponse } from './http-helpers.js';
4
4
  import { ErrorCodes } from '../constants.js';
5
+ import { checkRateLimit } from './ratelimit.js';
5
6
  import { sealSession, buildSessionSetCookie, buildSessionClearCookie } from '../session/index.js';
6
7
  import { flattenHeaders } from '../session/helpers.js';
7
8
  import { validateRequestSchema } from '../validation.js';
@@ -37,6 +38,7 @@ class ApiRouter {
37
38
  _fileWatcher;
38
39
  _appContext;
39
40
  _sessionConfig;
41
+ _redis;
40
42
  _routes = [];
41
43
  _middleware = [];
42
44
  _prefix = '';
@@ -50,6 +52,7 @@ class ApiRouter {
50
52
  this._fileWatcher = fileWatcher ?? null;
51
53
  this._appContext = appContext ?? null;
52
54
  this._sessionConfig = sessionConfig ?? null;
55
+ this._redis = appContext?.redis ?? null;
53
56
  }
54
57
 
55
58
  get routes() {
@@ -69,6 +72,31 @@ class ApiRouter {
69
72
  }
70
73
 
71
74
  async executeRoute(route, reqInfo) {
75
+ if (route.config._parsedRateLimit && this._redis) {
76
+ const { max, windowSec } = route.config._parsedRateLimit;
77
+ const rl = await checkRateLimit(this._redis.client, {
78
+ key: route.config.ratelimit.key,
79
+ ip: reqInfo.ip,
80
+ max,
81
+ windowSec,
82
+ });
83
+ if (!rl.allowed) {
84
+ const retryAfter = Math.max(1, rl.resetAt - Math.ceil(Date.now() / 1000));
85
+ return {
86
+ status: 429,
87
+ error: {
88
+ code: ErrorCodes.RATE_LIMITED,
89
+ message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
90
+ },
91
+ headers: {
92
+ 'X-RateLimit-Limit': String(max),
93
+ 'X-RateLimit-Remaining': '0',
94
+ 'X-RateLimit-Reset': String(rl.resetAt),
95
+ 'Retry-After': String(retryAfter),
96
+ },
97
+ };
98
+ }
99
+ }
72
100
  const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern, route.method);
73
101
  const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
74
102
  const ctx = this._buildCtx(reqInfo);
@@ -78,9 +106,15 @@ class ApiRouter {
78
106
  this._log.error(`Handler error in ${route.method} ${route.pattern}`, {
79
107
  error: toErrorMessage(err),
80
108
  });
109
+ const errorMessage =
110
+ this._mode === 'development'
111
+ ? err instanceof Error
112
+ ? err.stack || err.message
113
+ : String(err)
114
+ : 'An internal error occurred';
81
115
  return {
82
116
  status: 500,
83
- error: { code: ErrorCodes.HANDLER_ERROR, message: 'An internal error occurred' },
117
+ error: { code: ErrorCodes.HANDLER_ERROR, message: errorMessage },
84
118
  };
85
119
  }
86
120
  }
@@ -159,6 +193,35 @@ class ApiRouter {
159
193
 
160
194
  const { route, params } = matched;
161
195
 
196
+ // ── Rate limiting ──
197
+ if (route.config._parsedRateLimit && this._redis) {
198
+ const { max, windowSec } = route.config._parsedRateLimit;
199
+ const rl = await checkRateLimit(this._redis.client, {
200
+ key: route.config.ratelimit.key,
201
+ ip,
202
+ max,
203
+ windowSec,
204
+ });
205
+ if (!rl.allowed) {
206
+ 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.`,
211
+ },
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
+ });
218
+ return true;
219
+ }
220
+ res.setHeader('X-RateLimit-Limit', String(max));
221
+ res.setHeader('X-RateLimit-Remaining', String(rl.remaining));
222
+ res.setHeader('X-RateLimit-Reset', String(rl.resetAt));
223
+ }
224
+
162
225
  // ── Body parsing control ──
163
226
  const body = route.config.parseBody === false ? req.rawBody : req.body;
164
227
 
@@ -195,12 +258,18 @@ class ApiRouter {
195
258
  } catch (err) {
196
259
  const errorMsg = toErrorMessage(err);
197
260
  ctx.log.error(`Handler error in ${route.method} ${route.pattern}`, { error: errorMsg });
261
+ const errorMessage =
262
+ this._mode === 'development'
263
+ ? err instanceof Error
264
+ ? err.stack || err.message
265
+ : String(err)
266
+ : 'An internal error occurred';
198
267
  this._emitCanonicalLog(ctx, {
199
268
  method, path: pathname, route: route.pattern, status: 500,
200
269
  startTime, middlewareNames, error: errorMsg, session: reqInfo.session,
201
270
  });
202
271
  sendJson(res, 500, {
203
- error: { code: ErrorCodes.HANDLER_ERROR, message: 'An internal error occurred' },
272
+ error: { code: ErrorCodes.HANDLER_ERROR, message: errorMessage },
204
273
  });
205
274
  return true;
206
275
  }
@@ -0,0 +1,50 @@
1
+ const TIME_RANGES = {
2
+ sec: 1,
3
+ min: 60,
4
+ hr: 3600,
5
+ day: 86400,
6
+ };
7
+
8
+ const LIMIT_REGEX = /^(\d+)\s+per\s+(sec|min|hr|day)$/i;
9
+
10
+ function parseRateLimit(limitStr) {
11
+ if (!limitStr || typeof limitStr !== 'string') {
12
+ throw new Error(`Invalid rate limit: expected "N per TIMERANGE", got ${JSON.stringify(limitStr)}`);
13
+ }
14
+ const match = limitStr.match(LIMIT_REGEX);
15
+ if (!match) {
16
+ throw new Error(`Invalid rate limit: expected "N per TIMERANGE" (sec|min|hr|day), got "${limitStr}"`);
17
+ }
18
+ const max = parseInt(match[1], 10);
19
+ if (max <= 0) {
20
+ throw new Error(`Invalid rate limit: max must be > 0, got ${max}`);
21
+ }
22
+ const windowSec = TIME_RANGES[match[2].toLowerCase()];
23
+ return { max, windowSec };
24
+ }
25
+
26
+ // Lua script: atomic INCR + conditional EXPIRE
27
+ // Returns [count, ttl]
28
+ const LUA_INCR_EXPIRE = `
29
+ local count = redis.call('INCR', KEYS[1])
30
+ if count == 1 then
31
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
32
+ end
33
+ local ttl = redis.call('TTL', KEYS[1])
34
+ return {count, ttl}
35
+ `;
36
+
37
+ async function checkRateLimit(redisClient, { key, ip, max, windowSec }) {
38
+ const redisKey = `rl:${key}:${ip}`;
39
+ const result = await redisClient.eval(LUA_INCR_EXPIRE, 1, redisKey, windowSec);
40
+ const count = result[0];
41
+ const ttl = result[1];
42
+ const resetAt = Math.ceil(Date.now() / 1000) + (ttl > 0 ? ttl : windowSec);
43
+ return {
44
+ allowed: count <= max,
45
+ remaining: Math.max(0, max - count),
46
+ resetAt,
47
+ };
48
+ }
49
+
50
+ export { parseRateLimit, checkRateLimit };
@@ -1,5 +1,6 @@
1
1
  import { compileRoutePattern, extractRouteParams } from '#client/route-pattern.js';
2
2
  import { discoverModules } from '../discovery.js';
3
+ import { parseRateLimit } from './ratelimit.js';
3
4
  const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
4
5
  function filePathToPattern(relativePath) {
5
6
  let route = relativePath.replace(/\\/g, '/');
@@ -49,6 +50,15 @@ async function discoverRoutes(apiDir) {
49
50
  `Route ${method} ${urlPattern} (${filePath}) must export a handler function`,
50
51
  );
51
52
  }
53
+ if (config.ratelimit) {
54
+ const rl = config.ratelimit;
55
+ if (!rl.key || typeof rl.key !== 'string') {
56
+ throw new Error(
57
+ `Route ${method} ${urlPattern} (${filePath}) ratelimit.key must be a non-empty string`,
58
+ );
59
+ }
60
+ config._parsedRateLimit = parseRateLimit(rl.limit);
61
+ }
52
62
  routes.push({
53
63
  method,
54
64
  pattern: urlPattern,
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
  }