@formadapter/http 0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ludicrous
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # `@formadapter/http`
2
+
3
+ Use any JSON or multipart HTTP endpoint as a FormAdapter submission target.
4
+
5
+ ```sh
6
+ bun add @formadapter/http
7
+ ```
8
+
9
+ ```tsx
10
+ import { createHttpSubmission } from "@formadapter/http";
11
+
12
+ const submit = createHttpSubmission({ url: "/api/profile" });
13
+
14
+ <Profile.Form onSubmit={submit} />;
15
+ ```
16
+
17
+ JSON is the default. It sends `SubmitContext.input`, the prepared schema input
18
+ before output transforms, so the server can validate and transform again at its
19
+ trust boundary. The endpoint response is wrapped as a successful shared
20
+ `SubmissionState` unless it already returns a well-formed state.
21
+
22
+ For files or an existing multipart endpoint, send the schema-aware FormData
23
+ created by the React runtime:
24
+
25
+ ```tsx
26
+ const submit = createHttpSubmission({
27
+ url: "/api/profile",
28
+ body: "form-data",
29
+ init: { credentials: "include" },
30
+ });
31
+ ```
32
+
33
+ Do not manually set `content-type` in FormData mode; `fetch` must add its
34
+ boundary. `init` may also be a per-submit function and can set method/headers.
35
+ The submit AbortSignal is always forwarded, and a custom `fetch` implementation
36
+ is supported.
37
+
38
+ Structured error states from successful and 4xx responses are preserved. A 4xx
39
+ string or `message` is treated as intentional client-safe feedback. Response
40
+ bodies from 5xx failures and exception messages from network failures are never
41
+ shown to users; they use `errorMessage` or a safe fallback instead. Aborts are
42
+ re-thrown so cancellation keeps working. A failed HTTP status is never treated
43
+ as success merely because its body resembles a successful state.
@@ -0,0 +1,20 @@
1
+ import { SubmissionState } from "@formadapter/core";
2
+
3
+ //#region src/index.d.ts
4
+ interface HttpSubmissionContext<Input = unknown> {
5
+ readonly input: Input;
6
+ readonly formData: FormData;
7
+ readonly signal: AbortSignal;
8
+ }
9
+ interface HttpSubmissionOptions {
10
+ readonly url: RequestInfo | URL;
11
+ readonly body?: "form-data" | "json";
12
+ readonly fetch?: typeof globalThis.fetch;
13
+ readonly init?: Omit<RequestInit, "body" | "signal"> | ((context: HttpSubmissionContext) => Omit<RequestInit, "body" | "signal">);
14
+ readonly errorMessage?: string;
15
+ }
16
+ type HttpSubmission = (output: unknown, context: HttpSubmissionContext) => Promise<SubmissionState>;
17
+ declare function createHttpSubmission(options: HttpSubmissionOptions): HttpSubmission;
18
+ //#endregion
19
+ export { HttpSubmission, HttpSubmissionContext, HttpSubmissionOptions, createHttpSubmission };
20
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,78 @@
1
+ import { isSubmissionState, submissionFailure, submissionSuccess } from "@formadapter/core";
2
+ //#region src/index.ts
3
+ const DEFAULT_NETWORK_ERROR_MESSAGE = "Unable to reach the server. Please try again.";
4
+ const DEFAULT_RESPONSE_ERROR_MESSAGE = "Unable to read the server response. Please try again.";
5
+ function transportFailure(message) {
6
+ return submissionFailure({
7
+ errorKind: "transport",
8
+ formErrors: [message]
9
+ });
10
+ }
11
+ function isAbort(error, signal) {
12
+ if (signal.aborted) return true;
13
+ try {
14
+ return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+ function fallbackMessage(configured, fallback) {
20
+ return configured?.trim() ? configured : fallback;
21
+ }
22
+ function responseErrorMessage(body) {
23
+ if (typeof body === "string") return body.trim() ? body : void 0;
24
+ if (typeof body !== "object" || body === null) return void 0;
25
+ try {
26
+ if (!Object.hasOwn(body, "message")) return void 0;
27
+ const message = body.message;
28
+ return typeof message === "string" && message.trim() ? message : void 0;
29
+ } catch {
30
+ return;
31
+ }
32
+ }
33
+ async function responseBody(response) {
34
+ if (response.status === 204) return void 0;
35
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
36
+ if (contentType === "application/json" || contentType.endsWith("+json")) return response.json();
37
+ return await response.text() || void 0;
38
+ }
39
+ function createHttpSubmission(options) {
40
+ return async (_output, context) => {
41
+ const request = options.fetch ?? globalThis.fetch;
42
+ const configured = typeof options.init === "function" ? options.init(context) : options.init ?? {};
43
+ const formDataBody = options.body === "form-data";
44
+ const headers = new Headers(configured.headers);
45
+ if (!formDataBody && !headers.has("content-type")) headers.set("content-type", "application/json");
46
+ let response;
47
+ try {
48
+ response = await request(options.url, {
49
+ ...configured,
50
+ body: formDataBody ? context.formData : JSON.stringify(context.input),
51
+ headers,
52
+ method: configured.method ?? "POST",
53
+ signal: context.signal
54
+ });
55
+ } catch (error) {
56
+ if (isAbort(error, context.signal)) throw error;
57
+ return transportFailure(fallbackMessage(options.errorMessage, DEFAULT_NETWORK_ERROR_MESSAGE));
58
+ }
59
+ let body;
60
+ try {
61
+ body = await responseBody(response);
62
+ } catch (error) {
63
+ if (isAbort(error, context.signal)) throw error;
64
+ return transportFailure(fallbackMessage(options.errorMessage, DEFAULT_RESPONSE_ERROR_MESSAGE));
65
+ }
66
+ if (!response.ok) {
67
+ if (response.status >= 500) return transportFailure(fallbackMessage(options.errorMessage, `Request failed with status ${response.status}`));
68
+ if (isSubmissionState(body) && body.status === "error") return body;
69
+ return transportFailure(responseErrorMessage(body) ?? fallbackMessage(options.errorMessage, `Request failed with status ${response.status}`));
70
+ }
71
+ if (isSubmissionState(body)) return body;
72
+ return submissionSuccess(body);
73
+ };
74
+ }
75
+ //#endregion
76
+ export { createHttpSubmission };
77
+
78
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import {\n isSubmissionState,\n submissionFailure,\n submissionSuccess,\n type SubmissionState,\n} from \"@formadapter/core\";\n\nexport interface HttpSubmissionContext<Input = unknown> {\n readonly input: Input;\n readonly formData: FormData;\n readonly signal: AbortSignal;\n}\n\nexport interface HttpSubmissionOptions {\n readonly url: RequestInfo | URL;\n readonly body?: \"form-data\" | \"json\";\n readonly fetch?: typeof globalThis.fetch;\n readonly init?:\n | Omit<RequestInit, \"body\" | \"signal\">\n | ((context: HttpSubmissionContext) => Omit<RequestInit, \"body\" | \"signal\">);\n readonly errorMessage?: string;\n}\n\nexport type HttpSubmission = (\n output: unknown,\n context: HttpSubmissionContext,\n) => Promise<SubmissionState>;\n\nconst DEFAULT_NETWORK_ERROR_MESSAGE =\n \"Unable to reach the server. Please try again.\";\nconst DEFAULT_RESPONSE_ERROR_MESSAGE =\n \"Unable to read the server response. Please try again.\";\n\nfunction transportFailure(message: string): SubmissionState {\n return submissionFailure({\n errorKind: \"transport\",\n formErrors: [message],\n });\n}\n\nfunction isAbort(error: unknown, signal: AbortSignal): boolean {\n if (signal.aborted) return true;\n try {\n return (\n typeof error === \"object\" &&\n error !== null &&\n \"name\" in error &&\n error.name === \"AbortError\"\n );\n } catch {\n return false;\n }\n}\n\nfunction fallbackMessage(\n configured: string | undefined,\n fallback: string,\n): string {\n return configured?.trim() ? configured : fallback;\n}\n\nfunction responseErrorMessage(body: unknown): string | undefined {\n if (typeof body === \"string\") return body.trim() ? body : undefined;\n if (typeof body !== \"object\" || body === null) return undefined;\n try {\n if (!Object.hasOwn(body, \"message\")) return undefined;\n const message = (body as Readonly<Record<string, unknown>>).message;\n return typeof message === \"string\" && message.trim() ? message : undefined;\n } catch {\n return undefined;\n }\n}\n\nasync function responseBody(response: Response): Promise<unknown> {\n if (response.status === 204) return undefined;\n const contentType = response.headers.get(\"content-type\")\n ?.split(\";\", 1)[0]\n ?.trim()\n .toLowerCase() ?? \"\";\n if (contentType === \"application/json\" || contentType.endsWith(\"+json\")) {\n return response.json();\n }\n const text = await response.text();\n return text || undefined;\n}\n\nexport function createHttpSubmission(\n options: HttpSubmissionOptions,\n): HttpSubmission {\n return async (_output, context) => {\n const request = options.fetch ?? globalThis.fetch;\n const configured = typeof options.init === \"function\"\n ? options.init(context)\n : options.init ?? {};\n const formDataBody = options.body === \"form-data\";\n const headers = new Headers(configured.headers);\n if (!formDataBody && !headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n let response: Response;\n try {\n response = await request(options.url, {\n ...configured,\n body: formDataBody\n ? context.formData\n : JSON.stringify(context.input),\n headers,\n method: configured.method ?? \"POST\",\n signal: context.signal,\n });\n } catch (error) {\n if (isAbort(error, context.signal)) throw error;\n return transportFailure(\n fallbackMessage(options.errorMessage, DEFAULT_NETWORK_ERROR_MESSAGE),\n );\n }\n\n let body: unknown;\n try {\n body = await responseBody(response);\n } catch (error) {\n if (isAbort(error, context.signal)) throw error;\n return transportFailure(\n fallbackMessage(options.errorMessage, DEFAULT_RESPONSE_ERROR_MESSAGE),\n );\n }\n\n if (!response.ok) {\n if (response.status >= 500) {\n return transportFailure(\n fallbackMessage(\n options.errorMessage,\n `Request failed with status ${response.status}`,\n ),\n );\n }\n if (isSubmissionState(body) && body.status === \"error\") return body;\n const message = responseErrorMessage(body) ?? fallbackMessage(\n options.errorMessage,\n `Request failed with status ${response.status}`,\n );\n return transportFailure(message);\n }\n if (isSubmissionState(body)) return body;\n return submissionSuccess(body);\n };\n}\n"],"mappings":";;AA4BA,MAAM,gCACJ;AACF,MAAM,iCACJ;AAEF,SAAS,iBAAiB,SAAkC;AAC1D,QAAO,kBAAkB;EACvB,WAAW;EACX,YAAY,CAAC,QAAQ;EACtB,CAAC;;AAGJ,SAAS,QAAQ,OAAgB,QAA8B;AAC7D,KAAI,OAAO,QAAS,QAAO;AAC3B,KAAI;AACF,SACE,OAAO,UAAU,YACjB,UAAU,QACV,UAAU,SACV,MAAM,SAAS;SAEX;AACN,SAAO;;;AAIX,SAAS,gBACP,YACA,UACQ;AACR,QAAO,YAAY,MAAM,GAAG,aAAa;;AAG3C,SAAS,qBAAqB,MAAmC;AAC/D,KAAI,OAAO,SAAS,SAAU,QAAO,KAAK,MAAM,GAAG,OAAO,KAAA;AAC1D,KAAI,OAAO,SAAS,YAAY,SAAS,KAAM,QAAO,KAAA;AACtD,KAAI;AACF,MAAI,CAAC,OAAO,OAAO,MAAM,UAAU,CAAE,QAAO,KAAA;EAC5C,MAAM,UAAW,KAA2C;AAC5D,SAAO,OAAO,YAAY,YAAY,QAAQ,MAAM,GAAG,UAAU,KAAA;SAC3D;AACN;;;AAIJ,eAAe,aAAa,UAAsC;AAChE,KAAI,SAAS,WAAW,IAAK,QAAO,KAAA;CACpC,MAAM,cAAc,SAAS,QAAQ,IAAI,eAAe,EACpD,MAAM,KAAK,EAAE,CAAC,IACd,MAAM,CACP,aAAa,IAAI;AACpB,KAAI,gBAAgB,sBAAsB,YAAY,SAAS,QAAQ,CACrE,QAAO,SAAS,MAAM;AAGxB,QAAO,MADY,SAAS,MAAM,IACnB,KAAA;;AAGjB,SAAgB,qBACd,SACgB;AAChB,QAAO,OAAO,SAAS,YAAY;EACjC,MAAM,UAAU,QAAQ,SAAS,WAAW;EAC5C,MAAM,aAAa,OAAO,QAAQ,SAAS,aACvC,QAAQ,KAAK,QAAQ,GACrB,QAAQ,QAAQ,EAAE;EACtB,MAAM,eAAe,QAAQ,SAAS;EACtC,MAAM,UAAU,IAAI,QAAQ,WAAW,QAAQ;AAC/C,MAAI,CAAC,gBAAgB,CAAC,QAAQ,IAAI,eAAe,CAC/C,SAAQ,IAAI,gBAAgB,mBAAmB;EAEjD,IAAI;AACJ,MAAI;AACF,cAAW,MAAM,QAAQ,QAAQ,KAAK;IACpC,GAAG;IACH,MAAM,eACF,QAAQ,WACR,KAAK,UAAU,QAAQ,MAAM;IACjC;IACA,QAAQ,WAAW,UAAU;IAC7B,QAAQ,QAAQ;IACjB,CAAC;WACK,OAAO;AACd,OAAI,QAAQ,OAAO,QAAQ,OAAO,CAAE,OAAM;AAC1C,UAAO,iBACL,gBAAgB,QAAQ,cAAc,8BAA8B,CACrE;;EAGH,IAAI;AACJ,MAAI;AACF,UAAO,MAAM,aAAa,SAAS;WAC5B,OAAO;AACd,OAAI,QAAQ,OAAO,QAAQ,OAAO,CAAE,OAAM;AAC1C,UAAO,iBACL,gBAAgB,QAAQ,cAAc,+BAA+B,CACtE;;AAGH,MAAI,CAAC,SAAS,IAAI;AAChB,OAAI,SAAS,UAAU,IACrB,QAAO,iBACL,gBACE,QAAQ,cACR,8BAA8B,SAAS,SACxC,CACF;AAEH,OAAI,kBAAkB,KAAK,IAAI,KAAK,WAAW,QAAS,QAAO;AAK/D,UAAO,iBAJS,qBAAqB,KAAK,IAAI,gBAC5C,QAAQ,cACR,8BAA8B,SAAS,SACxC,CAC+B;;AAElC,MAAI,kBAAkB,KAAK,CAAE,QAAO;AACpC,SAAO,kBAAkB,KAAK"}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@formadapter/http",
3
+ "version": "0.0.0",
4
+ "description": "JSON and multipart HTTP submissions for FormAdapter forms.",
5
+ "keywords": [
6
+ "fetch",
7
+ "forms",
8
+ "http",
9
+ "standard-schema",
10
+ "typescript"
11
+ ],
12
+ "homepage": "https://formadapter.com",
13
+ "bugs": {
14
+ "url": "https://github.com/ludicroushq/formadapter/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/ludicroushq/formadapter.git",
19
+ "directory": "packages/http"
20
+ },
21
+ "author": "ludicrous",
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "main": "./dist/index.js",
33
+ "module": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.ts",
38
+ "import": "./dist/index.js",
39
+ "default": "./dist/index.js"
40
+ },
41
+ "./package.json": "./package.json"
42
+ },
43
+ "scripts": {
44
+ "build": "tsdown",
45
+ "clean": "rm -rf dist tsconfig.tsbuildinfo .turbo",
46
+ "test": "vitest run --config vitest.config.ts",
47
+ "test:coverage": "vitest run --config vitest.config.ts --coverage",
48
+ "typecheck": "tsc --project tsconfig.json --noEmit"
49
+ },
50
+ "dependencies": {
51
+ "@formadapter/core": "^0.0.0"
52
+ },
53
+ "license": "MIT"
54
+ }