arcway 0.3.2 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,10 +1,16 @@
1
1
  import boot from '#server/boot.js';
2
+ import { loadEnvFiles } from '#server/env.js';
2
3
 
3
4
  async function startServer(mode) {
4
5
  if (mode === 'production') {
5
6
  process.env.NODE_ENV = 'production';
6
7
  }
7
8
 
9
+ // Load .env files so `arcway dev` / `arcway start` pick up local config
10
+ // (.env, .env.local, .env.<mode>, .env.<mode>.local). Real process env wins —
11
+ // dotenv never overrides already-set variables.
12
+ loadEnvFiles(process.cwd(), mode);
13
+
8
14
  const app = await boot({ mode, rootDir: process.cwd() });
9
15
  app.logger.info('Arcway framework ready', { mode, port: app.port });
10
16
 
@@ -24,7 +30,10 @@ async function startServer(mode) {
24
30
  }
25
31
 
26
32
  function register(program) {
27
- program.command('start').description('Start production server').action(() => startServer('production'));
33
+ program
34
+ .command('start')
35
+ .description('Start production server')
36
+ .action(() => startServer('production'));
28
37
  }
29
38
 
30
39
  export { startServer };
@@ -124,6 +124,7 @@ async function boot(options) {
124
124
  sessionConfig: config.session,
125
125
  });
126
126
  await apiRouter.init();
127
+ appContext.api = apiRouter;
127
128
  if (fileWatcher) plugins.watch(fileWatcher, { jobRunner });
128
129
 
129
130
  const pagesRouter = new PagesRouter(config, { rootDir, log, mode, fileWatcher, appContext });
package/server/context.js CHANGED
@@ -156,6 +156,7 @@ function buildContext(appContext, extras) {
156
156
  };
157
157
  ctx.callbacks = appContext.callbacks?.bind?.(ctx);
158
158
  ctx.plugins = appContext.plugins?.buildContextProxy?.(ctx);
159
+ ctx.api = appContext.api?.buildContextApi?.(ctx) ?? null;
159
160
  return ctx;
160
161
  }
161
162
 
@@ -1,9 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { nanoid } from 'nanoid';
2
+ import { customAlphabet, nanoid } from 'nanoid';
3
+
4
+ const LOWERCASE_BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
5
+ const lowercaseBase32 = customAlphabet(LOWERCASE_BASE32_ALPHABET);
3
6
  function generateUUID() {
4
7
  return randomUUID();
5
8
  }
6
9
  function generateNanoId(size) {
7
10
  return nanoid(size);
8
11
  }
9
- export { generateNanoId, generateUUID };
12
+ function generateBase32Id(size = 16) {
13
+ return lowercaseBase32(size);
14
+ }
15
+ export { generateBase32Id, generateNanoId, generateUUID };
@@ -1,5 +1,5 @@
1
1
  import { hashPassword, verifyPassword } from './password.js';
2
- import { generateUUID, generateNanoId } from './ids.js';
2
+ import { generateBase32Id, generateUUID, generateNanoId } from './ids.js';
3
3
  import { encrypt, decrypt } from './encrypt.js';
4
4
  import { jwtEncode, jwtDecode } from './jwt.js';
5
5
  import {
@@ -29,6 +29,7 @@ export {
29
29
  encodeMasterSecret,
30
30
  encrypt,
31
31
  formatVaultKey,
32
+ generateBase32Id,
32
33
  generateNanoId,
33
34
  generateUUID,
34
35
  hashPassword,
@@ -50,7 +50,8 @@ async function loadComponent(bundlePath, cacheKey, componentCache, cacheVersion)
50
50
  const Component = mod.default;
51
51
  componentCache.set(cacheKey, Component);
52
52
  return Component;
53
- } catch {
53
+ } catch (error) {
54
+ console.error(`[arcway] failed to load component ${cacheKey} from ${bundlePath}:`, error && error.stack ? error.stack : error);
54
55
  // Component failed to load (missing file, syntax error) — caller handles null
55
56
  return null;
56
57
  }
@@ -2,7 +2,7 @@ import { discoverRoutes, matchRoute, compilePattern, sortBySpecificity } from '.
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
+ import { checkRouteRateLimits } from './ratelimit.js';
6
6
  import { sealSession, buildSessionSetCookie, buildSessionClearCookie } from '../session/index.js';
7
7
  import { flattenHeaders } from '../session/helpers.js';
8
8
  import { validateRequestSchema } from '../validation.js';
@@ -71,17 +71,93 @@ class ApiRouter {
71
71
  return matchRoute(this._routes, method, path);
72
72
  }
73
73
 
74
+ async forward(
75
+ { method: fwdMethod, path: fwdPath, params: fwdParams, body: fwdBody, query: fwdQuery },
76
+ callerCtx,
77
+ ) {
78
+ const matched = matchRoute(this._routes, fwdMethod, fwdPath);
79
+ if (!matched) {
80
+ return {
81
+ status: 404,
82
+ error: { code: ErrorCodes.NOT_FOUND, message: `No route for ${fwdMethod} ${fwdPath}` },
83
+ };
84
+ }
85
+ const { route, params: matchedParams } = matched;
86
+
87
+ const mergedQuery = {
88
+ ...(callerCtx.req?.query ?? {}),
89
+ ...(fwdQuery ?? {}),
90
+ ...matchedParams,
91
+ ...(fwdParams ?? {}),
92
+ };
93
+ const reqBody = fwdBody !== undefined ? fwdBody : callerCtx.req?.body;
94
+ const validated = validateRequestSchema(route.config.schema, mergedQuery, reqBody);
95
+ if (validated.error) {
96
+ return { status: 400, error: validated.error };
97
+ }
98
+
99
+ const reqInfo = {
100
+ id: callerCtx.req?.id,
101
+ ip: callerCtx.req?.ip ?? '127.0.0.1',
102
+ method: fwdMethod,
103
+ path: fwdPath,
104
+ query: validated.query,
105
+ body: validated.body,
106
+ rawBody: callerCtx.req?.rawBody,
107
+ headers: callerCtx.req?.headers ?? {},
108
+ cookies: callerCtx.req?.cookies ?? {},
109
+ session: callerCtx.req?.session,
110
+ };
111
+
112
+ const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern, fwdMethod);
113
+ const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
114
+ const ctx = this._buildCtx(reqInfo);
115
+ try {
116
+ const response = await chainedHandler(ctx);
117
+ response.status = response.status ?? (response.error ? 400 : 200);
118
+ return response;
119
+ } catch (err) {
120
+ this._log.error(`Forward handler error in ${fwdMethod} ${route.pattern}`, {
121
+ error: toErrorMessage(err),
122
+ });
123
+ const errorMessage =
124
+ this._mode === 'development'
125
+ ? err instanceof Error
126
+ ? err.stack || err.message
127
+ : String(err)
128
+ : 'An internal error occurred';
129
+ return {
130
+ status: 500,
131
+ error: { code: ErrorCodes.HANDLER_ERROR, message: errorMessage },
132
+ };
133
+ }
134
+ }
135
+
136
+ routesApi() {
137
+ return this._routes.map((route) => ({
138
+ method: route.method,
139
+ pattern: route.pattern,
140
+ tags: route.config._tags,
141
+ name: route.config._name,
142
+ description: route.config._description,
143
+ mcp: route.config._mcp,
144
+ schema: route.config.schema,
145
+ }));
146
+ }
147
+
148
+ buildContextApi(ctx) {
149
+ return {
150
+ forward: (args) => this.forward(args, ctx),
151
+ routes: () => this.routesApi(),
152
+ };
153
+ }
154
+
74
155
  async executeRoute(route, reqInfo) {
75
156
  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));
157
+ const checked = await checkRouteRateLimits(this._redis.client, route.config, reqInfo.ip);
158
+ if (!checked.allowed) {
159
+ const { policy, result } = checked.selected;
160
+ const retryAfter = Math.max(1, result.resetAt - Math.ceil(Date.now() / 1000));
85
161
  return {
86
162
  status: 429,
87
163
  error: {
@@ -89,9 +165,9 @@ class ApiRouter {
89
165
  message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
90
166
  },
91
167
  headers: {
92
- 'X-RateLimit-Limit': String(max),
168
+ 'X-RateLimit-Limit': String(policy.max),
93
169
  'X-RateLimit-Remaining': '0',
94
- 'X-RateLimit-Reset': String(rl.resetAt),
170
+ 'X-RateLimit-Reset': String(result.resetAt),
95
171
  'Retry-After': String(retryAfter),
96
172
  },
97
173
  };
@@ -198,15 +274,10 @@ class ApiRouter {
198
274
 
199
275
  // ── Rate limiting ──
200
276
  if (route.config._parsedRateLimit && this._redis) {
201
- const { max, windowSec } = route.config._parsedRateLimit;
202
- const rl = await checkRateLimit(this._redis.client, {
203
- key: route.config.ratelimit.key,
204
- ip,
205
- max,
206
- windowSec,
207
- });
208
- if (!rl.allowed) {
209
- const retryAfter = Math.max(1, rl.resetAt - Math.ceil(Date.now() / 1000));
277
+ const checked = await checkRouteRateLimits(this._redis.client, route.config, ip);
278
+ const { policy, result } = checked.selected;
279
+ if (!checked.allowed) {
280
+ const retryAfter = Math.max(1, result.resetAt - Math.ceil(Date.now() / 1000));
210
281
  sendJson(
211
282
  res,
212
283
  429,
@@ -217,17 +288,17 @@ class ApiRouter {
217
288
  },
218
289
  },
219
290
  {
220
- 'X-RateLimit-Limit': String(max),
291
+ 'X-RateLimit-Limit': String(policy.max),
221
292
  'X-RateLimit-Remaining': '0',
222
- 'X-RateLimit-Reset': String(rl.resetAt),
293
+ 'X-RateLimit-Reset': String(result.resetAt),
223
294
  'Retry-After': String(retryAfter),
224
295
  },
225
296
  );
226
297
  return true;
227
298
  }
228
- res.setHeader('X-RateLimit-Limit', String(max));
229
- res.setHeader('X-RateLimit-Remaining', String(rl.remaining));
230
- res.setHeader('X-RateLimit-Reset', String(rl.resetAt));
299
+ res.setHeader('X-RateLimit-Limit', String(policy.max));
300
+ res.setHeader('X-RateLimit-Remaining', String(result.remaining));
301
+ res.setHeader('X-RateLimit-Reset', String(result.resetAt));
231
302
  }
232
303
 
233
304
  // ── Body parsing control ──
@@ -9,11 +9,15 @@ const LIMIT_REGEX = /^(\d+)\s+per\s+(sec|min|hr|day)$/i;
9
9
 
10
10
  function parseRateLimit(limitStr) {
11
11
  if (!limitStr || typeof limitStr !== 'string') {
12
- throw new Error(`Invalid rate limit: expected "N per TIMERANGE", got ${JSON.stringify(limitStr)}`);
12
+ throw new Error(
13
+ `Invalid rate limit: expected "N per TIMERANGE", got ${JSON.stringify(limitStr)}`,
14
+ );
13
15
  }
14
16
  const match = limitStr.match(LIMIT_REGEX);
15
17
  if (!match) {
16
- throw new Error(`Invalid rate limit: expected "N per TIMERANGE" (sec|min|hr|day), got "${limitStr}"`);
18
+ throw new Error(
19
+ `Invalid rate limit: expected "N per TIMERANGE" (sec|min|hr|day), got "${limitStr}"`,
20
+ );
17
21
  }
18
22
  const max = parseInt(match[1], 10);
19
23
  if (max <= 0) {
@@ -35,7 +39,7 @@ return {count, ttl}
35
39
  `;
36
40
 
37
41
  async function checkRateLimit(redisClient, { key, ip, max, windowSec }) {
38
- const redisKey = `rl:${key}:${ip}`;
42
+ const redisKey = `rl:${key}:${windowSec}:${ip}`;
39
43
  const result = await redisClient.eval(LUA_INCR_EXPIRE, 1, redisKey, windowSec);
40
44
  const count = result[0];
41
45
  const ttl = result[1];
@@ -47,4 +51,36 @@ async function checkRateLimit(redisClient, { key, ip, max, windowSec }) {
47
51
  };
48
52
  }
49
53
 
50
- export { parseRateLimit, checkRateLimit };
54
+ function configuredPolicies(config) {
55
+ if (Array.isArray(config._parsedRateLimits)) return config._parsedRateLimits;
56
+ if (!config._parsedRateLimit) return [];
57
+ return [{ key: config.ratelimit.key, ...config._parsedRateLimit }];
58
+ }
59
+
60
+ async function checkRouteRateLimits(redisClient, config, ip) {
61
+ const policies = configuredPolicies(config);
62
+ const results = await Promise.all(
63
+ policies.map(async (policy) => ({
64
+ policy,
65
+ result: await checkRateLimit(redisClient, { ...policy, ip }),
66
+ })),
67
+ );
68
+ const denied = results.filter(({ result }) => !result.allowed);
69
+ const candidates = denied.length > 0 ? denied : results;
70
+ const selected = candidates.reduce((strictest, candidate) => {
71
+ if (!strictest) return candidate;
72
+ if (denied.length > 0) {
73
+ return candidate.result.resetAt > strictest.result.resetAt ? candidate : strictest;
74
+ }
75
+ const candidateRatio = candidate.result.remaining / candidate.policy.max;
76
+ const strictestRatio = strictest.result.remaining / strictest.policy.max;
77
+ return candidateRatio < strictestRatio ? candidate : strictest;
78
+ }, null);
79
+ return {
80
+ allowed: denied.length === 0,
81
+ selected,
82
+ results,
83
+ };
84
+ }
85
+
86
+ export { checkRateLimit, checkRouteRateLimits, parseRateLimit };
@@ -60,13 +60,50 @@ function buildRoutesFromModules(entries, { prefix = '', plugin } = {}) {
60
60
  );
61
61
  }
62
62
  if (config.ratelimit) {
63
- const rl = config.ratelimit;
64
- if (!rl.key || typeof rl.key !== 'string') {
63
+ const policies = Array.isArray(config.ratelimit) ? config.ratelimit : [config.ratelimit];
64
+ if (policies.length === 0) {
65
+ throw new Error(`Route ${method} ${urlPattern} (${filePath}) ratelimit cannot be empty`);
66
+ }
67
+ config._parsedRateLimits = policies.map((policy, index) => {
68
+ if (!policy?.key || typeof policy.key !== 'string') {
69
+ const suffix = policies.length > 1 ? `[${index}]` : '';
70
+ throw new Error(
71
+ `Route ${method} ${urlPattern} (${filePath}) ratelimit${suffix}.key must be a non-empty string`,
72
+ );
73
+ }
74
+ return { key: policy.key, ...parseRateLimit(policy.limit) };
75
+ });
76
+ // Preserve the existing internal field for integrations that inspect a
77
+ // route with one rate-limit policy.
78
+ config._parsedRateLimit = config._parsedRateLimits[0];
79
+ }
80
+ if (config.tags !== undefined) {
81
+ if (!Array.isArray(config.tags) || !config.tags.every((t) => typeof t === 'string')) {
82
+ throw new Error(
83
+ `Route ${method} ${urlPattern} (${filePath}) tags must be an array of strings`,
84
+ );
85
+ }
86
+ config._tags = config.tags;
87
+ }
88
+ if (config.name !== undefined) {
89
+ if (typeof config.name !== 'string') {
90
+ throw new Error(`Route ${method} ${urlPattern} (${filePath}) name must be a string`);
91
+ }
92
+ config._name = config.name;
93
+ }
94
+ if (config.description !== undefined) {
95
+ if (typeof config.description !== 'string') {
65
96
  throw new Error(
66
- `Route ${method} ${urlPattern} (${filePath}) ratelimit.key must be a non-empty string`,
97
+ `Route ${method} ${urlPattern} (${filePath}) description must be a string`,
67
98
  );
68
99
  }
69
- config._parsedRateLimit = parseRateLimit(rl.limit);
100
+ config._description = config.description;
101
+ }
102
+ if (config.mcp !== undefined) {
103
+ if (typeof config.mcp !== 'object' || config.mcp === null) {
104
+ throw new Error(`Route ${method} ${urlPattern} (${filePath}) mcp must be an object`);
105
+ }
106
+ config._mcp = config.mcp;
70
107
  }
71
108
  routes.push({
72
109
  method,