arcway 0.1.26 → 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 (39) 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 +12 -6
  7. package/server/build.js +62 -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/modules/cache.js +7 -1
  12. package/server/config/modules/jobs.js +14 -1
  13. package/server/config/modules/mail.js +16 -1
  14. package/server/config/modules/pages.js +1 -0
  15. package/server/config/modules/queue.js +7 -1
  16. package/server/config/modules/server.js +13 -1
  17. package/server/config/modules/websocket.js +8 -0
  18. package/server/context.js +61 -2
  19. package/server/db/index.js +6 -1
  20. package/server/events/drivers/memory.js +22 -4
  21. package/server/events/drivers/redis.js +20 -5
  22. package/server/events/handler.js +21 -7
  23. package/server/events/index.js +2 -2
  24. package/server/index.js +0 -1
  25. package/server/jobs/runner.js +3 -2
  26. package/server/jobs/worker-config.js +58 -0
  27. package/server/jobs/worker-entry.js +7 -12
  28. package/server/meta.js +106 -0
  29. package/server/pages/build-plugins.js +2 -1
  30. package/server/pages/handler.js +23 -5
  31. package/server/pages/out-dir.js +103 -2
  32. package/server/pages/pages-router.js +3 -2
  33. package/server/pages/ssr.js +55 -18
  34. package/server/server.js +13 -1
  35. package/server/session/index.js +5 -1
  36. package/server/static/index.js +5 -2
  37. package/server/web-server.js +3 -3
  38. package/server/ws/realtime.js +24 -8
  39. package/server/ws/ws-router.js +24 -8
@@ -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,
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,