@oomfware/forms 0.1.0

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.
@@ -0,0 +1,469 @@
1
+ import { createInjectionKey } from '@oomfware/fetch-router';
2
+ import { getContext } from '@oomfware/fetch-router/middlewares/async-context';
3
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
4
+
5
+ import { ValidationError } from './errors.ts';
6
+ import {
7
+ createFieldProxy,
8
+ deepSet,
9
+ flattenIssues,
10
+ normalizeIssue,
11
+ type InternalFormIssue,
12
+ } from './form-utils.ts';
13
+ import type { MaybePromise } from './types.ts';
14
+
15
+ // #region types
16
+
17
+ export interface FormInput {
18
+ [key: string]: MaybeArray<string | number | boolean | File | FormInput>;
19
+ }
20
+
21
+ type MaybeArray<T> = T | T[];
22
+
23
+ export interface FormIssue {
24
+ message: string;
25
+ path: (string | number)[];
26
+ }
27
+
28
+ /**
29
+ * the issue creator proxy passed to form callbacks.
30
+ * allows creating field-specific validation issues via property access.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * form(schema, async (data, issue) => {
35
+ * if (emailTaken(data.email)) {
36
+ * invalid(issue.email('Email already in use'));
37
+ * }
38
+ * // nested fields: issue.user.profile.name('Invalid name')
39
+ * // array fields: issue.items[0].name('Invalid item name')
40
+ * });
41
+ * ```
42
+ */
43
+ export type InvalidField<T> = ((message: string) => StandardSchemaV1.Issue) & {
44
+ [K in keyof T]-?: T[K] extends (infer U)[]
45
+ ? InvalidFieldArray<U>
46
+ : T[K] extends object
47
+ ? InvalidField<T[K]>
48
+ : (message: string) => StandardSchemaV1.Issue;
49
+ };
50
+
51
+ type InvalidFieldArray<T> = {
52
+ [index: number]: T extends object ? InvalidField<T> : (message: string) => StandardSchemaV1.Issue;
53
+ } & ((message: string) => StandardSchemaV1.Issue);
54
+
55
+ /**
56
+ * internal info attached to a form instance.
57
+ * used by the forms() middleware to identify and process forms.
58
+ */
59
+ export interface FormInfo {
60
+ type: 'form';
61
+ /** the schema, if any */
62
+ schema: StandardSchemaV1 | null;
63
+ /** the handler function */
64
+ fn: (data: any, issue: any) => MaybePromise<any>;
65
+ }
66
+
67
+ /**
68
+ * form config stored by the forms() middleware.
69
+ */
70
+ export interface FormConfig {
71
+ /** the form id, derived from registration name */
72
+ id: string;
73
+ }
74
+
75
+ /**
76
+ * form state stored by the forms() middleware.
77
+ */
78
+ export interface FormState<Input = unknown, Output = unknown> {
79
+ /** the submitted input data (for repopulating form on error) */
80
+ input?: Input;
81
+ /** validation issues, flattened by path */
82
+ issues?: Record<string, InternalFormIssue[]>;
83
+ /** the handler result (if successful) */
84
+ result?: Output;
85
+ }
86
+
87
+ /**
88
+ * the form store holds registered forms, their configs, and state.
89
+ */
90
+ export interface FormStore {
91
+ /** map of form instance to config */
92
+ configs: WeakMap<InternalForm<any, any>, FormConfig>;
93
+ /** state for each form instance */
94
+ state: WeakMap<InternalForm<any, any>, FormState>;
95
+ }
96
+
97
+ /**
98
+ * injection key for the form store.
99
+ */
100
+ export const FORM_STORE_KEY = createInjectionKey<FormStore>();
101
+
102
+ /**
103
+ * the return value of a form() function.
104
+ * can be spread onto a <form> element.
105
+ */
106
+ export interface Form<Input extends FormInput | void, Output> {
107
+ /** HTTP method */
108
+ readonly method: 'POST';
109
+ /** the form action URL */
110
+ readonly action: string;
111
+ /** the handler result, if submission was successful */
112
+ readonly result: Output | undefined;
113
+ /** access form fields using object notation */
114
+ readonly fields: FormFields<Input>;
115
+ /** spread this onto a <button> or <input type="submit"> */
116
+ readonly buttonProps: FormButtonProps;
117
+ }
118
+
119
+ /**
120
+ * internal form type with metadata.
121
+ * used internally by middleware; cast Form to this when accessing `__`.
122
+ */
123
+ export interface InternalForm<Input extends FormInput | void, Output> extends Form<Input, Output> {
124
+ /** internal form info, used by forms() middleware */
125
+ readonly __: FormInfo;
126
+ }
127
+
128
+ export interface FormButtonProps {
129
+ type: 'submit';
130
+ readonly formaction: string;
131
+ }
132
+
133
+ // #region field types
134
+
135
+ /** valid leaf value types for form fields */
136
+ export type FormFieldValue = string | string[] | number | boolean | File | File[];
137
+
138
+ /** guard to prevent infinite recursion when T is unknown or has an index signature */
139
+ type WillRecurseIndefinitely<T> = unknown extends T ? true : string extends keyof T ? true : false;
140
+
141
+ /** base methods available on all form fields */
142
+ export interface FormFieldMethods<T> {
143
+ /** get the current value */
144
+ value(): T | undefined;
145
+ /** set the value */
146
+ set(value: T): T;
147
+ /** get validation issues for this field */
148
+ issues(): FormIssue[] | undefined;
149
+ }
150
+
151
+ /** leaf field (primitives, files) with .as() method */
152
+ export type FormFieldLeaf<T extends FormFieldValue> = FormFieldMethods<T> & {
153
+ /** get props for an input element */
154
+ as(type: string, value?: string): Record<string, unknown>;
155
+ };
156
+
157
+ /** container field (objects, arrays) with allIssues() method */
158
+ type FormFieldContainer<T> = FormFieldMethods<T> & {
159
+ /** get all issues for this field and descendants */
160
+ allIssues(): FormIssue[] | undefined;
161
+ };
162
+
163
+ /** fallback field type when recursion would be infinite */
164
+ type FormFieldUnknown<T> = FormFieldMethods<T> & {
165
+ /** get all issues for this field and descendants */
166
+ allIssues(): FormIssue[] | undefined;
167
+ /** get props for an input element */
168
+ as(type: string, value?: string): Record<string, unknown>;
169
+ } & {
170
+ [key: string | number]: FormFieldUnknown<unknown>;
171
+ };
172
+
173
+ /**
174
+ * recursive type to build form fields structure with proxy access.
175
+ * preserves type information through the object hierarchy.
176
+ */
177
+ export type FormFields<T> = T extends void
178
+ ? Record<string, never>
179
+ : WillRecurseIndefinitely<T> extends true
180
+ ? FormFieldUnknown<T>
181
+ : NonNullable<T> extends string | number | boolean | File
182
+ ? FormFieldLeaf<NonNullable<T>>
183
+ : T extends string[] | File[]
184
+ ? FormFieldLeaf<T> & { [K in number]: FormFieldLeaf<T[number]> }
185
+ : T extends Array<infer U>
186
+ ? FormFieldContainer<T> & { [K in number]: FormFields<U> }
187
+ : FormFieldContainer<T> & { [K in keyof T]-?: FormFields<T[K]> };
188
+
189
+ // #endregion
190
+
191
+ // #region issue creator
192
+
193
+ /**
194
+ * creates an issue creator proxy that builds up paths for field-specific issues.
195
+ */
196
+ function createIssueCreator<T>(): InvalidField<T> {
197
+ return new Proxy((message: string) => createIssue(message), {
198
+ get(_target, prop) {
199
+ if (typeof prop === 'symbol') return undefined;
200
+ return createIssueProxy(prop, []);
201
+ },
202
+ }) as InvalidField<T>;
203
+
204
+ function createIssue(message: string, path: (string | number)[] = []): StandardSchemaV1.Issue {
205
+ return { message, path };
206
+ }
207
+
208
+ function createIssueProxy(
209
+ key: string | number,
210
+ path: (string | number)[],
211
+ ): (message: string) => StandardSchemaV1.Issue {
212
+ const newPath = [...path, key];
213
+
214
+ const issueFunc = (message: string) => createIssue(message, newPath);
215
+
216
+ return new Proxy(issueFunc, {
217
+ get(_target, prop) {
218
+ if (typeof prop === 'symbol') return undefined;
219
+
220
+ if (/^\d+$/.test(prop)) {
221
+ return createIssueProxy(parseInt(prop, 10), newPath);
222
+ }
223
+
224
+ return createIssueProxy(prop, newPath);
225
+ },
226
+ });
227
+ }
228
+ }
229
+
230
+ // #endregion
231
+
232
+ // #region form state access
233
+
234
+ /**
235
+ * get the form store from the current request context.
236
+ * @throws if called outside of a request context
237
+ */
238
+ export function getFormStore(): FormStore {
239
+ const context = getContext();
240
+ const store = context.store.inject(FORM_STORE_KEY);
241
+
242
+ if (!store) {
243
+ throw new Error('form store not found. make sure the forms() middleware is installed.');
244
+ }
245
+
246
+ return store;
247
+ }
248
+
249
+ /**
250
+ * get config for a specific form instance.
251
+ * @throws if form is not registered with forms() middleware
252
+ */
253
+ function getFormConfig(form: InternalForm<any, any>): FormConfig {
254
+ const store = getFormStore();
255
+ const config = store.configs.get(form);
256
+
257
+ if (!config) {
258
+ throw new Error('form not registered. make sure to pass it to the forms() middleware.');
259
+ }
260
+
261
+ return config;
262
+ }
263
+
264
+ /**
265
+ * get state for a specific form instance.
266
+ */
267
+ export function getFormState<Input, Output>(
268
+ form: InternalForm<any, any>,
269
+ ): FormState<Input, Output> | undefined {
270
+ const store = getFormStore();
271
+ return store.state.get(form) as FormState<Input, Output> | undefined;
272
+ }
273
+
274
+ /**
275
+ * set state for a specific form instance.
276
+ */
277
+ export function setFormState<Input, Output>(
278
+ form: InternalForm<any, any>,
279
+ state: FormState<Input, Output>,
280
+ ): void {
281
+ const store = getFormStore();
282
+ store.state.set(form, state);
283
+ }
284
+
285
+ // #endregion
286
+
287
+ // #region form function
288
+
289
+ /**
290
+ * creates a form without validation.
291
+ */
292
+ export function form<Output>(fn: () => MaybePromise<Output>): Form<void, Output>;
293
+
294
+ /**
295
+ * creates a form with unchecked input (no validation).
296
+ */
297
+ export function form<Input extends FormInput, Output>(
298
+ validate: 'unchecked',
299
+ fn: (data: Input, issue: InvalidField<Input>) => MaybePromise<Output>,
300
+ ): Form<Input, Output>;
301
+
302
+ /**
303
+ * creates a form with Standard Schema validation.
304
+ */
305
+ export function form<Schema extends StandardSchemaV1<FormInput, Record<string, unknown>>, Output>(
306
+ validate: Schema,
307
+ fn: (
308
+ data: StandardSchemaV1.InferOutput<Schema>,
309
+ issue: InvalidField<StandardSchemaV1.InferInput<Schema>>,
310
+ ) => MaybePromise<Output>,
311
+ ): Form<StandardSchemaV1.InferInput<Schema>, Output>;
312
+
313
+ export function form(
314
+ validateOrFn: StandardSchemaV1 | 'unchecked' | (() => MaybePromise<unknown>),
315
+ maybeFn?: (data: any, issue: any) => MaybePromise<unknown>,
316
+ ): Form<any, any> {
317
+ const fn = (maybeFn ?? validateOrFn) as (data: any, issue: any) => MaybePromise<unknown>;
318
+
319
+ const schema: StandardSchemaV1 | null =
320
+ !maybeFn || validateOrFn === 'unchecked' ? null : (validateOrFn as StandardSchemaV1);
321
+
322
+ const instance = {} as InternalForm<any, any>;
323
+
324
+ const info: FormInfo = {
325
+ type: 'form',
326
+ schema,
327
+ fn,
328
+ };
329
+
330
+ // method
331
+ Object.defineProperty(instance, 'method', {
332
+ value: 'POST',
333
+ enumerable: true,
334
+ });
335
+
336
+ // action - computed from form store
337
+ Object.defineProperty(instance, 'action', {
338
+ get() {
339
+ const config = getFormConfig(instance);
340
+ return `?__action=${config.id}`;
341
+ },
342
+ enumerable: true,
343
+ });
344
+
345
+ // result - from state store
346
+ Object.defineProperty(instance, 'result', {
347
+ get() {
348
+ return getFormState(instance)?.result;
349
+ },
350
+ });
351
+
352
+ // fields - proxy for field access
353
+ Object.defineProperty(instance, 'fields', {
354
+ get() {
355
+ return createFieldProxy(
356
+ {},
357
+ () => (getFormState(instance)?.input as Record<string, unknown>) ?? {},
358
+ (path, value) => {
359
+ const currentState = getFormState(instance) ?? { input: {} };
360
+ if (path.length === 0) {
361
+ setFormState(instance, { ...currentState, input: value });
362
+ } else {
363
+ const input = (currentState.input as Record<string, unknown>) ?? {};
364
+ deepSet(input, path.map(String), value);
365
+ setFormState(instance, { ...currentState, input });
366
+ }
367
+ },
368
+ () => getFormState(instance)?.issues ?? {},
369
+ );
370
+ },
371
+ });
372
+
373
+ // buttonProps
374
+ Object.defineProperty(instance, 'buttonProps', {
375
+ get() {
376
+ const config = getFormConfig(instance);
377
+ return {
378
+ type: 'submit' as const,
379
+ formaction: `?__action=${config.id}`,
380
+ };
381
+ },
382
+ });
383
+
384
+ // internal info
385
+ Object.defineProperty(instance, '__', {
386
+ value: info,
387
+ });
388
+
389
+ return instance;
390
+ }
391
+
392
+ // #endregion
393
+
394
+ // #region form processing
395
+
396
+ /**
397
+ * redacts sensitive fields (those starting with `_`) from form input.
398
+ * this prevents passwords and other sensitive data from being returned in form state.
399
+ */
400
+ function redactSensitiveFields(obj: Record<string, unknown>): Record<string, unknown> {
401
+ const result: Record<string, unknown> = {};
402
+
403
+ for (const key of Object.keys(obj)) {
404
+ if (key.startsWith('_')) continue;
405
+
406
+ const value = obj[key];
407
+
408
+ if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof File)) {
409
+ result[key] = redactSensitiveFields(value as Record<string, unknown>);
410
+ } else if (Array.isArray(value)) {
411
+ result[key] = value.map((item) =>
412
+ item !== null && typeof item === 'object' && !(item instanceof File)
413
+ ? redactSensitiveFields(item as Record<string, unknown>)
414
+ : item,
415
+ );
416
+ } else {
417
+ result[key] = value;
418
+ }
419
+ }
420
+
421
+ return result;
422
+ }
423
+
424
+ /**
425
+ * process a form submission.
426
+ * called by forms() middleware when a matching action is received.
427
+ */
428
+ export async function processForm(formInstance: InternalForm<any, any>, data: FormInput): Promise<FormState> {
429
+ const { schema, fn } = formInstance.__;
430
+
431
+ let validatedData = data;
432
+
433
+ // validate with schema if present
434
+ if (schema) {
435
+ const result = await schema['~standard'].validate(data);
436
+
437
+ if (result.issues) {
438
+ return {
439
+ result: undefined,
440
+ issues: flattenIssues(result.issues.map((issue) => normalizeIssue(issue, true))),
441
+ input: redactSensitiveFields(data),
442
+ };
443
+ }
444
+ validatedData = result.value as FormInput;
445
+ }
446
+
447
+ // run handler
448
+ const issue = createIssueCreator();
449
+
450
+ try {
451
+ return {
452
+ result: await fn(validatedData, issue),
453
+ issues: undefined,
454
+ input: undefined,
455
+ };
456
+ } catch (e) {
457
+ if (e instanceof ValidationError) {
458
+ return {
459
+ result: undefined,
460
+ issues: flattenIssues(e.issues.map((issue) => normalizeIssue(issue, true))),
461
+ input: redactSensitiveFields(data),
462
+ };
463
+ }
464
+
465
+ throw e;
466
+ }
467
+ }
468
+
469
+ // #endregion
@@ -0,0 +1,99 @@
1
+ import type { RouterMiddleware } from '@oomfware/fetch-router';
2
+
3
+ import { convertFormData } from './form-utils.ts';
4
+ import {
5
+ FORM_STORE_KEY,
6
+ processForm,
7
+ setFormState,
8
+ type Form,
9
+ type FormConfig,
10
+ type FormStore,
11
+ type InternalForm,
12
+ } from './form.ts';
13
+
14
+ // #region types
15
+
16
+ /**
17
+ * a record of form instances to register with the middleware.
18
+ */
19
+ export type FormDefinitions = Record<string, Form<any, any>>;
20
+
21
+ // #endregion
22
+
23
+ // #region middleware
24
+
25
+ /**
26
+ * creates a forms middleware that registers forms and handles form submissions.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * import { form, forms } from '@oomfware/forms';
31
+ * import * as v from 'valibot';
32
+ *
33
+ * const createUserForm = form(
34
+ * v.object({ name: v.string(), password: v.string() }),
35
+ * async (input, issue) => {
36
+ * // handle form submission
37
+ * },
38
+ * );
39
+ *
40
+ * router.map(routes.admin, {
41
+ * middleware: [forms({ createUserForm })],
42
+ * action() {
43
+ * return render(
44
+ * <form {...createUserForm}>
45
+ * <input {...createUserForm.fields.name.as('text')} required />
46
+ * </form>
47
+ * );
48
+ * },
49
+ * });
50
+ * ```
51
+ */
52
+ export function forms(definitions: FormDefinitions): RouterMiddleware {
53
+ const formConfig = new WeakMap<InternalForm<any, any>, FormConfig>();
54
+ const formsById = new Map<string, InternalForm<any, any>>();
55
+
56
+ for (const [name, formInstance] of Object.entries(definitions)) {
57
+ const f = formInstance as InternalForm<any, any>;
58
+
59
+ formConfig.set(f, { id: name });
60
+ formsById.set(name, f);
61
+ }
62
+
63
+ return async (context, next) => {
64
+ const { url, request, store } = context;
65
+
66
+ // create form store for this request
67
+ const formStore: FormStore = {
68
+ configs: formConfig,
69
+ state: new WeakMap(),
70
+ };
71
+
72
+ // inject form store into context
73
+ store.provide(FORM_STORE_KEY, formStore);
74
+
75
+ // check if this is a form submission
76
+ const action = url.searchParams.get('__action');
77
+
78
+ if (action && request.method === 'POST') {
79
+ // find the form
80
+ const formInstance = formsById.get(action);
81
+
82
+ if (formInstance) {
83
+ // parse form data
84
+ const formData = await request.formData();
85
+ const data = convertFormData(formData as unknown as FormData);
86
+
87
+ // process the form
88
+ const state = await processForm(formInstance, data as any);
89
+
90
+ // store the state
91
+ setFormState(formInstance, state);
92
+ }
93
+ }
94
+
95
+ return next(context);
96
+ };
97
+ }
98
+
99
+ // #endregion
@@ -0,0 +1 @@
1
+ export type MaybePromise<T> = T | Promise<T>;