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.
- package/client/fetcher.js +4 -1
- package/client/hooks/swr-compat.js +27 -0
- package/client/hooks/use-api-paginated.js +74 -0
- package/client/hooks/use-api.js +1 -4
- package/client/hooks/use-graphql.js +1 -2
- package/client/hooks/use-mutation.js +1 -1
- package/client/hooks/web/use-local-storage.js +54 -16
- package/client/index.js +16 -55
- package/client/router.js +51 -29
- package/client/ui/index.js +62 -380
- package/package.json +4 -3
- package/server/bin/commands/build.js +3 -0
- package/server/boot/index.js +12 -6
- package/server/build.js +63 -5
- package/server/cache/index.js +12 -2
- package/server/cache/ttl.js +24 -0
- package/server/config/duration.js +49 -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/pages.js +3 -2
- package/server/config/modules/queue.js +7 -1
- package/server/config/modules/server.js +13 -1
- package/server/config/modules/websocket.js +8 -0
- package/server/context.js +61 -2
- package/server/db/index.js +6 -1
- 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 +7 -33
- package/server/jobs/runner.js +3 -2
- package/server/jobs/worker-config.js +58 -0
- package/server/jobs/worker-entry.js +7 -12
- package/server/meta.js +106 -0
- package/server/pages/build-client.js +1 -1
- package/server/pages/build-plugins.js +2 -1
- package/server/pages/chunk-graph.js +1 -0
- package/server/pages/fonts.js +14 -1
- package/server/pages/handler.js +25 -7
- package/server/pages/lazy-context.js +2 -2
- package/server/pages/out-dir.js +104 -3
- package/server/pages/pages-router.js +3 -2
- package/server/pages/ssr.js +55 -18
- package/server/pages/vite-dev.js +38 -3
- package/server/router/api-router.js +71 -2
- package/server/router/ratelimit.js +50 -0
- package/server/router/routes.js +10 -0
- 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
- package/client/hooks/use-form.js +0 -86
package/server/pages/ssr.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
209
|
-
pass.on('data', (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.
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
295
|
+
res.writeHead(statusCode, getHtmlHeaders());
|
|
270
296
|
const shell = buildHtmlShell({ headHtml, fontPreloadTags, cssLinkTag });
|
|
271
297
|
res.write(shell.head);
|
|
272
|
-
|
|
273
|
-
pass.on('data', (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.
|
|
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,
|
|
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/pages/vite-dev.js
CHANGED
|
@@ -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
|
|
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: [
|
|
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:
|
|
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:
|
|
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 };
|
package/server/router/routes.js
CHANGED
|
@@ -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') {
|
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
|
}
|