@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 +21 -0
- package/README.md +43 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +78 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|