arcway 0.1.25 → 0.1.26

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.
@@ -18,7 +18,7 @@ import { computeEntryChunks } from './chunk-graph.js';
18
18
  // Bump when the shape produced by `extractMetadata` / the serialized cache
19
19
  // payload changes in a way that would make an old cache entry decode into a
20
20
  // broken manifest. Prevents a stale v1 cache from ever satisfying a v2 build.
21
- const METADATA_VERSION = 3;
21
+ const METADATA_VERSION = 4;
22
22
 
23
23
  // Build a deterministic plan of the hydration entries that will be written
24
24
  // into the staging dir. Computed up-front so we can derive a cache key
@@ -39,6 +39,7 @@ function computeEntryChunks(metafile, clientDir) {
39
39
  const meta = outputs[cur];
40
40
  if (!meta) continue;
41
41
  for (const imp of meta.imports ?? []) {
42
+ if (imp.kind === 'dynamic-import') continue;
42
43
  const target = imp.path;
43
44
  if (!target || visited.has(target)) continue;
44
45
  visited.add(target);
@@ -36,6 +36,19 @@ const NAMED_WEIGHTS = {
36
36
  black: 900,
37
37
  heavy: 900,
38
38
  };
39
+ const NAMED_WEIGHT_KEYS = Object.keys(NAMED_WEIGHTS).sort((a, b) => b.length - a.length);
40
+
41
+ function resolveNamedWeight(baseName) {
42
+ if (!baseName) return void 0;
43
+ const normalized = baseName.toLowerCase().replace(/[\s._-]+/g, '');
44
+ for (const key of NAMED_WEIGHT_KEYS) {
45
+ if (normalized === key || normalized.endsWith(key)) {
46
+ return NAMED_WEIGHTS[key];
47
+ }
48
+ }
49
+ return void 0;
50
+ }
51
+
39
52
  async function scanFontDirectory(srcDir, publicDir) {
40
53
  const dirPath = path.join(publicDir, srcDir);
41
54
  let entries;
@@ -54,7 +67,7 @@ async function scanFontDirectory(srcDir, publicDir) {
54
67
  if (!isNaN(numericWeight) && numericWeight >= 1 && numericWeight <= 999) {
55
68
  weight = numericWeight;
56
69
  } else {
57
- weight = NAMED_WEIGHTS[baseName];
70
+ weight = resolveNamedWeight(baseName);
58
71
  }
59
72
  if (weight === void 0) continue;
60
73
  files.push({
@@ -225,7 +225,7 @@ function createPagesHandler(options) {
225
225
  if (manifest.errorBundle && !res.headersSent) {
226
226
  const errorMessage = devMode
227
227
  ? err instanceof Error
228
- ? err.message
228
+ ? err.stack || err.message
229
229
  : String(err)
230
230
  : 'An unexpected error occurred';
231
231
  await renderErrorPage(
@@ -270,7 +270,7 @@ async function renderDevBuildError(
270
270
  cacheVersion,
271
271
  projectReact,
272
272
  ) {
273
- const errorMessage = err instanceof Error ? err.message : String(err);
273
+ const errorMessage = err instanceof Error ? err.stack || err.message : String(err);
274
274
  if (manifest.errorBundle && !res.headersSent) {
275
275
  await renderErrorPage(
276
276
  manifest.errorBundle,
@@ -161,7 +161,7 @@ async function createLazyPagesContext(options) {
161
161
  minify,
162
162
  limit,
163
163
  }),
164
- syncViteHydrationEntries({ manifest, outDir }),
164
+ syncViteHydrationEntries({ manifest, outDir, fontFaceCss }),
165
165
  ]);
166
166
  if (errorBundles.error) manifest.errorBundle = errorBundles.error;
167
167
  if (errorBundles.notFound) manifest.notFoundBundle = errorBundles.notFound;
@@ -708,7 +708,7 @@ async function createLazyPagesContext(options) {
708
708
  if (viteDev) {
709
709
  const nextStylesPath = await discoverStyles(pagesDir);
710
710
  manifest.stylesPath = nextStylesPath ? path.resolve(nextStylesPath) : null;
711
- await syncViteHydrationEntries({ manifest, outDir });
711
+ await syncViteHydrationEntries({ manifest, outDir, fontFaceCss });
712
712
  }
713
713
 
714
714
  // ── Version bump + event emission ────────────────────────────────
@@ -4,7 +4,7 @@ function resolvePagesOutDir(config, rootDir, mode = 'production') {
4
4
  const pages = config?.pages ?? {};
5
5
  const isDev = mode === 'development';
6
6
  const prodOutDir = pages.outDir ?? path.resolve(rootDir, '.build/pages');
7
- const devOutDir = pages.devOutDir ?? path.resolve(rootDir, '.build-dev/pages');
7
+ const devOutDir = pages.devOutDir ?? path.resolve(rootDir, '.build/dev/pages');
8
8
  return isDev ? devOutDir : prodOutDir;
9
9
  }
10
10
 
@@ -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,
@@ -1,86 +0,0 @@
1
- import { useState, useCallback, useRef } from 'react';
2
- import { ArkErrors } from 'arktype';
3
-
4
- export default function useForm(options) {
5
- const [values, setValues] = useState(options.initialValues);
6
- const [errors, setErrors] = useState({});
7
- const [touched, setTouched] = useState({});
8
- const [isSubmitting, setIsSubmitting] = useState(false);
9
-
10
- const initialRef = useRef(options.initialValues);
11
- const schemaRef = useRef(options.schema);
12
- schemaRef.current = options.schema;
13
- const onSubmitRef = useRef(options.onSubmit);
14
- onSubmitRef.current = options.onSubmit;
15
- const valuesRef = useRef(values);
16
- valuesRef.current = values;
17
-
18
- const isDirty = Object.keys(initialRef.current).some(
19
- (key) => values[key] !== initialRef.current[key],
20
- );
21
-
22
- function setField(name, value) {
23
- setValues((prev) => ({ ...prev, [name]: value }));
24
- setTouched((prev) => ({ ...prev, [name]: true }));
25
- setErrors((prev) => {
26
- if (!prev[name]) return prev;
27
- const next = { ...prev };
28
- delete next[name];
29
- return next;
30
- });
31
- }
32
-
33
- const setError = useCallback((name, message) => {
34
- setErrors((prev) => ({ ...prev, [name]: message }));
35
- }, []);
36
-
37
- const handleSubmit = useCallback(async (e) => {
38
- e?.preventDefault?.();
39
- const currentValues = valuesRef.current;
40
- const schema = schemaRef.current;
41
-
42
- if (schema) {
43
- const result = schema(currentValues);
44
- if (result instanceof ArkErrors) {
45
- const fieldErrors = {};
46
- for (const err of result) {
47
- const key = String(err.path[0]);
48
- if (key && !fieldErrors[key]) {
49
- fieldErrors[key] = err.message;
50
- }
51
- }
52
- setErrors(fieldErrors);
53
- return;
54
- }
55
- }
56
-
57
- setErrors({});
58
- setIsSubmitting(true);
59
- try {
60
- await onSubmitRef.current(currentValues);
61
- } finally {
62
- setIsSubmitting(false);
63
- }
64
- }, []);
65
-
66
- const reset = useCallback((newValues) => {
67
- const resetTo = newValues ?? initialRef.current;
68
- if (newValues) initialRef.current = newValues;
69
- setValues(resetTo);
70
- setErrors({});
71
- setTouched({});
72
- setIsSubmitting(false);
73
- }, []);
74
-
75
- return {
76
- values,
77
- errors,
78
- touched,
79
- isDirty,
80
- isSubmitting,
81
- setField,
82
- setError,
83
- handleSubmit,
84
- reset,
85
- };
86
- }