@holo-js/forms 0.1.1 → 0.1.3
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/dist/chunk-663LOLKH.mjs +421 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.mjs +63 -3
- package/dist/index.d.ts +41 -5
- package/dist/index.mjs +1 -1
- package/package.json +10 -2
- package/dist/chunk-Z6TEFT7S.mjs +0 -124
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// src/contracts.ts
|
|
2
|
+
import {
|
|
3
|
+
createErrorBag,
|
|
4
|
+
defineSchema,
|
|
5
|
+
isValidationSchema,
|
|
6
|
+
validate as validateInput
|
|
7
|
+
} from "@holo-js/validation";
|
|
8
|
+
|
|
9
|
+
// src/security.ts
|
|
10
|
+
var securityModulePromise;
|
|
11
|
+
function isMissingOptionalPackageError(error) {
|
|
12
|
+
if (!(error instanceof Error)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return [
|
|
16
|
+
/Cannot find package ['"]?@holo-js\/security['"]?/,
|
|
17
|
+
/Cannot find module ['"]?@holo-js\/security['"]?/,
|
|
18
|
+
/Failed to resolve module specifier ['"]?@holo-js\/security['"]?/,
|
|
19
|
+
/Failed to load url ['"]?@holo-js\/security['"]?/,
|
|
20
|
+
/Could not resolve ['"]?@holo-js\/security['"]?/
|
|
21
|
+
].some((pattern) => pattern.test(error.message));
|
|
22
|
+
}
|
|
23
|
+
function createMissingSecurityPackageError() {
|
|
24
|
+
return new FormContractError(
|
|
25
|
+
"[@holo-js/forms] Security-aware form options require the optional @holo-js/security package to be installed."
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
async function importSecurityModule() {
|
|
29
|
+
const specifier = ["@holo-js", "security"].join("/");
|
|
30
|
+
if (typeof process !== "undefined" && process.env && process.env.VITEST) {
|
|
31
|
+
return await import(
|
|
32
|
+
/* @vite-ignore */
|
|
33
|
+
specifier
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return await import(specifier);
|
|
37
|
+
}
|
|
38
|
+
async function loadSecurityModule() {
|
|
39
|
+
const runtime = globalThis;
|
|
40
|
+
if (runtime.__holoFormsSecurityModule__) {
|
|
41
|
+
return runtime.__holoFormsSecurityModule__;
|
|
42
|
+
}
|
|
43
|
+
securityModulePromise ??= (runtime.__holoFormsSecurityImport__ ? runtime.__holoFormsSecurityImport__() : importSecurityModule()).then((module) => module).catch(async (error) => {
|
|
44
|
+
securityModulePromise = void 0;
|
|
45
|
+
if (isMissingOptionalPackageError(error)) {
|
|
46
|
+
throw createMissingSecurityPackageError();
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
});
|
|
50
|
+
return await securityModulePromise;
|
|
51
|
+
}
|
|
52
|
+
function resetSecurityModuleCache() {
|
|
53
|
+
securityModulePromise = void 0;
|
|
54
|
+
}
|
|
55
|
+
function parseCookieHeader(header) {
|
|
56
|
+
const decodeCookiePart = (value) => {
|
|
57
|
+
try {
|
|
58
|
+
return decodeURIComponent(value);
|
|
59
|
+
} catch {
|
|
60
|
+
return void 0;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
return Object.freeze(Object.fromEntries(
|
|
64
|
+
header.split(";").map((segment) => segment.trim()).filter(Boolean).map((segment) => {
|
|
65
|
+
const separator = segment.indexOf("=");
|
|
66
|
+
if (separator <= 0) {
|
|
67
|
+
return void 0;
|
|
68
|
+
}
|
|
69
|
+
const key = decodeCookiePart(segment.slice(0, separator));
|
|
70
|
+
const value = decodeCookiePart(segment.slice(separator + 1));
|
|
71
|
+
if (!key || typeof value === "undefined") {
|
|
72
|
+
return void 0;
|
|
73
|
+
}
|
|
74
|
+
return [
|
|
75
|
+
key,
|
|
76
|
+
value
|
|
77
|
+
];
|
|
78
|
+
}).filter((entry) => !!entry)
|
|
79
|
+
));
|
|
80
|
+
}
|
|
81
|
+
function isRootSecurityError(error) {
|
|
82
|
+
const candidate = error;
|
|
83
|
+
return error instanceof Error && typeof candidate?.status === "number" && (candidate.status === 419 || candidate.status === 429);
|
|
84
|
+
}
|
|
85
|
+
var formsSecurityInternals = {
|
|
86
|
+
createMissingSecurityPackageError,
|
|
87
|
+
isMissingOptionalPackageError,
|
|
88
|
+
isRootSecurityError,
|
|
89
|
+
parseCookieHeader,
|
|
90
|
+
resetSecurityModuleCache
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// src/contracts.ts
|
|
94
|
+
var FormContractError = class extends Error {
|
|
95
|
+
constructor(message) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = "FormContractError";
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
function schema(shapeOrSchema) {
|
|
101
|
+
const base = isValidationSchema(shapeOrSchema) ? shapeOrSchema : defineSchema(shapeOrSchema);
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
...base,
|
|
104
|
+
mode: "form"
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function isFormSchema(value) {
|
|
108
|
+
return isValidationSchema(value) && value.mode === "form";
|
|
109
|
+
}
|
|
110
|
+
function normalizeStatus(value, fallback) {
|
|
111
|
+
if (typeof value === "undefined") {
|
|
112
|
+
return fallback;
|
|
113
|
+
}
|
|
114
|
+
if (!Number.isInteger(value) || value < 100) {
|
|
115
|
+
throw new FormContractError("HTTP status codes must be integers greater than or equal to 100.");
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
function serializeSubmissionState(valid, values, errors) {
|
|
120
|
+
return Object.freeze({
|
|
121
|
+
valid,
|
|
122
|
+
submitted: true,
|
|
123
|
+
values,
|
|
124
|
+
errors: errors.flatten()
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function createSubmission(valid, values, errors, failureStatus = 422) {
|
|
128
|
+
const normalizedFailureStatus = normalizeStatus(failureStatus, 422);
|
|
129
|
+
const serialize = () => serializeSubmissionState(valid, values, errors);
|
|
130
|
+
const failure = () => ({
|
|
131
|
+
ok: false,
|
|
132
|
+
status: normalizedFailureStatus,
|
|
133
|
+
valid: false,
|
|
134
|
+
values,
|
|
135
|
+
errors: errors.flatten()
|
|
136
|
+
});
|
|
137
|
+
const success = (payload, status) => ({
|
|
138
|
+
ok: true,
|
|
139
|
+
status: normalizeStatus(status, 200),
|
|
140
|
+
data: payload
|
|
141
|
+
});
|
|
142
|
+
if (valid) {
|
|
143
|
+
const data = values;
|
|
144
|
+
return Object.freeze({
|
|
145
|
+
valid: true,
|
|
146
|
+
submitted: true,
|
|
147
|
+
data,
|
|
148
|
+
values: data,
|
|
149
|
+
errors,
|
|
150
|
+
serialize,
|
|
151
|
+
success,
|
|
152
|
+
fail(status) {
|
|
153
|
+
const payload = failure();
|
|
154
|
+
return {
|
|
155
|
+
...payload,
|
|
156
|
+
status: normalizeStatus(status, payload.status)
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
valid: false,
|
|
163
|
+
submitted: true,
|
|
164
|
+
data: void 0,
|
|
165
|
+
values,
|
|
166
|
+
errors,
|
|
167
|
+
serialize,
|
|
168
|
+
success,
|
|
169
|
+
fail(status) {
|
|
170
|
+
const payload = failure();
|
|
171
|
+
return {
|
|
172
|
+
...payload,
|
|
173
|
+
status: normalizeStatus(status, payload.status)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function createSuccessfulSubmission(schemaDefinition, data) {
|
|
179
|
+
void schemaDefinition;
|
|
180
|
+
return createSubmission(true, data, createErrorBag());
|
|
181
|
+
}
|
|
182
|
+
function createFailedSubmission(schemaDefinition, values, flattenedErrors, status = 422) {
|
|
183
|
+
void schemaDefinition;
|
|
184
|
+
const normalizedStatus = normalizeStatus(status, 422);
|
|
185
|
+
return createSubmission(
|
|
186
|
+
false,
|
|
187
|
+
values,
|
|
188
|
+
createErrorBag(flattenedErrors),
|
|
189
|
+
normalizedStatus
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
function isRequestInput(input) {
|
|
193
|
+
return typeof Request !== "undefined" && input instanceof Request;
|
|
194
|
+
}
|
|
195
|
+
function isRequestLikeBody(value) {
|
|
196
|
+
if (typeof value === "string" || value instanceof URLSearchParams) {
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
if (typeof FormData !== "undefined" && value instanceof FormData) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (typeof Blob !== "undefined" && value instanceof Blob) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
if (typeof ArrayBuffer !== "undefined" && value instanceof ArrayBuffer) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
if (ArrayBuffer.isView(value)) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
return !!value && typeof value === "object" && (Symbol.asyncIterator in value || "pipe" in value || "getReader" in value);
|
|
212
|
+
}
|
|
213
|
+
function isHeadersTupleArray(value) {
|
|
214
|
+
return Array.isArray(value) && value.every((entry) => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && typeof entry[1] === "string");
|
|
215
|
+
}
|
|
216
|
+
function isRequestLikeHeaders(value) {
|
|
217
|
+
return value instanceof Headers || isHeadersTupleArray(value) || !!value && typeof value === "object";
|
|
218
|
+
}
|
|
219
|
+
function normalizeRequestHeaders(input) {
|
|
220
|
+
if (input instanceof Headers) {
|
|
221
|
+
return new Headers(input);
|
|
222
|
+
}
|
|
223
|
+
const headers = new Headers();
|
|
224
|
+
if (isHeadersTupleArray(input)) {
|
|
225
|
+
for (const [name, value] of input) {
|
|
226
|
+
headers.append(name, value);
|
|
227
|
+
}
|
|
228
|
+
return headers;
|
|
229
|
+
}
|
|
230
|
+
if (input && typeof input === "object") {
|
|
231
|
+
for (const [name, value] of Object.entries(input)) {
|
|
232
|
+
if (typeof value === "string") {
|
|
233
|
+
headers.append(name, value);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (Array.isArray(value)) {
|
|
237
|
+
const separator = name.toLowerCase() === "cookie" ? "; " : ",";
|
|
238
|
+
const joined = value.filter((entry) => typeof entry === "string").join(separator);
|
|
239
|
+
if (joined) {
|
|
240
|
+
headers.append(name, joined);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return headers;
|
|
246
|
+
}
|
|
247
|
+
function extractRequestLikeUrl(input, headers) {
|
|
248
|
+
const url = input.web?.request?.url ?? (typeof input.url === "string" ? input.url : input.url?.toString()) ?? (typeof input.req === "object" && !(input.req instanceof Request) ? input.req.url : void 0) ?? input.node?.req?.url ?? input.path ?? "/";
|
|
249
|
+
try {
|
|
250
|
+
return new URL(url).toString();
|
|
251
|
+
} catch {
|
|
252
|
+
const protocol = headers.get("x-forwarded-proto") ?? "http";
|
|
253
|
+
const host = headers.get("x-forwarded-host") ?? headers.get("host") ?? "localhost";
|
|
254
|
+
return new URL(url, `${protocol}://${host}`).toString();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function extractRequestLikeMethod(input) {
|
|
258
|
+
return input.web?.request?.method ?? input.method ?? (typeof input.req === "object" && !(input.req instanceof Request) ? input.req.method : void 0) ?? input.node?.req?.method ?? "GET";
|
|
259
|
+
}
|
|
260
|
+
function extractRequestLikeBody(input, headers, method) {
|
|
261
|
+
if (method === "GET" || method === "HEAD") {
|
|
262
|
+
return void 0;
|
|
263
|
+
}
|
|
264
|
+
const rawBody = input.body ?? (typeof input.req === "object" && !(input.req instanceof Request) ? input.req.body : void 0) ?? input.node?.req?.body ?? input.node?.req;
|
|
265
|
+
if (typeof rawBody === "undefined" || rawBody === null) {
|
|
266
|
+
return void 0;
|
|
267
|
+
}
|
|
268
|
+
if (isRequestLikeBody(rawBody)) {
|
|
269
|
+
return rawBody;
|
|
270
|
+
}
|
|
271
|
+
if (typeof rawBody === "object") {
|
|
272
|
+
if (!headers.has("content-type")) {
|
|
273
|
+
headers.set("content-type", "application/json");
|
|
274
|
+
}
|
|
275
|
+
return JSON.stringify(rawBody);
|
|
276
|
+
}
|
|
277
|
+
return String(rawBody);
|
|
278
|
+
}
|
|
279
|
+
function isRequestLikeInput(input) {
|
|
280
|
+
if (!input || typeof input !== "object") {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
const candidate = input;
|
|
284
|
+
return candidate.web?.request instanceof Request || candidate.req instanceof Request || typeof candidate.method === "string" || typeof candidate.path === "string" || typeof candidate.url === "string" || candidate.url instanceof URL || isRequestLikeHeaders(candidate.headers) || !!candidate.req && typeof candidate.req === "object" || !!candidate.node?.req && typeof candidate.node.req === "object";
|
|
285
|
+
}
|
|
286
|
+
function normalizeRequestLikeInput(input) {
|
|
287
|
+
if (isRequestInput(input)) {
|
|
288
|
+
return input;
|
|
289
|
+
}
|
|
290
|
+
if (!isRequestLikeInput(input)) {
|
|
291
|
+
return void 0;
|
|
292
|
+
}
|
|
293
|
+
if (input.web?.request instanceof Request) {
|
|
294
|
+
return input.web.request;
|
|
295
|
+
}
|
|
296
|
+
if (input.req instanceof Request) {
|
|
297
|
+
return input.req;
|
|
298
|
+
}
|
|
299
|
+
const headers = normalizeRequestHeaders(
|
|
300
|
+
input.headers ?? (typeof input.req === "object" && !(input.req instanceof Request) ? input.req.headers : void 0) ?? input.node?.req?.headers
|
|
301
|
+
);
|
|
302
|
+
const method = extractRequestLikeMethod(input);
|
|
303
|
+
const body = extractRequestLikeBody(input, headers, method);
|
|
304
|
+
return new Request(extractRequestLikeUrl(input, headers), {
|
|
305
|
+
method,
|
|
306
|
+
headers,
|
|
307
|
+
body,
|
|
308
|
+
// Undici requires duplex when constructing a Request from a stream/async iterable in Node.
|
|
309
|
+
...body && typeof body === "object" && (Symbol.asyncIterator in body || "pipe" in body || "getReader" in body) ? { duplex: "half" } : {}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
async function createSecurityFailureSubmission(input, schemaDefinition, error) {
|
|
313
|
+
let values = {};
|
|
314
|
+
let flattenedErrors = {};
|
|
315
|
+
try {
|
|
316
|
+
const inspection = await validateInput(input.clone(), schemaDefinition);
|
|
317
|
+
if (inspection.valid) {
|
|
318
|
+
values = inspection.data;
|
|
319
|
+
} else {
|
|
320
|
+
values = inspection.values;
|
|
321
|
+
flattenedErrors = inspection.errors.flatten();
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
values = {};
|
|
325
|
+
flattenedErrors = {};
|
|
326
|
+
}
|
|
327
|
+
return createFailedSubmission(
|
|
328
|
+
schemaDefinition,
|
|
329
|
+
values,
|
|
330
|
+
{
|
|
331
|
+
...flattenedErrors,
|
|
332
|
+
_root: [
|
|
333
|
+
...flattenedErrors._root ?? [],
|
|
334
|
+
error.message
|
|
335
|
+
]
|
|
336
|
+
},
|
|
337
|
+
error.status
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
async function validate(input, schemaDefinition, options = {}) {
|
|
341
|
+
let validatedSubmission;
|
|
342
|
+
const usesSecurityOptions = options.csrf === true || typeof options.throttle === "string";
|
|
343
|
+
const normalizedRequestInput = usesSecurityOptions ? normalizeRequestLikeInput(input) : void 0;
|
|
344
|
+
const validationInput = normalizedRequestInput ?? input;
|
|
345
|
+
if (usesSecurityOptions && !normalizedRequestInput) {
|
|
346
|
+
throw new FormContractError(
|
|
347
|
+
"Security-aware validate() options require a Request or request-like event input."
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (usesSecurityOptions) {
|
|
351
|
+
const request = normalizedRequestInput;
|
|
352
|
+
try {
|
|
353
|
+
const security = await loadSecurityModule();
|
|
354
|
+
const verificationRequest = (() => {
|
|
355
|
+
try {
|
|
356
|
+
return request.clone();
|
|
357
|
+
} catch {
|
|
358
|
+
return request;
|
|
359
|
+
}
|
|
360
|
+
})();
|
|
361
|
+
if (options.csrf === true) {
|
|
362
|
+
await security.csrf.verify(verificationRequest);
|
|
363
|
+
}
|
|
364
|
+
if (typeof options.throttle === "string") {
|
|
365
|
+
const inspection = await validateInput(request.clone(), schemaDefinition);
|
|
366
|
+
const throttleValues = inspection.valid ? inspection.data : inspection.values;
|
|
367
|
+
validatedSubmission = inspection.valid ? createSuccessfulSubmission(schemaDefinition, inspection.data) : createFailedSubmission(schemaDefinition, inspection.values, inspection.errors.flatten());
|
|
368
|
+
await security.rateLimit(options.throttle, {
|
|
369
|
+
request,
|
|
370
|
+
values: throttleValues
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (formsSecurityInternals.isRootSecurityError(error)) {
|
|
375
|
+
if (validatedSubmission) {
|
|
376
|
+
return createFailedSubmission(
|
|
377
|
+
schemaDefinition,
|
|
378
|
+
validatedSubmission.valid ? validatedSubmission.data : validatedSubmission.values,
|
|
379
|
+
{
|
|
380
|
+
...validatedSubmission.valid ? {} : validatedSubmission.errors.flatten(),
|
|
381
|
+
_root: [
|
|
382
|
+
...!validatedSubmission.valid ? validatedSubmission.errors.flatten()._root ?? [] : [],
|
|
383
|
+
error.message
|
|
384
|
+
]
|
|
385
|
+
},
|
|
386
|
+
error.status
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
return await createSecurityFailureSubmission(request, schemaDefinition, error);
|
|
390
|
+
}
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (validatedSubmission) {
|
|
395
|
+
return validatedSubmission;
|
|
396
|
+
}
|
|
397
|
+
const result = await validateInput(validationInput, schemaDefinition);
|
|
398
|
+
if (result.valid) {
|
|
399
|
+
return createSuccessfulSubmission(schemaDefinition, result.data);
|
|
400
|
+
}
|
|
401
|
+
return createFailedSubmission(schemaDefinition, result.values, result.errors.flatten());
|
|
402
|
+
}
|
|
403
|
+
var formsInternals = {
|
|
404
|
+
createSubmission,
|
|
405
|
+
isRequestLikeHeaders,
|
|
406
|
+
normalizeRequestLikeInput,
|
|
407
|
+
normalizeStatus,
|
|
408
|
+
normalizeRequestHeaders,
|
|
409
|
+
serializeSubmissionState
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
export {
|
|
413
|
+
formsSecurityInternals,
|
|
414
|
+
FormContractError,
|
|
415
|
+
schema,
|
|
416
|
+
isFormSchema,
|
|
417
|
+
createSuccessfulSubmission,
|
|
418
|
+
createFailedSubmission,
|
|
419
|
+
validate,
|
|
420
|
+
formsInternals
|
|
421
|
+
};
|
package/dist/client.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ type ClientSubmitResult<TData> = FormSubmissionResult<TData> | SerializedFormSub
|
|
|
13
13
|
interface UseFormOptions<TData> {
|
|
14
14
|
readonly action?: string;
|
|
15
15
|
readonly method?: string;
|
|
16
|
+
readonly csrf?: boolean;
|
|
16
17
|
readonly validateOn?: ValidateOnMode;
|
|
17
18
|
readonly initialValues?: Partial<TData>;
|
|
18
19
|
readonly initialState?: SerializedFormSubmission<TData>;
|
|
@@ -28,7 +29,7 @@ interface FormFieldState<TValue> {
|
|
|
28
29
|
onBlur(): Promise<void>;
|
|
29
30
|
validate(): Promise<readonly string[]>;
|
|
30
31
|
}
|
|
31
|
-
type FormFieldTree<TData> = TData extends readonly unknown[] ? FormFieldState<TData> : TData extends PrimitiveLike ? FormFieldState<TData> : TData extends Record<string, unknown> ? {
|
|
32
|
+
type FormFieldTree<TData> = [TData] extends [readonly unknown[]] ? FormFieldState<TData> : [TData] extends [PrimitiveLike] ? FormFieldState<TData> : [TData] extends [Record<string, unknown>] ? {
|
|
32
33
|
readonly [K in keyof TData]: FormFieldTree<TData[K]>;
|
|
33
34
|
} : FormFieldState<TData>;
|
|
34
35
|
interface UseFormResult<TData> {
|
package/dist/client.mjs
CHANGED
|
@@ -1,11 +1,61 @@
|
|
|
1
1
|
import {
|
|
2
|
+
FormContractError,
|
|
2
3
|
createFailedSubmission,
|
|
3
4
|
createSuccessfulSubmission,
|
|
5
|
+
formsSecurityInternals,
|
|
4
6
|
validate
|
|
5
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-663LOLKH.mjs";
|
|
6
8
|
|
|
7
9
|
// src/client.ts
|
|
8
10
|
import { createErrorBag } from "@holo-js/validation";
|
|
11
|
+
|
|
12
|
+
// src/client-security.ts
|
|
13
|
+
var securityClientModulePromise;
|
|
14
|
+
async function importSecurityClientModule() {
|
|
15
|
+
if (process.env.VITEST) {
|
|
16
|
+
return await import(
|
|
17
|
+
/* @vite-ignore */
|
|
18
|
+
"@holo-js/security/client"
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return await import("@holo-js/security/client");
|
|
22
|
+
}
|
|
23
|
+
async function loadSecurityClientModule() {
|
|
24
|
+
const runtime = globalThis;
|
|
25
|
+
if (runtime.__holoFormsSecurityClientModule__) {
|
|
26
|
+
return runtime.__holoFormsSecurityClientModule__;
|
|
27
|
+
}
|
|
28
|
+
securityClientModulePromise ??= (runtime.__holoFormsSecurityClientImport__ ? runtime.__holoFormsSecurityClientImport__() : importSecurityClientModule()).then((module) => module).catch(async (error) => {
|
|
29
|
+
securityClientModulePromise = void 0;
|
|
30
|
+
if (formsSecurityInternals.isMissingOptionalPackageError(error)) {
|
|
31
|
+
throw formsSecurityInternals.createMissingSecurityPackageError();
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
return await securityClientModulePromise;
|
|
36
|
+
}
|
|
37
|
+
async function getClientCsrfField() {
|
|
38
|
+
const runtime = globalThis;
|
|
39
|
+
if (!runtime.document || typeof runtime.document.cookie !== "string") {
|
|
40
|
+
throw new FormContractError(
|
|
41
|
+
"[@holo-js/forms] useForm({ csrf: true }) requires a browser-like document.cookie environment."
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const security = await loadSecurityClientModule();
|
|
45
|
+
const config = security.getSecurityClientConfig().csrf;
|
|
46
|
+
const value = formsSecurityInternals.parseCookieHeader(runtime.document.cookie)[config.cookie];
|
|
47
|
+
if (!value) {
|
|
48
|
+
throw new FormContractError(
|
|
49
|
+
`[@holo-js/forms] Missing CSRF cookie "${config.cookie}" required by useForm({ csrf: true }).`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
name: config.field,
|
|
54
|
+
value
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/client.ts
|
|
9
59
|
function isPlainObject(value) {
|
|
10
60
|
return !!value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof Blob);
|
|
11
61
|
}
|
|
@@ -323,6 +373,10 @@ async function defaultSubmitter(context) {
|
|
|
323
373
|
});
|
|
324
374
|
return await normalizeFetchResponse(response, context.values);
|
|
325
375
|
}
|
|
376
|
+
function isSafeMethod(method) {
|
|
377
|
+
const normalized = method.trim().toUpperCase();
|
|
378
|
+
return normalized === "GET" || normalized === "HEAD" || normalized === "OPTIONS" || normalized === "TRACE";
|
|
379
|
+
}
|
|
326
380
|
function useForm(schemaDefinition, options = {}) {
|
|
327
381
|
const initialValues = mergeValues(
|
|
328
382
|
normalizeObject(options.initialState?.values),
|
|
@@ -392,11 +446,17 @@ function useForm(schemaDefinition, options = {}) {
|
|
|
392
446
|
return local;
|
|
393
447
|
}
|
|
394
448
|
const submitter = options.submitter ?? defaultSubmitter;
|
|
449
|
+
const method = options.method ?? "POST";
|
|
450
|
+
const formData = buildFormData(state.values);
|
|
451
|
+
if (options.csrf === true && !isSafeMethod(method)) {
|
|
452
|
+
const csrfField = await getClientCsrfField();
|
|
453
|
+
formData.set(csrfField.name, csrfField.value);
|
|
454
|
+
}
|
|
395
455
|
const response = await submitter({
|
|
396
456
|
action: options.action,
|
|
397
|
-
method
|
|
457
|
+
method,
|
|
398
458
|
values: state.values,
|
|
399
|
-
formData
|
|
459
|
+
formData
|
|
400
460
|
});
|
|
401
461
|
return this.applyServerState(response);
|
|
402
462
|
} finally {
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { ErrorTree, ErrorTreeNode, FieldDefinition, FieldRule, InferSchemaData,
|
|
|
3
3
|
|
|
4
4
|
interface FormSchema<TShape extends SchemaInputShape = SchemaInputShape> extends ValidationSchema<TShape> {
|
|
5
5
|
readonly mode: 'form';
|
|
6
|
+
readonly fields: ValidationSchema<TShape>['fields'];
|
|
6
7
|
readonly $values?: Partial<InferSchemaData<TShape>>;
|
|
7
8
|
}
|
|
8
9
|
interface FormFailurePayload<TData> {
|
|
@@ -23,6 +24,35 @@ interface SerializedFormSubmission<TData> {
|
|
|
23
24
|
readonly values: Partial<TData> | TData;
|
|
24
25
|
readonly errors: Record<string, readonly string[]>;
|
|
25
26
|
}
|
|
27
|
+
interface FormSecurityOptions {
|
|
28
|
+
readonly csrf?: boolean;
|
|
29
|
+
readonly throttle?: string;
|
|
30
|
+
}
|
|
31
|
+
type RequestLikeHeaders = Headers | ReadonlyArray<readonly [string, string]> | Record<string, string | readonly string[] | undefined>;
|
|
32
|
+
interface FormRequestLikeInput {
|
|
33
|
+
readonly method?: string;
|
|
34
|
+
readonly path?: string;
|
|
35
|
+
readonly url?: string | URL;
|
|
36
|
+
readonly headers?: RequestLikeHeaders;
|
|
37
|
+
readonly body?: unknown;
|
|
38
|
+
readonly req?: Request | {
|
|
39
|
+
readonly method?: string;
|
|
40
|
+
readonly url?: string;
|
|
41
|
+
readonly headers?: RequestLikeHeaders;
|
|
42
|
+
readonly body?: unknown;
|
|
43
|
+
};
|
|
44
|
+
readonly node?: {
|
|
45
|
+
readonly req?: {
|
|
46
|
+
readonly method?: string;
|
|
47
|
+
readonly url?: string;
|
|
48
|
+
readonly headers?: RequestLikeHeaders;
|
|
49
|
+
readonly body?: unknown;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
readonly web?: {
|
|
53
|
+
readonly request?: Request;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
26
56
|
interface FormSubmissionSuccess<TData> {
|
|
27
57
|
readonly valid: true;
|
|
28
58
|
readonly submitted: true;
|
|
@@ -53,14 +83,20 @@ declare function schema<TShape extends SchemaInputShape>(shapeOrSchema: TShape |
|
|
|
53
83
|
declare function isFormSchema(value: unknown): value is FormSchema;
|
|
54
84
|
declare function normalizeStatus(value: number | undefined, fallback: number): number;
|
|
55
85
|
declare function serializeSubmissionState<TData>(valid: boolean, values: Partial<TData> | TData, errors: ValidationErrorBag<TData>): SerializedFormSubmission<TData>;
|
|
56
|
-
declare function createSubmission<TData>(valid: boolean, values: Partial<TData> | TData, errors: ValidationErrorBag<TData
|
|
57
|
-
declare function createSuccessfulSubmission<TShape extends SchemaInputShape
|
|
58
|
-
declare function createFailedSubmission<TShape extends SchemaInputShape
|
|
59
|
-
declare function
|
|
86
|
+
declare function createSubmission<TData>(valid: boolean, values: Partial<TData> | TData, errors: ValidationErrorBag<TData>, failureStatus?: number): FormSubmissionResult<TData>;
|
|
87
|
+
declare function createSuccessfulSubmission<TShape extends SchemaInputShape>(schemaDefinition: FormSchema<TShape>, data: InferSchemaData<TShape>): FormSubmissionSuccess<InferSchemaData<TShape>>;
|
|
88
|
+
declare function createFailedSubmission<TShape extends SchemaInputShape>(schemaDefinition: FormSchema<TShape>, values: Partial<InferSchemaData<TShape>>, flattenedErrors: Record<string, readonly string[]>, status?: number): FormSubmissionFailure<InferSchemaData<TShape>>;
|
|
89
|
+
declare function isRequestLikeHeaders(value: unknown): value is RequestLikeHeaders;
|
|
90
|
+
declare function normalizeRequestHeaders(input: unknown): Headers;
|
|
91
|
+
declare function normalizeRequestLikeInput(input: FormLikeValidationInput | FormRequestLikeInput | null | undefined): Request | undefined;
|
|
92
|
+
declare function validate<TShape extends SchemaInputShape>(input: FormLikeValidationInput | FormRequestLikeInput, schemaDefinition: FormSchema<TShape>, options?: FormSecurityOptions): Promise<FormSubmissionResult<InferSchemaData<TShape>>>;
|
|
60
93
|
declare const formsInternals: {
|
|
61
94
|
createSubmission: typeof createSubmission;
|
|
95
|
+
isRequestLikeHeaders: typeof isRequestLikeHeaders;
|
|
96
|
+
normalizeRequestLikeInput: typeof normalizeRequestLikeInput;
|
|
62
97
|
normalizeStatus: typeof normalizeStatus;
|
|
98
|
+
normalizeRequestHeaders: typeof normalizeRequestHeaders;
|
|
63
99
|
serializeSubmissionState: typeof serializeSubmissionState;
|
|
64
100
|
};
|
|
65
101
|
|
|
66
|
-
export { FormContractError, type FormFailurePayload, type FormSchema, type FormSubmissionFailure, type FormSubmissionResult, type FormSubmissionSuccess, type FormSuccessPayload, type SerializedFormSubmission, createFailedSubmission, createSuccessfulSubmission, formsInternals, isFormSchema, schema, validate };
|
|
102
|
+
export { FormContractError, type FormFailurePayload, type FormRequestLikeInput, type FormSchema, type FormSecurityOptions, type FormSubmissionFailure, type FormSubmissionResult, type FormSubmissionSuccess, type FormSuccessPayload, type SerializedFormSubmission, createFailedSubmission, createSuccessfulSubmission, formsInternals, isFormSchema, schema, validate };
|
package/dist/index.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@holo-js/forms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Holo-JS Framework - forms-first validation and submission contracts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,15 @@
|
|
|
28
28
|
"test": "vitest --run"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@holo-js/validation": "^0.1.
|
|
31
|
+
"@holo-js/validation": "^0.1.3"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@holo-js/security": "^0.1.3"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"@holo-js/security": {
|
|
38
|
+
"optional": true
|
|
39
|
+
}
|
|
32
40
|
},
|
|
33
41
|
"devDependencies": {
|
|
34
42
|
"@types/node": "^22.10.2",
|
package/dist/chunk-Z6TEFT7S.mjs
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
// src/contracts.ts
|
|
2
|
-
import {
|
|
3
|
-
createErrorBag,
|
|
4
|
-
defineSchema,
|
|
5
|
-
isValidationSchema,
|
|
6
|
-
validate as validateInput
|
|
7
|
-
} from "@holo-js/validation";
|
|
8
|
-
var FormContractError = class extends Error {
|
|
9
|
-
constructor(message) {
|
|
10
|
-
super(message);
|
|
11
|
-
this.name = "FormContractError";
|
|
12
|
-
}
|
|
13
|
-
};
|
|
14
|
-
function schema(shapeOrSchema) {
|
|
15
|
-
const base = isValidationSchema(shapeOrSchema) ? shapeOrSchema : defineSchema(shapeOrSchema);
|
|
16
|
-
return Object.freeze({
|
|
17
|
-
...base,
|
|
18
|
-
mode: "form"
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
function isFormSchema(value) {
|
|
22
|
-
return isValidationSchema(value) && value.mode === "form";
|
|
23
|
-
}
|
|
24
|
-
function normalizeStatus(value, fallback) {
|
|
25
|
-
if (typeof value === "undefined") {
|
|
26
|
-
return fallback;
|
|
27
|
-
}
|
|
28
|
-
if (!Number.isInteger(value) || value < 100) {
|
|
29
|
-
throw new FormContractError("HTTP status codes must be integers greater than or equal to 100.");
|
|
30
|
-
}
|
|
31
|
-
return value;
|
|
32
|
-
}
|
|
33
|
-
function serializeSubmissionState(valid, values, errors) {
|
|
34
|
-
return Object.freeze({
|
|
35
|
-
valid,
|
|
36
|
-
submitted: true,
|
|
37
|
-
values,
|
|
38
|
-
errors: errors.flatten()
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
function createSubmission(valid, values, errors) {
|
|
42
|
-
const serialize = () => serializeSubmissionState(valid, values, errors);
|
|
43
|
-
const failure = () => ({
|
|
44
|
-
ok: false,
|
|
45
|
-
status: 422,
|
|
46
|
-
valid: false,
|
|
47
|
-
values,
|
|
48
|
-
errors: errors.flatten()
|
|
49
|
-
});
|
|
50
|
-
const success = (payload, status) => ({
|
|
51
|
-
ok: true,
|
|
52
|
-
status: normalizeStatus(status, 200),
|
|
53
|
-
data: payload
|
|
54
|
-
});
|
|
55
|
-
if (valid) {
|
|
56
|
-
const data = values;
|
|
57
|
-
return Object.freeze({
|
|
58
|
-
valid: true,
|
|
59
|
-
submitted: true,
|
|
60
|
-
data,
|
|
61
|
-
values: data,
|
|
62
|
-
errors,
|
|
63
|
-
serialize,
|
|
64
|
-
success,
|
|
65
|
-
fail(status) {
|
|
66
|
-
const payload = failure();
|
|
67
|
-
return {
|
|
68
|
-
...payload,
|
|
69
|
-
status: normalizeStatus(status, payload.status)
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
return Object.freeze({
|
|
75
|
-
valid: false,
|
|
76
|
-
submitted: true,
|
|
77
|
-
data: void 0,
|
|
78
|
-
values,
|
|
79
|
-
errors,
|
|
80
|
-
serialize,
|
|
81
|
-
success,
|
|
82
|
-
fail(status) {
|
|
83
|
-
const payload = failure();
|
|
84
|
-
return {
|
|
85
|
-
...payload,
|
|
86
|
-
status: normalizeStatus(status, payload.status)
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
function createSuccessfulSubmission(schemaDefinition, data) {
|
|
92
|
-
void schemaDefinition;
|
|
93
|
-
return createSubmission(true, data, createErrorBag());
|
|
94
|
-
}
|
|
95
|
-
function createFailedSubmission(schemaDefinition, values, flattenedErrors) {
|
|
96
|
-
void schemaDefinition;
|
|
97
|
-
return createSubmission(
|
|
98
|
-
false,
|
|
99
|
-
values,
|
|
100
|
-
createErrorBag(flattenedErrors)
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
async function validate(input, schemaDefinition) {
|
|
104
|
-
const result = await validateInput(input, schemaDefinition);
|
|
105
|
-
if (result.valid) {
|
|
106
|
-
return createSuccessfulSubmission(schemaDefinition, result.data);
|
|
107
|
-
}
|
|
108
|
-
return createFailedSubmission(schemaDefinition, result.values, result.errors.flatten());
|
|
109
|
-
}
|
|
110
|
-
var formsInternals = {
|
|
111
|
-
createSubmission,
|
|
112
|
-
normalizeStatus,
|
|
113
|
-
serializeSubmissionState
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
export {
|
|
117
|
-
FormContractError,
|
|
118
|
-
schema,
|
|
119
|
-
isFormSchema,
|
|
120
|
-
createSuccessfulSubmission,
|
|
121
|
-
createFailedSubmission,
|
|
122
|
-
validate,
|
|
123
|
-
formsInternals
|
|
124
|
-
};
|