arcway 0.1.24 → 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.
- package/client/hooks/swr-compat.js +25 -0
- package/client/hooks/use-api.js +1 -1
- 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 +15 -55
- package/client/router.js +51 -29
- package/client/ui/index.js +62 -380
- package/package.json +1 -1
- package/server/bin/commands/build.js +3 -0
- package/server/build.js +1 -1
- package/server/config/modules/pages.js +2 -2
- package/server/index.js +8 -33
- package/server/pages/build-client.js +1 -1
- package/server/pages/chunk-graph.js +1 -0
- package/server/pages/fonts.js +14 -1
- package/server/pages/handler.js +2 -2
- package/server/pages/lazy-context.js +2 -2
- package/server/pages/out-dir.js +1 -1
- 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/client/hooks/use-form.js +0 -86
|
@@ -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 =
|
|
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);
|
package/server/pages/fonts.js
CHANGED
|
@@ -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 =
|
|
70
|
+
weight = resolveNamedWeight(baseName);
|
|
58
71
|
}
|
|
59
72
|
if (weight === void 0) continue;
|
|
60
73
|
files.push({
|
package/server/pages/handler.js
CHANGED
|
@@ -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 ────────────────────────────────
|
package/server/pages/out-dir.js
CHANGED
|
@@ -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
|
|
7
|
+
const devOutDir = pages.devOutDir ?? path.resolve(rootDir, '.build/dev/pages');
|
|
8
8
|
return isDev ? devOutDir : prodOutDir;
|
|
9
9
|
}
|
|
10
10
|
|
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/client/hooks/use-form.js
DELETED
|
@@ -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
|
-
}
|