@lacspace/form 1.0.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.
- package/LICENSE +51 -0
- package/README.md +103 -0
- package/dist/index.cjs +104 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +118 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +98 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Lacspace Free Licence
|
|
2
|
+
Version 1.0, August 2026
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 Lacspace
|
|
5
|
+
|
|
6
|
+
PREAMBLE
|
|
7
|
+
|
|
8
|
+
This software is published by Lacspace under the Lacspace Free Licence — a free,
|
|
9
|
+
permissive licence that lets you use this software for any purpose, including in
|
|
10
|
+
commercial products and services, at no cost. It grants the same freedoms as
|
|
11
|
+
common permissive open-source licences; the only condition is that this notice
|
|
12
|
+
travels with the software. The canonical, always-current text of this licence is
|
|
13
|
+
maintained at https://lacspace.com/licenses/lacspace-free-1.0
|
|
14
|
+
|
|
15
|
+
GRANT OF RIGHTS
|
|
16
|
+
|
|
17
|
+
Permission is hereby granted, free of charge, to any person or organisation
|
|
18
|
+
obtaining a copy of this software and its associated documentation and data files
|
|
19
|
+
(the "Software"), to deal in the Software without restriction, including without
|
|
20
|
+
limitation the rights to use, copy, modify, merge, publish, distribute,
|
|
21
|
+
sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
|
22
|
+
Software is furnished to do so, subject to the conditions below. These rights are
|
|
23
|
+
granted for any purpose, personal or commercial, and are perpetual, worldwide,
|
|
24
|
+
non-exclusive, and royalty-free.
|
|
25
|
+
|
|
26
|
+
CONDITIONS
|
|
27
|
+
|
|
28
|
+
The above copyright notice, this permission notice, and the name of this licence
|
|
29
|
+
("Lacspace Free Licence") shall be included in all copies or substantial portions
|
|
30
|
+
of the Software.
|
|
31
|
+
|
|
32
|
+
TRADEMARKS
|
|
33
|
+
|
|
34
|
+
This licence does not grant permission to use the trade names, trademarks, service
|
|
35
|
+
marks, logos, or product names of Lacspace, except as required to reproduce the
|
|
36
|
+
notice above or to describe the origin of the Software in a truthful manner.
|
|
37
|
+
|
|
38
|
+
DISCLAIMER OF WARRANTY AND LIMITATION OF LIABILITY
|
|
39
|
+
|
|
40
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
41
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
42
|
+
FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
43
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN
|
|
44
|
+
AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION
|
|
45
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
The Lacspace Free Licence is a source-available, permissive licence and is not (as
|
|
50
|
+
of this version) an OSI-approved licence. In substance it grants the same freedoms
|
|
51
|
+
as the MIT Licence. Learn more at https://lacspace.com/licenses
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# @lacspace/form
|
|
4
|
+
|
|
5
|
+
**Typed, validated, spam-protected form handling — built for Next.js Server Actions.**
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@lacspace/form)
|
|
8
|
+
[](https://github.com/lacspace/npm-packages/blob/main/LICENSE)
|
|
9
|
+
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
> Take a `FormData`, validate it against a schema, block bots with a honeypot + timing check, and get back **either your typed data or per-field errors ready to re-render**. Framework-agnostic, zero dependencies.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm i @lacspace/form @lacspace/validate
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`@lacspace/validate` is optional — any object with a `safeParse` (including zod) works.
|
|
21
|
+
|
|
22
|
+
## Use it — a complete contact form
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// app/actions.ts
|
|
26
|
+
"use server";
|
|
27
|
+
import { createForm } from "@lacspace/form";
|
|
28
|
+
import { v } from "@lacspace/validate";
|
|
29
|
+
|
|
30
|
+
const contact = createForm({
|
|
31
|
+
schema: v.object({
|
|
32
|
+
name: v.string().min(2),
|
|
33
|
+
email: v.string().email(),
|
|
34
|
+
message: v.string().min(10),
|
|
35
|
+
}),
|
|
36
|
+
honeypot: "company", // hidden field bots fill; humans never see it
|
|
37
|
+
minSubmitMs: 800, // reject sub-second (bot-speed) submissions
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export async function submit(prev: unknown, formData: FormData) {
|
|
41
|
+
const r = contact.action(prev, formData);
|
|
42
|
+
if (!r.ok) return r; // { errors, values } → re-render form
|
|
43
|
+
await sendEmail(r.data); // ✅ { name, email, message } fully typed
|
|
44
|
+
return { ok: true as const };
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
// app/contact/page.tsx
|
|
50
|
+
"use client";
|
|
51
|
+
import { useActionState } from "react";
|
|
52
|
+
import { submit } from "../actions";
|
|
53
|
+
import { honeypotProps, timestampValue } from "@lacspace/form";
|
|
54
|
+
|
|
55
|
+
export default function Contact() {
|
|
56
|
+
const [state, action] = useActionState(submit, null);
|
|
57
|
+
return (
|
|
58
|
+
<form action={action}>
|
|
59
|
+
<input name="name" defaultValue={state?.values?.name as string} />
|
|
60
|
+
{state?.errors?.name && <p>{state.errors.name}</p>}
|
|
61
|
+
|
|
62
|
+
<input name="email" defaultValue={state?.values?.email as string} />
|
|
63
|
+
{state?.errors?.email && <p>{state.errors.email}</p>}
|
|
64
|
+
|
|
65
|
+
<textarea name="message" defaultValue={state?.values?.message as string} />
|
|
66
|
+
{state?.errors?.message && <p>{state.errors.message}</p>}
|
|
67
|
+
|
|
68
|
+
{/* spam protection — one line each */}
|
|
69
|
+
<input {...honeypotProps("company")} />
|
|
70
|
+
<input type="hidden" name="_ts" defaultValue={timestampValue()} />
|
|
71
|
+
|
|
72
|
+
<button>Send</button>
|
|
73
|
+
{state?.ok && <p>Thanks — we'll be in touch!</p>}
|
|
74
|
+
</form>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## What you get
|
|
80
|
+
|
|
81
|
+
- **`createForm(opts)`** → `{ handle, action }` — `action` matches the `(prev, formData)` shape of `useActionState`, so it drops in with zero glue.
|
|
82
|
+
- **Typed result** — `{ ok: true, data }` or `{ ok: false, errors, values, spam? }`. `values` echoes what the user typed so re-renders keep their input.
|
|
83
|
+
- **`formDataToObject(fd)`** — repeated keys → arrays, files passed through, empty strings preserved.
|
|
84
|
+
- **Spam guard** — `honeypot` field + `minSubmitMs` timing heuristic, both optional. Internal fields (`_ts`, honeypot) are stripped before validation so your schema can stay `.strict()`.
|
|
85
|
+
- **`honeypotProps(name)`** + **`timestampValue()`** — client helpers, no React dependency.
|
|
86
|
+
|
|
87
|
+
Pairs with [`@lacspace/validate`](https://www.npmjs.com/package/@lacspace/validate), [`@lacspace/rate-limit`](https://www.npmjs.com/package/@lacspace/rate-limit) and [`@lacspace/mailer`](https://www.npmjs.com/package/@lacspace/mailer).
|
|
88
|
+
|
|
89
|
+
## Licensing
|
|
90
|
+
|
|
91
|
+
Free under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** — MIT-equivalent freedoms. Use it in personal and commercial projects at no cost; just keep the notice. See the **[Lacspace Licence Centre](https://lacspace.com/licenses)**.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
<div align="center">
|
|
96
|
+
|
|
97
|
+
**Part of the Lacspace ecosystem — zero-dependency, isomorphic TypeScript packages.**
|
|
98
|
+
|
|
99
|
+
[All packages ↗](https://lacspace.com/packages) · [npm org ↗](https://www.npmjs.com/org/lacspace) · [Licence Centre ↗](https://lacspace.com/licenses) · [GitHub ↗](https://github.com/lacspace/npm-packages)
|
|
100
|
+
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div align="center"><sub>Built with care by <a href="https://lacspace.com">Lacspace</a> · Lacspace Free Licence · <a href="https://github.com/lacspace/npm-packages">source</a></sub></div>
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
var DEFAULT_TS_FIELD = "_ts";
|
|
5
|
+
var DEFAULT_FORM_KEY = "_form";
|
|
6
|
+
var DEFAULT_SPAM_MSG = "Your submission could not be processed. Please try again.";
|
|
7
|
+
function isFormData(x) {
|
|
8
|
+
return typeof x === "object" && x !== null && typeof x.entries === "function" && typeof x.append === "function";
|
|
9
|
+
}
|
|
10
|
+
function formDataToObject(fd) {
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const [key, value] of fd.entries()) {
|
|
13
|
+
if (key in out) {
|
|
14
|
+
const existing = out[key];
|
|
15
|
+
if (Array.isArray(existing)) existing.push(value);
|
|
16
|
+
else out[key] = [existing, value];
|
|
17
|
+
} else {
|
|
18
|
+
out[key] = value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
function toObject(input) {
|
|
24
|
+
return isFormData(input) ? formDataToObject(input) : { ...input };
|
|
25
|
+
}
|
|
26
|
+
function handleForm(input, opts) {
|
|
27
|
+
const values = toObject(input);
|
|
28
|
+
const formKey = opts.formErrorKey ?? DEFAULT_FORM_KEY;
|
|
29
|
+
if (opts.honeypot) {
|
|
30
|
+
const trap = values[opts.honeypot];
|
|
31
|
+
if (typeof trap === "string" ? trap.trim() !== "" : trap != null && trap !== "") {
|
|
32
|
+
return spam(opts, values, formKey);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (opts.minSubmitMs && opts.minSubmitMs > 0) {
|
|
36
|
+
const tsField = opts.timestampField ?? DEFAULT_TS_FIELD;
|
|
37
|
+
const raw = values[tsField];
|
|
38
|
+
const ts = typeof raw === "string" ? Number(raw) : typeof raw === "number" ? raw : NaN;
|
|
39
|
+
if (Number.isFinite(ts)) {
|
|
40
|
+
const elapsed = Date.now() - ts;
|
|
41
|
+
if (elapsed >= 0 && elapsed < opts.minSubmitMs) {
|
|
42
|
+
return spam(opts, values, formKey);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const cleaned = stripInternal(values, opts);
|
|
47
|
+
const r = opts.schema.safeParse(cleaned);
|
|
48
|
+
if (r.success) return { ok: true, data: r.data };
|
|
49
|
+
return { ok: false, errors: r.error.flatten(), values: cleaned };
|
|
50
|
+
}
|
|
51
|
+
function stripInternal(values, opts) {
|
|
52
|
+
const drop = /* @__PURE__ */ new Set();
|
|
53
|
+
if (opts.honeypot) drop.add(opts.honeypot);
|
|
54
|
+
drop.add(opts.timestampField ?? DEFAULT_TS_FIELD);
|
|
55
|
+
if (drop.size === 0) return values;
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const k of Object.keys(values)) if (!drop.has(k)) out[k] = values[k];
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
function spam(opts, values, formKey) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
spam: true,
|
|
64
|
+
values,
|
|
65
|
+
errors: { [formKey]: opts.spamMessage ?? DEFAULT_SPAM_MSG }
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function createForm(opts) {
|
|
69
|
+
return {
|
|
70
|
+
handle: (input) => handleForm(input, opts),
|
|
71
|
+
action: (_prev, formData) => handleForm(formData, opts)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function honeypotProps(name) {
|
|
75
|
+
return {
|
|
76
|
+
type: "text",
|
|
77
|
+
name,
|
|
78
|
+
tabIndex: -1,
|
|
79
|
+
autoComplete: "off",
|
|
80
|
+
"aria-hidden": "true",
|
|
81
|
+
style: {
|
|
82
|
+
position: "absolute",
|
|
83
|
+
width: "1px",
|
|
84
|
+
height: "1px",
|
|
85
|
+
padding: "0",
|
|
86
|
+
margin: "-1px",
|
|
87
|
+
overflow: "hidden",
|
|
88
|
+
clip: "rect(0 0 0 0)",
|
|
89
|
+
whiteSpace: "nowrap",
|
|
90
|
+
border: "0"
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function timestampValue() {
|
|
95
|
+
return String(Date.now());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
exports.createForm = createForm;
|
|
99
|
+
exports.formDataToObject = formDataToObject;
|
|
100
|
+
exports.handleForm = handleForm;
|
|
101
|
+
exports.honeypotProps = honeypotProps;
|
|
102
|
+
exports.timestampValue = timestampValue;
|
|
103
|
+
//# sourceMappingURL=index.cjs.map
|
|
104
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @lacspace/form\n * End-to-end form handling for the server — turn a `FormData` (or a plain\n * object) into typed, validated data with built-in spam protection, and get\n * back either your data or per-field errors ready to re-render.\n *\n * Framework-agnostic, but shaped for Next.js Server Actions.\n *\n * ```ts\n * \"use server\";\n * import { createForm } from \"@lacspace/form\";\n * import { v } from \"@lacspace/validate\";\n *\n * const contact = createForm({\n * schema: v.object({\n * name: v.string().min(2),\n * email: v.string().email(),\n * message: v.string().min(10),\n * }),\n * honeypot: \"company\", // a hidden field bots love to fill\n * });\n *\n * export async function submit(prev, formData) {\n * const r = contact.action(prev, formData);\n * if (!r.ok) return r; // { errors, values } → re-render\n * await sendEmail(r.data); // fully typed\n * return { ok: true };\n * }\n * ```\n *\n * Zero dependencies · isomorphic · fully typed.\n */\n\n/* ------------------------------------------------------------------ *\n * Validator contract — structurally compatible with @lacspace/validate\n * (and, in practice, with zod). No hard dependency either way.\n * ------------------------------------------------------------------ */\n\nexport interface Validator<T> {\n safeParse(input: unknown):\n | { success: true; data: T }\n | { success: false; error: { flatten(): Record<string, string> } };\n}\n\n/* ------------------------------------------------------------------ *\n * Options & results\n * ------------------------------------------------------------------ */\n\nexport interface FormOptions<T> {\n /** A schema with `safeParse` — e.g. `v.object({...})` from @lacspace/validate. */\n schema: Validator<T>;\n /**\n * Name of a hidden \"honeypot\" field that real users never see and never fill.\n * If it arrives non-empty, the submission is treated as spam.\n */\n honeypot?: string;\n /**\n * Reject submissions that arrive faster than this many ms after the form was\n * rendered. Requires a hidden timestamp field (see `timestampField`).\n */\n minSubmitMs?: number;\n /** Hidden field holding the render time in ms. Default `\"_ts\"`. */\n timestampField?: string;\n /** Message returned when a submission is flagged as spam. */\n spamMessage?: string;\n /** Key used for form-level (non-field) errors. Default `\"_form\"`. */\n formErrorKey?: string;\n}\n\nexport type FormResult<T> =\n | { ok: true; data: T }\n | {\n ok: false;\n /** `{ email: \"Invalid email\", _form: \"...\" }` — render next to inputs. */\n errors: Record<string, string>;\n /** The raw submitted values, so the form can be re-rendered as typed. */\n values: Record<string, unknown>;\n /** True when the failure was a spam/bot heuristic, not user error. */\n spam?: boolean;\n };\n\nconst DEFAULT_TS_FIELD = \"_ts\";\nconst DEFAULT_FORM_KEY = \"_form\";\nconst DEFAULT_SPAM_MSG = \"Your submission could not be processed. Please try again.\";\n\n/* ------------------------------------------------------------------ *\n * FormData → plain object\n * ------------------------------------------------------------------ */\n\n/** Minimal structural shape of the parts of FormData we use. */\ninterface FormDataLike {\n entries(): IterableIterator<[string, unknown]>;\n}\n\nfunction isFormData(x: unknown): x is FormDataLike {\n return (\n typeof x === \"object\" &&\n x !== null &&\n typeof (x as { entries?: unknown }).entries === \"function\" &&\n typeof (x as { append?: unknown }).append === \"function\"\n );\n}\n\n/**\n * Convert a `FormData` into a plain object. Repeated keys become arrays; File\n * values are passed through untouched. Empty strings are preserved (validation\n * decides what \"required\" means).\n */\nexport function formDataToObject(fd: FormDataLike): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const [key, value] of fd.entries()) {\n if (key in out) {\n const existing = out[key];\n if (Array.isArray(existing)) existing.push(value);\n else out[key] = [existing, value];\n } else {\n out[key] = value;\n }\n }\n return out;\n}\n\n/** Normalise any accepted input to a plain object. */\nfunction toObject(input: FormDataLike | Record<string, unknown>): Record<string, unknown> {\n return isFormData(input) ? formDataToObject(input) : { ...input };\n}\n\n/* ------------------------------------------------------------------ *\n * Core handler\n * ------------------------------------------------------------------ */\n\n/**\n * Validate an input (FormData or object) against a schema, applying spam\n * heuristics first. Returns typed data or per-field errors + the raw values.\n */\nexport function handleForm<T>(\n input: FormDataLike | Record<string, unknown>,\n opts: FormOptions<T>,\n): FormResult<T> {\n const values = toObject(input);\n const formKey = opts.formErrorKey ?? DEFAULT_FORM_KEY;\n\n // 1. Honeypot — a non-empty hidden field means a bot.\n if (opts.honeypot) {\n const trap = values[opts.honeypot];\n if (typeof trap === \"string\" ? trap.trim() !== \"\" : trap != null && trap !== \"\") {\n return spam(opts, values, formKey);\n }\n }\n\n // 2. Timing — submitted implausibly fast after render.\n if (opts.minSubmitMs && opts.minSubmitMs > 0) {\n const tsField = opts.timestampField ?? DEFAULT_TS_FIELD;\n const raw = values[tsField];\n const ts = typeof raw === \"string\" ? Number(raw) : typeof raw === \"number\" ? raw : NaN;\n if (Number.isFinite(ts)) {\n const elapsed = Date.now() - ts;\n if (elapsed >= 0 && elapsed < opts.minSubmitMs) {\n return spam(opts, values, formKey);\n }\n }\n }\n\n // 3. Strip internal fields before validation so schemas can stay `.strict()`.\n const cleaned = stripInternal(values, opts);\n\n // 4. Validate.\n const r = opts.schema.safeParse(cleaned);\n if (r.success) return { ok: true, data: r.data };\n return { ok: false, errors: r.error.flatten(), values: cleaned };\n}\n\nfunction stripInternal<T>(values: Record<string, unknown>, opts: FormOptions<T>): Record<string, unknown> {\n const drop = new Set<string>();\n if (opts.honeypot) drop.add(opts.honeypot);\n drop.add(opts.timestampField ?? DEFAULT_TS_FIELD);\n if (drop.size === 0) return values;\n const out: Record<string, unknown> = {};\n for (const k of Object.keys(values)) if (!drop.has(k)) out[k] = values[k];\n return out;\n}\n\nfunction spam<T>(opts: FormOptions<T>, values: Record<string, unknown>, formKey: string): FormResult<T> {\n return {\n ok: false,\n spam: true,\n values,\n errors: { [formKey]: opts.spamMessage ?? DEFAULT_SPAM_MSG },\n };\n}\n\n/* ------------------------------------------------------------------ *\n * createForm — reusable handler bound to one schema\n * ------------------------------------------------------------------ */\n\nexport interface Form<T> {\n /** Validate any input; returns typed data or errors. */\n handle(input: FormDataLike | Record<string, unknown>): FormResult<T>;\n /**\n * Next.js Server Action signature `(prevState, formData) => result`.\n * The previous state is ignored; it exists so this drops straight into\n * `useActionState`.\n */\n action(prevState: unknown, formData: FormDataLike): FormResult<T>;\n}\n\n/** Bind a schema + spam options once and reuse the handler across requests. */\nexport function createForm<T>(opts: FormOptions<T>): Form<T> {\n return {\n handle: (input) => handleForm(input, opts),\n action: (_prev, formData) => handleForm(formData, opts),\n };\n}\n\n/* ------------------------------------------------------------------ *\n * Client helpers (framework-agnostic, no React needed)\n * ------------------------------------------------------------------ */\n\n/**\n * Attributes for a visually-hidden honeypot input. Spread onto an `<input>`:\n * `<input {...honeypotProps(\"company\")} />`.\n */\nexport function honeypotProps(name: string): {\n type: \"text\";\n name: string;\n tabIndex: -1;\n autoComplete: \"off\";\n \"aria-hidden\": \"true\";\n style: Record<string, string>;\n} {\n return {\n type: \"text\",\n name,\n tabIndex: -1,\n autoComplete: \"off\",\n \"aria-hidden\": \"true\",\n style: {\n position: \"absolute\",\n width: \"1px\",\n height: \"1px\",\n padding: \"0\",\n margin: \"-1px\",\n overflow: \"hidden\",\n clip: \"rect(0 0 0 0)\",\n whiteSpace: \"nowrap\",\n border: \"0\",\n },\n };\n}\n\n/** A `<input type=\"hidden\">`-ready render timestamp for the timing check. */\nexport function timestampValue(): string {\n return String(Date.now());\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lacspace/form
|
|
3
|
+
* End-to-end form handling for the server — turn a `FormData` (or a plain
|
|
4
|
+
* object) into typed, validated data with built-in spam protection, and get
|
|
5
|
+
* back either your data or per-field errors ready to re-render.
|
|
6
|
+
*
|
|
7
|
+
* Framework-agnostic, but shaped for Next.js Server Actions.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* "use server";
|
|
11
|
+
* import { createForm } from "@lacspace/form";
|
|
12
|
+
* import { v } from "@lacspace/validate";
|
|
13
|
+
*
|
|
14
|
+
* const contact = createForm({
|
|
15
|
+
* schema: v.object({
|
|
16
|
+
* name: v.string().min(2),
|
|
17
|
+
* email: v.string().email(),
|
|
18
|
+
* message: v.string().min(10),
|
|
19
|
+
* }),
|
|
20
|
+
* honeypot: "company", // a hidden field bots love to fill
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* export async function submit(prev, formData) {
|
|
24
|
+
* const r = contact.action(prev, formData);
|
|
25
|
+
* if (!r.ok) return r; // { errors, values } → re-render
|
|
26
|
+
* await sendEmail(r.data); // fully typed
|
|
27
|
+
* return { ok: true };
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Zero dependencies · isomorphic · fully typed.
|
|
32
|
+
*/
|
|
33
|
+
interface Validator<T> {
|
|
34
|
+
safeParse(input: unknown): {
|
|
35
|
+
success: true;
|
|
36
|
+
data: T;
|
|
37
|
+
} | {
|
|
38
|
+
success: false;
|
|
39
|
+
error: {
|
|
40
|
+
flatten(): Record<string, string>;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
interface FormOptions<T> {
|
|
45
|
+
/** A schema with `safeParse` — e.g. `v.object({...})` from @lacspace/validate. */
|
|
46
|
+
schema: Validator<T>;
|
|
47
|
+
/**
|
|
48
|
+
* Name of a hidden "honeypot" field that real users never see and never fill.
|
|
49
|
+
* If it arrives non-empty, the submission is treated as spam.
|
|
50
|
+
*/
|
|
51
|
+
honeypot?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Reject submissions that arrive faster than this many ms after the form was
|
|
54
|
+
* rendered. Requires a hidden timestamp field (see `timestampField`).
|
|
55
|
+
*/
|
|
56
|
+
minSubmitMs?: number;
|
|
57
|
+
/** Hidden field holding the render time in ms. Default `"_ts"`. */
|
|
58
|
+
timestampField?: string;
|
|
59
|
+
/** Message returned when a submission is flagged as spam. */
|
|
60
|
+
spamMessage?: string;
|
|
61
|
+
/** Key used for form-level (non-field) errors. Default `"_form"`. */
|
|
62
|
+
formErrorKey?: string;
|
|
63
|
+
}
|
|
64
|
+
type FormResult<T> = {
|
|
65
|
+
ok: true;
|
|
66
|
+
data: T;
|
|
67
|
+
} | {
|
|
68
|
+
ok: false;
|
|
69
|
+
/** `{ email: "Invalid email", _form: "..." }` — render next to inputs. */
|
|
70
|
+
errors: Record<string, string>;
|
|
71
|
+
/** The raw submitted values, so the form can be re-rendered as typed. */
|
|
72
|
+
values: Record<string, unknown>;
|
|
73
|
+
/** True when the failure was a spam/bot heuristic, not user error. */
|
|
74
|
+
spam?: boolean;
|
|
75
|
+
};
|
|
76
|
+
/** Minimal structural shape of the parts of FormData we use. */
|
|
77
|
+
interface FormDataLike {
|
|
78
|
+
entries(): IterableIterator<[string, unknown]>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Convert a `FormData` into a plain object. Repeated keys become arrays; File
|
|
82
|
+
* values are passed through untouched. Empty strings are preserved (validation
|
|
83
|
+
* decides what "required" means).
|
|
84
|
+
*/
|
|
85
|
+
declare function formDataToObject(fd: FormDataLike): Record<string, unknown>;
|
|
86
|
+
/**
|
|
87
|
+
* Validate an input (FormData or object) against a schema, applying spam
|
|
88
|
+
* heuristics first. Returns typed data or per-field errors + the raw values.
|
|
89
|
+
*/
|
|
90
|
+
declare function handleForm<T>(input: FormDataLike | Record<string, unknown>, opts: FormOptions<T>): FormResult<T>;
|
|
91
|
+
interface Form<T> {
|
|
92
|
+
/** Validate any input; returns typed data or errors. */
|
|
93
|
+
handle(input: FormDataLike | Record<string, unknown>): FormResult<T>;
|
|
94
|
+
/**
|
|
95
|
+
* Next.js Server Action signature `(prevState, formData) => result`.
|
|
96
|
+
* The previous state is ignored; it exists so this drops straight into
|
|
97
|
+
* `useActionState`.
|
|
98
|
+
*/
|
|
99
|
+
action(prevState: unknown, formData: FormDataLike): FormResult<T>;
|
|
100
|
+
}
|
|
101
|
+
/** Bind a schema + spam options once and reuse the handler across requests. */
|
|
102
|
+
declare function createForm<T>(opts: FormOptions<T>): Form<T>;
|
|
103
|
+
/**
|
|
104
|
+
* Attributes for a visually-hidden honeypot input. Spread onto an `<input>`:
|
|
105
|
+
* `<input {...honeypotProps("company")} />`.
|
|
106
|
+
*/
|
|
107
|
+
declare function honeypotProps(name: string): {
|
|
108
|
+
type: "text";
|
|
109
|
+
name: string;
|
|
110
|
+
tabIndex: -1;
|
|
111
|
+
autoComplete: "off";
|
|
112
|
+
"aria-hidden": "true";
|
|
113
|
+
style: Record<string, string>;
|
|
114
|
+
};
|
|
115
|
+
/** A `<input type="hidden">`-ready render timestamp for the timing check. */
|
|
116
|
+
declare function timestampValue(): string;
|
|
117
|
+
|
|
118
|
+
export { type Form, type FormOptions, type FormResult, type Validator, createForm, formDataToObject, handleForm, honeypotProps, timestampValue };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lacspace/form
|
|
3
|
+
* End-to-end form handling for the server — turn a `FormData` (or a plain
|
|
4
|
+
* object) into typed, validated data with built-in spam protection, and get
|
|
5
|
+
* back either your data or per-field errors ready to re-render.
|
|
6
|
+
*
|
|
7
|
+
* Framework-agnostic, but shaped for Next.js Server Actions.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* "use server";
|
|
11
|
+
* import { createForm } from "@lacspace/form";
|
|
12
|
+
* import { v } from "@lacspace/validate";
|
|
13
|
+
*
|
|
14
|
+
* const contact = createForm({
|
|
15
|
+
* schema: v.object({
|
|
16
|
+
* name: v.string().min(2),
|
|
17
|
+
* email: v.string().email(),
|
|
18
|
+
* message: v.string().min(10),
|
|
19
|
+
* }),
|
|
20
|
+
* honeypot: "company", // a hidden field bots love to fill
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* export async function submit(prev, formData) {
|
|
24
|
+
* const r = contact.action(prev, formData);
|
|
25
|
+
* if (!r.ok) return r; // { errors, values } → re-render
|
|
26
|
+
* await sendEmail(r.data); // fully typed
|
|
27
|
+
* return { ok: true };
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Zero dependencies · isomorphic · fully typed.
|
|
32
|
+
*/
|
|
33
|
+
interface Validator<T> {
|
|
34
|
+
safeParse(input: unknown): {
|
|
35
|
+
success: true;
|
|
36
|
+
data: T;
|
|
37
|
+
} | {
|
|
38
|
+
success: false;
|
|
39
|
+
error: {
|
|
40
|
+
flatten(): Record<string, string>;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
interface FormOptions<T> {
|
|
45
|
+
/** A schema with `safeParse` — e.g. `v.object({...})` from @lacspace/validate. */
|
|
46
|
+
schema: Validator<T>;
|
|
47
|
+
/**
|
|
48
|
+
* Name of a hidden "honeypot" field that real users never see and never fill.
|
|
49
|
+
* If it arrives non-empty, the submission is treated as spam.
|
|
50
|
+
*/
|
|
51
|
+
honeypot?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Reject submissions that arrive faster than this many ms after the form was
|
|
54
|
+
* rendered. Requires a hidden timestamp field (see `timestampField`).
|
|
55
|
+
*/
|
|
56
|
+
minSubmitMs?: number;
|
|
57
|
+
/** Hidden field holding the render time in ms. Default `"_ts"`. */
|
|
58
|
+
timestampField?: string;
|
|
59
|
+
/** Message returned when a submission is flagged as spam. */
|
|
60
|
+
spamMessage?: string;
|
|
61
|
+
/** Key used for form-level (non-field) errors. Default `"_form"`. */
|
|
62
|
+
formErrorKey?: string;
|
|
63
|
+
}
|
|
64
|
+
type FormResult<T> = {
|
|
65
|
+
ok: true;
|
|
66
|
+
data: T;
|
|
67
|
+
} | {
|
|
68
|
+
ok: false;
|
|
69
|
+
/** `{ email: "Invalid email", _form: "..." }` — render next to inputs. */
|
|
70
|
+
errors: Record<string, string>;
|
|
71
|
+
/** The raw submitted values, so the form can be re-rendered as typed. */
|
|
72
|
+
values: Record<string, unknown>;
|
|
73
|
+
/** True when the failure was a spam/bot heuristic, not user error. */
|
|
74
|
+
spam?: boolean;
|
|
75
|
+
};
|
|
76
|
+
/** Minimal structural shape of the parts of FormData we use. */
|
|
77
|
+
interface FormDataLike {
|
|
78
|
+
entries(): IterableIterator<[string, unknown]>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Convert a `FormData` into a plain object. Repeated keys become arrays; File
|
|
82
|
+
* values are passed through untouched. Empty strings are preserved (validation
|
|
83
|
+
* decides what "required" means).
|
|
84
|
+
*/
|
|
85
|
+
declare function formDataToObject(fd: FormDataLike): Record<string, unknown>;
|
|
86
|
+
/**
|
|
87
|
+
* Validate an input (FormData or object) against a schema, applying spam
|
|
88
|
+
* heuristics first. Returns typed data or per-field errors + the raw values.
|
|
89
|
+
*/
|
|
90
|
+
declare function handleForm<T>(input: FormDataLike | Record<string, unknown>, opts: FormOptions<T>): FormResult<T>;
|
|
91
|
+
interface Form<T> {
|
|
92
|
+
/** Validate any input; returns typed data or errors. */
|
|
93
|
+
handle(input: FormDataLike | Record<string, unknown>): FormResult<T>;
|
|
94
|
+
/**
|
|
95
|
+
* Next.js Server Action signature `(prevState, formData) => result`.
|
|
96
|
+
* The previous state is ignored; it exists so this drops straight into
|
|
97
|
+
* `useActionState`.
|
|
98
|
+
*/
|
|
99
|
+
action(prevState: unknown, formData: FormDataLike): FormResult<T>;
|
|
100
|
+
}
|
|
101
|
+
/** Bind a schema + spam options once and reuse the handler across requests. */
|
|
102
|
+
declare function createForm<T>(opts: FormOptions<T>): Form<T>;
|
|
103
|
+
/**
|
|
104
|
+
* Attributes for a visually-hidden honeypot input. Spread onto an `<input>`:
|
|
105
|
+
* `<input {...honeypotProps("company")} />`.
|
|
106
|
+
*/
|
|
107
|
+
declare function honeypotProps(name: string): {
|
|
108
|
+
type: "text";
|
|
109
|
+
name: string;
|
|
110
|
+
tabIndex: -1;
|
|
111
|
+
autoComplete: "off";
|
|
112
|
+
"aria-hidden": "true";
|
|
113
|
+
style: Record<string, string>;
|
|
114
|
+
};
|
|
115
|
+
/** A `<input type="hidden">`-ready render timestamp for the timing check. */
|
|
116
|
+
declare function timestampValue(): string;
|
|
117
|
+
|
|
118
|
+
export { type Form, type FormOptions, type FormResult, type Validator, createForm, formDataToObject, handleForm, honeypotProps, timestampValue };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var DEFAULT_TS_FIELD = "_ts";
|
|
3
|
+
var DEFAULT_FORM_KEY = "_form";
|
|
4
|
+
var DEFAULT_SPAM_MSG = "Your submission could not be processed. Please try again.";
|
|
5
|
+
function isFormData(x) {
|
|
6
|
+
return typeof x === "object" && x !== null && typeof x.entries === "function" && typeof x.append === "function";
|
|
7
|
+
}
|
|
8
|
+
function formDataToObject(fd) {
|
|
9
|
+
const out = {};
|
|
10
|
+
for (const [key, value] of fd.entries()) {
|
|
11
|
+
if (key in out) {
|
|
12
|
+
const existing = out[key];
|
|
13
|
+
if (Array.isArray(existing)) existing.push(value);
|
|
14
|
+
else out[key] = [existing, value];
|
|
15
|
+
} else {
|
|
16
|
+
out[key] = value;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
function toObject(input) {
|
|
22
|
+
return isFormData(input) ? formDataToObject(input) : { ...input };
|
|
23
|
+
}
|
|
24
|
+
function handleForm(input, opts) {
|
|
25
|
+
const values = toObject(input);
|
|
26
|
+
const formKey = opts.formErrorKey ?? DEFAULT_FORM_KEY;
|
|
27
|
+
if (opts.honeypot) {
|
|
28
|
+
const trap = values[opts.honeypot];
|
|
29
|
+
if (typeof trap === "string" ? trap.trim() !== "" : trap != null && trap !== "") {
|
|
30
|
+
return spam(opts, values, formKey);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (opts.minSubmitMs && opts.minSubmitMs > 0) {
|
|
34
|
+
const tsField = opts.timestampField ?? DEFAULT_TS_FIELD;
|
|
35
|
+
const raw = values[tsField];
|
|
36
|
+
const ts = typeof raw === "string" ? Number(raw) : typeof raw === "number" ? raw : NaN;
|
|
37
|
+
if (Number.isFinite(ts)) {
|
|
38
|
+
const elapsed = Date.now() - ts;
|
|
39
|
+
if (elapsed >= 0 && elapsed < opts.minSubmitMs) {
|
|
40
|
+
return spam(opts, values, formKey);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const cleaned = stripInternal(values, opts);
|
|
45
|
+
const r = opts.schema.safeParse(cleaned);
|
|
46
|
+
if (r.success) return { ok: true, data: r.data };
|
|
47
|
+
return { ok: false, errors: r.error.flatten(), values: cleaned };
|
|
48
|
+
}
|
|
49
|
+
function stripInternal(values, opts) {
|
|
50
|
+
const drop = /* @__PURE__ */ new Set();
|
|
51
|
+
if (opts.honeypot) drop.add(opts.honeypot);
|
|
52
|
+
drop.add(opts.timestampField ?? DEFAULT_TS_FIELD);
|
|
53
|
+
if (drop.size === 0) return values;
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const k of Object.keys(values)) if (!drop.has(k)) out[k] = values[k];
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function spam(opts, values, formKey) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
spam: true,
|
|
62
|
+
values,
|
|
63
|
+
errors: { [formKey]: opts.spamMessage ?? DEFAULT_SPAM_MSG }
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function createForm(opts) {
|
|
67
|
+
return {
|
|
68
|
+
handle: (input) => handleForm(input, opts),
|
|
69
|
+
action: (_prev, formData) => handleForm(formData, opts)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function honeypotProps(name) {
|
|
73
|
+
return {
|
|
74
|
+
type: "text",
|
|
75
|
+
name,
|
|
76
|
+
tabIndex: -1,
|
|
77
|
+
autoComplete: "off",
|
|
78
|
+
"aria-hidden": "true",
|
|
79
|
+
style: {
|
|
80
|
+
position: "absolute",
|
|
81
|
+
width: "1px",
|
|
82
|
+
height: "1px",
|
|
83
|
+
padding: "0",
|
|
84
|
+
margin: "-1px",
|
|
85
|
+
overflow: "hidden",
|
|
86
|
+
clip: "rect(0 0 0 0)",
|
|
87
|
+
whiteSpace: "nowrap",
|
|
88
|
+
border: "0"
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function timestampValue() {
|
|
93
|
+
return String(Date.now());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { createForm, formDataToObject, handleForm, honeypotProps, timestampValue };
|
|
97
|
+
//# sourceMappingURL=index.js.map
|
|
98
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @lacspace/form\n * End-to-end form handling for the server — turn a `FormData` (or a plain\n * object) into typed, validated data with built-in spam protection, and get\n * back either your data or per-field errors ready to re-render.\n *\n * Framework-agnostic, but shaped for Next.js Server Actions.\n *\n * ```ts\n * \"use server\";\n * import { createForm } from \"@lacspace/form\";\n * import { v } from \"@lacspace/validate\";\n *\n * const contact = createForm({\n * schema: v.object({\n * name: v.string().min(2),\n * email: v.string().email(),\n * message: v.string().min(10),\n * }),\n * honeypot: \"company\", // a hidden field bots love to fill\n * });\n *\n * export async function submit(prev, formData) {\n * const r = contact.action(prev, formData);\n * if (!r.ok) return r; // { errors, values } → re-render\n * await sendEmail(r.data); // fully typed\n * return { ok: true };\n * }\n * ```\n *\n * Zero dependencies · isomorphic · fully typed.\n */\n\n/* ------------------------------------------------------------------ *\n * Validator contract — structurally compatible with @lacspace/validate\n * (and, in practice, with zod). No hard dependency either way.\n * ------------------------------------------------------------------ */\n\nexport interface Validator<T> {\n safeParse(input: unknown):\n | { success: true; data: T }\n | { success: false; error: { flatten(): Record<string, string> } };\n}\n\n/* ------------------------------------------------------------------ *\n * Options & results\n * ------------------------------------------------------------------ */\n\nexport interface FormOptions<T> {\n /** A schema with `safeParse` — e.g. `v.object({...})` from @lacspace/validate. */\n schema: Validator<T>;\n /**\n * Name of a hidden \"honeypot\" field that real users never see and never fill.\n * If it arrives non-empty, the submission is treated as spam.\n */\n honeypot?: string;\n /**\n * Reject submissions that arrive faster than this many ms after the form was\n * rendered. Requires a hidden timestamp field (see `timestampField`).\n */\n minSubmitMs?: number;\n /** Hidden field holding the render time in ms. Default `\"_ts\"`. */\n timestampField?: string;\n /** Message returned when a submission is flagged as spam. */\n spamMessage?: string;\n /** Key used for form-level (non-field) errors. Default `\"_form\"`. */\n formErrorKey?: string;\n}\n\nexport type FormResult<T> =\n | { ok: true; data: T }\n | {\n ok: false;\n /** `{ email: \"Invalid email\", _form: \"...\" }` — render next to inputs. */\n errors: Record<string, string>;\n /** The raw submitted values, so the form can be re-rendered as typed. */\n values: Record<string, unknown>;\n /** True when the failure was a spam/bot heuristic, not user error. */\n spam?: boolean;\n };\n\nconst DEFAULT_TS_FIELD = \"_ts\";\nconst DEFAULT_FORM_KEY = \"_form\";\nconst DEFAULT_SPAM_MSG = \"Your submission could not be processed. Please try again.\";\n\n/* ------------------------------------------------------------------ *\n * FormData → plain object\n * ------------------------------------------------------------------ */\n\n/** Minimal structural shape of the parts of FormData we use. */\ninterface FormDataLike {\n entries(): IterableIterator<[string, unknown]>;\n}\n\nfunction isFormData(x: unknown): x is FormDataLike {\n return (\n typeof x === \"object\" &&\n x !== null &&\n typeof (x as { entries?: unknown }).entries === \"function\" &&\n typeof (x as { append?: unknown }).append === \"function\"\n );\n}\n\n/**\n * Convert a `FormData` into a plain object. Repeated keys become arrays; File\n * values are passed through untouched. Empty strings are preserved (validation\n * decides what \"required\" means).\n */\nexport function formDataToObject(fd: FormDataLike): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const [key, value] of fd.entries()) {\n if (key in out) {\n const existing = out[key];\n if (Array.isArray(existing)) existing.push(value);\n else out[key] = [existing, value];\n } else {\n out[key] = value;\n }\n }\n return out;\n}\n\n/** Normalise any accepted input to a plain object. */\nfunction toObject(input: FormDataLike | Record<string, unknown>): Record<string, unknown> {\n return isFormData(input) ? formDataToObject(input) : { ...input };\n}\n\n/* ------------------------------------------------------------------ *\n * Core handler\n * ------------------------------------------------------------------ */\n\n/**\n * Validate an input (FormData or object) against a schema, applying spam\n * heuristics first. Returns typed data or per-field errors + the raw values.\n */\nexport function handleForm<T>(\n input: FormDataLike | Record<string, unknown>,\n opts: FormOptions<T>,\n): FormResult<T> {\n const values = toObject(input);\n const formKey = opts.formErrorKey ?? DEFAULT_FORM_KEY;\n\n // 1. Honeypot — a non-empty hidden field means a bot.\n if (opts.honeypot) {\n const trap = values[opts.honeypot];\n if (typeof trap === \"string\" ? trap.trim() !== \"\" : trap != null && trap !== \"\") {\n return spam(opts, values, formKey);\n }\n }\n\n // 2. Timing — submitted implausibly fast after render.\n if (opts.minSubmitMs && opts.minSubmitMs > 0) {\n const tsField = opts.timestampField ?? DEFAULT_TS_FIELD;\n const raw = values[tsField];\n const ts = typeof raw === \"string\" ? Number(raw) : typeof raw === \"number\" ? raw : NaN;\n if (Number.isFinite(ts)) {\n const elapsed = Date.now() - ts;\n if (elapsed >= 0 && elapsed < opts.minSubmitMs) {\n return spam(opts, values, formKey);\n }\n }\n }\n\n // 3. Strip internal fields before validation so schemas can stay `.strict()`.\n const cleaned = stripInternal(values, opts);\n\n // 4. Validate.\n const r = opts.schema.safeParse(cleaned);\n if (r.success) return { ok: true, data: r.data };\n return { ok: false, errors: r.error.flatten(), values: cleaned };\n}\n\nfunction stripInternal<T>(values: Record<string, unknown>, opts: FormOptions<T>): Record<string, unknown> {\n const drop = new Set<string>();\n if (opts.honeypot) drop.add(opts.honeypot);\n drop.add(opts.timestampField ?? DEFAULT_TS_FIELD);\n if (drop.size === 0) return values;\n const out: Record<string, unknown> = {};\n for (const k of Object.keys(values)) if (!drop.has(k)) out[k] = values[k];\n return out;\n}\n\nfunction spam<T>(opts: FormOptions<T>, values: Record<string, unknown>, formKey: string): FormResult<T> {\n return {\n ok: false,\n spam: true,\n values,\n errors: { [formKey]: opts.spamMessage ?? DEFAULT_SPAM_MSG },\n };\n}\n\n/* ------------------------------------------------------------------ *\n * createForm — reusable handler bound to one schema\n * ------------------------------------------------------------------ */\n\nexport interface Form<T> {\n /** Validate any input; returns typed data or errors. */\n handle(input: FormDataLike | Record<string, unknown>): FormResult<T>;\n /**\n * Next.js Server Action signature `(prevState, formData) => result`.\n * The previous state is ignored; it exists so this drops straight into\n * `useActionState`.\n */\n action(prevState: unknown, formData: FormDataLike): FormResult<T>;\n}\n\n/** Bind a schema + spam options once and reuse the handler across requests. */\nexport function createForm<T>(opts: FormOptions<T>): Form<T> {\n return {\n handle: (input) => handleForm(input, opts),\n action: (_prev, formData) => handleForm(formData, opts),\n };\n}\n\n/* ------------------------------------------------------------------ *\n * Client helpers (framework-agnostic, no React needed)\n * ------------------------------------------------------------------ */\n\n/**\n * Attributes for a visually-hidden honeypot input. Spread onto an `<input>`:\n * `<input {...honeypotProps(\"company\")} />`.\n */\nexport function honeypotProps(name: string): {\n type: \"text\";\n name: string;\n tabIndex: -1;\n autoComplete: \"off\";\n \"aria-hidden\": \"true\";\n style: Record<string, string>;\n} {\n return {\n type: \"text\",\n name,\n tabIndex: -1,\n autoComplete: \"off\",\n \"aria-hidden\": \"true\",\n style: {\n position: \"absolute\",\n width: \"1px\",\n height: \"1px\",\n padding: \"0\",\n margin: \"-1px\",\n overflow: \"hidden\",\n clip: \"rect(0 0 0 0)\",\n whiteSpace: \"nowrap\",\n border: \"0\",\n },\n };\n}\n\n/** A `<input type=\"hidden\">`-ready render timestamp for the timing check. */\nexport function timestampValue(): string {\n return String(Date.now());\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lacspace/form",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "End-to-end form handling for the server — turn FormData into typed, validated data with a honeypot + timing spam guard, and get back your data or per-field errors ready to re-render. Shaped for Next.js Server Actions. Zero-dependency, isomorphic.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"prepublishOnly": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"form",
|
|
31
|
+
"form-handling",
|
|
32
|
+
"form-validation",
|
|
33
|
+
"formdata",
|
|
34
|
+
"server-actions",
|
|
35
|
+
"nextjs",
|
|
36
|
+
"honeypot",
|
|
37
|
+
"spam-protection",
|
|
38
|
+
"useactionstate",
|
|
39
|
+
"contact-form",
|
|
40
|
+
"typescript",
|
|
41
|
+
"zero-dependency",
|
|
42
|
+
"isomorphic",
|
|
43
|
+
"type-safe",
|
|
44
|
+
"validation"
|
|
45
|
+
],
|
|
46
|
+
"author": "Lacspace <contact@lacspace.com>",
|
|
47
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
48
|
+
"homepage": "https://lacspace.com/packages",
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/lacspace/npm-packages.git",
|
|
52
|
+
"directory": "form"
|
|
53
|
+
},
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/lacspace/npm-packages/issues"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
}
|
|
63
|
+
}
|