@remix-run/spa 0.0.0 → 0.1.1

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) 2025 Shopify Inc.
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 CHANGED
@@ -1,3 +1,96 @@
1
- # Placeholder Package
1
+ # spa
2
2
 
3
- This package is a placeholder published at `0.0.0` to reserve the npm name and configure CI publish permissions.
3
+ Client-rendered application routing for Remix. It connects a standard fetch router to the browser
4
+ UI runtime without exposing the response carrier used to associate route responses with Remix
5
+ nodes.
6
+
7
+ ## Features
8
+
9
+ - **Standard fetch routing** - Preserve `Request` to `Response` dispatch, redirects, status codes,
10
+ headers, middleware, and cancellation
11
+ - **Node rendering middleware** - Render `RemixNode` values through `context.render()`
12
+ - **Browser runtime** - Resolve the current URL and future frame navigations through the router
13
+ - **Initial fallback** - Display an interactive Remix node while the first route loads
14
+
15
+ ## Installation
16
+
17
+ ```sh
18
+ npm i remix
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Install `render()` before middleware and route handlers that use `context.render()`, then pass the
24
+ router to `run()`.
25
+
26
+ ```tsx
27
+ import { createRouter } from 'remix/router'
28
+ import { get, route } from 'remix/routes'
29
+ import { render, run } from 'remix/spa'
30
+
31
+ const routes = route({
32
+ home: get('/'),
33
+ about: get('/about'),
34
+ })
35
+
36
+ const router = createRouter({
37
+ middleware: [render()],
38
+ defaultHandler({ render }) {
39
+ return render(<h1>Not Found</h1>, { status: 404 })
40
+ },
41
+ })
42
+
43
+ router.map(routes, {
44
+ actions: {
45
+ home({ render }) {
46
+ return render(<h1>Home</h1>)
47
+ },
48
+ about({ render }) {
49
+ return render(<h1>About</h1>)
50
+ },
51
+ },
52
+ })
53
+
54
+ const app = run(router, { fallback: <p>Loading…</p> })
55
+ await app.ready()
56
+ ```
57
+
58
+ The router remains an ordinary fetch router, so its normal actions, controllers, middleware, and
59
+ context typing continue to apply.
60
+
61
+ ## Wrapping Route Content
62
+
63
+ Pass a request-aware transform to `render()` when every route should share an application shell.
64
+
65
+ ```tsx
66
+ const router = createRouter({
67
+ middleware: [
68
+ render((content, { url }) => (
69
+ <main data-pathname={url.pathname}>
70
+ <nav>{/* ... */}</nav>
71
+ {content}
72
+ </main>
73
+ )),
74
+ ],
75
+ })
76
+ ```
77
+
78
+ ## Related Packages
79
+
80
+ - [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Request
81
+ routing, controllers, and middleware context
82
+ - [`render-middleware`](https://github.com/remix-run/remix/tree/main/packages/render-middleware) -
83
+ Request-scoped renderer middleware
84
+ - [`ui`](https://github.com/remix-run/remix/tree/main/packages/ui) - Remix components, frames, and
85
+ browser runtime
86
+
87
+ ## Related Work
88
+
89
+ - [Fetch standard](https://fetch.spec.whatwg.org/) - The request and response model preserved by SPA
90
+ routers
91
+ - [Navigation API](https://wicg.github.io/navigation-api/) - Browser navigation lifecycle used by
92
+ the Remix UI runtime
93
+
94
+ ## License
95
+
96
+ See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
@@ -0,0 +1,2 @@
1
+ export { render, run, type Render, type RenderTransform, type Router, type RunOptions, type Runtime, } from './lib/spa.ts';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,MAAM,EACN,GAAG,EACH,KAAK,MAAM,EACX,KAAK,eAAe,EACpB,KAAK,MAAM,EACX,KAAK,UAAU,EACf,KAAK,OAAO,GACb,MAAM,cAAc,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { render, run, } from './lib/spa.js';
@@ -0,0 +1,65 @@
1
+ import type { Middleware, RequestContext } from '@remix-run/fetch-router';
2
+ import { type Renderer } from '@remix-run/render-middleware';
3
+ import { type AppRuntime, type RemixNode } from '@remix-run/ui';
4
+ /** Creates a response that the SPA runtime can render. */
5
+ export interface Render {
6
+ /**
7
+ * Creates a renderable route response.
8
+ *
9
+ * @param node Node to render.
10
+ * @param init Optional response status and headers.
11
+ * @returns A response understood by the SPA runtime.
12
+ */
13
+ (node: RemixNode, init?: ResponseInit): Response;
14
+ }
15
+ /** Transforms a route node before the SPA runtime renders it. */
16
+ export interface RenderTransform {
17
+ /**
18
+ * Transforms a node using the active request context.
19
+ *
20
+ * @param node Node returned by the route.
21
+ * @param context Active request context.
22
+ * @returns The node to render.
23
+ */
24
+ (node: RemixNode, context: RequestContext): RemixNode;
25
+ }
26
+ /** Minimal router contract used by {@link run}. */
27
+ export interface Router {
28
+ fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
29
+ }
30
+ /** Client runtime returned by {@link run}. */
31
+ export type Runtime = Omit<AppRuntime, 'ready'> & {
32
+ /** Resolves after the client runtime starts and the initial route renders. */
33
+ ready(): Promise<void>;
34
+ };
35
+ /** Options for starting a client-rendered Remix application. */
36
+ export interface RunOptions {
37
+ /** Remix node to display while the initial route loads. */
38
+ fallback?: RemixNode;
39
+ }
40
+ type RenderMiddleware = Middleware<{
41
+ key: typeof Renderer;
42
+ value: Render;
43
+ property: 'render';
44
+ }>;
45
+ /**
46
+ * Creates middleware that exposes `context.render()` for SPA route responses.
47
+ *
48
+ * @param transform Optional transform that wraps or replaces route nodes.
49
+ * @returns Middleware that installs the SPA renderer on request context.
50
+ */
51
+ export declare function render(transform?: RenderTransform): RenderMiddleware;
52
+ /**
53
+ * Starts a client-rendered Remix application for the current document.
54
+ *
55
+ * The current URL and subsequent same-origin navigations are dispatched through `router`. Route
56
+ * handlers return responses created by the {@link render} middleware, and their associated nodes
57
+ * are rendered into the document's top frame.
58
+ *
59
+ * @param router Router that resolves browser requests to SPA route responses.
60
+ * @param options Options for the initial render.
61
+ * @returns The running application runtime.
62
+ */
63
+ export declare function run(router: Router, options?: RunOptions): Runtime;
64
+ export {};
65
+ //# sourceMappingURL=spa.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spa.d.ts","sourceRoot":"","sources":["../../src/lib/spa.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAA;AACzE,OAAO,EAAc,KAAK,QAAQ,EAAE,MAAM,8BAA8B,CAAA;AACxE,OAAO,EAGL,KAAK,UAAU,EACf,KAAK,SAAS,EAEf,MAAM,eAAe,CAAA;AAEtB,0DAA0D;AAC1D,MAAM,WAAW,MAAM;IACrB;;;;;;OAMG;IACH,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,EAAE,YAAY,GAAG,QAAQ,CAAA;CACjD;AAED,iEAAiE;AACjE,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,cAAc,GAAG,SAAS,CAAA;CACtD;AAED,mDAAmD;AACnD,MAAM,WAAW,MAAM;IACrB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAC5E;AAED,8CAA8C;AAC9C,MAAM,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,GAAG;IAChD,8EAA8E;IAC9E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvB,CAAA;AAED,gEAAgE;AAChE,MAAM,WAAW,UAAU;IACzB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,SAAS,CAAA;CACrB;AAED,KAAK,gBAAgB,GAAG,UAAU,CAAC;IACjC,GAAG,EAAE,OAAO,QAAQ,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,QAAQ,CAAA;CACnB,CAAC,CAAA;AAKF;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,SAAS,CAAC,EAAE,eAAe,GAAG,gBAAgB,CAOpE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,UAAe,GAAG,OAAO,CA0BrE"}
@@ -0,0 +1,109 @@
1
+ import { renderWith } from '@remix-run/render-middleware';
2
+ import { run as runRuntime, spaResponse, } from '@remix-run/ui';
3
+ const redirectStatuses = new Set([301, 302, 303, 307, 308]);
4
+ const maxRedirects = 10;
5
+ /**
6
+ * Creates middleware that exposes `context.render()` for SPA route responses.
7
+ *
8
+ * @param transform Optional transform that wraps or replaces route nodes.
9
+ * @returns Middleware that installs the SPA renderer on request context.
10
+ */
11
+ export function render(transform) {
12
+ return renderWith((context) => function render(node, init) {
13
+ return spaResponse.create(transform ? transform(node, context) : node, init);
14
+ });
15
+ }
16
+ /**
17
+ * Starts a client-rendered Remix application for the current document.
18
+ *
19
+ * The current URL and subsequent same-origin navigations are dispatched through `router`. Route
20
+ * handlers return responses created by the {@link render} middleware, and their associated nodes
21
+ * are rendered into the document's top frame.
22
+ *
23
+ * @param router Router that resolves browser requests to SPA route responses.
24
+ * @param options Options for the initial render.
25
+ * @returns The running application runtime.
26
+ */
27
+ export function run(router, options = {}) {
28
+ let app = runRuntime({
29
+ loadModule() {
30
+ throw new Error('SPA responses cannot hydrate client entries');
31
+ },
32
+ async resolveFrame(src, options) {
33
+ let url = new URL(src, document.baseURI);
34
+ let { response, redirectedTo } = await followFrameRedirects(router, url, {
35
+ method: options?.method,
36
+ body: getRequestBody(options),
37
+ signal: options?.signal,
38
+ });
39
+ return spaResponse.finalize(response, redirectedTo);
40
+ },
41
+ });
42
+ let readyPromise = app.ready().then(async () => {
43
+ if (options.fallback !== undefined) {
44
+ await app.frames.top.replace(options.fallback);
45
+ }
46
+ await app.frames.top.reload();
47
+ });
48
+ return Object.assign(app, {
49
+ ready: () => readyPromise,
50
+ });
51
+ }
52
+ async function followFrameRedirects(router, url, init) {
53
+ let initialOrigin = url.origin;
54
+ let method = init.method?.toUpperCase() ?? 'GET';
55
+ let body = init.body;
56
+ let redirectedTo;
57
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
58
+ let response = await router.fetch(url, { ...init, method, body });
59
+ if (!redirectStatuses.has(response.status)) {
60
+ return { response, redirectedTo };
61
+ }
62
+ let location = response.headers.get('Location');
63
+ if (!location)
64
+ return { response };
65
+ if (redirectCount === maxRedirects) {
66
+ throw new TypeError(`SPA route exceeded ${maxRedirects} redirects`);
67
+ }
68
+ let nextUrl = new URL(location, url);
69
+ if (nextUrl.origin !== initialOrigin) {
70
+ throw new TypeError('SPA routes cannot redirect to another origin');
71
+ }
72
+ if ((response.status === 303 && method !== 'GET' && method !== 'HEAD') ||
73
+ ((response.status === 301 || response.status === 302) && method === 'POST')) {
74
+ method = 'GET';
75
+ body = undefined;
76
+ }
77
+ url = nextUrl;
78
+ redirectedTo = url.href;
79
+ }
80
+ throw new TypeError(`SPA route exceeded ${maxRedirects} redirects`);
81
+ }
82
+ // Frame reloads can receive raw FormData without going through form navigation. Encode it here so
83
+ // manual reloads use the requested form encoding instead of always sending multipart bodies.
84
+ function getRequestBody(options) {
85
+ let formData = options?.formData;
86
+ let method = options?.method;
87
+ if (!formData || !method || ['get', 'head'].includes(method.toLowerCase()))
88
+ return;
89
+ let encType = options?.encType;
90
+ if (encType === 'text/plain') {
91
+ let body = '';
92
+ for (let [name, value] of formData) {
93
+ name = normalizeLineBreaks(name);
94
+ value = normalizeLineBreaks(typeof value === 'string' ? value : value.name);
95
+ body += `${name}=${value}\r\n`;
96
+ }
97
+ return new Blob([body], { type: 'text/plain' });
98
+ }
99
+ if (encType !== 'application/x-www-form-urlencoded')
100
+ return formData;
101
+ let body = new URLSearchParams();
102
+ for (let [name, value] of formData) {
103
+ body.append(name, typeof value === 'string' ? value : value.name);
104
+ }
105
+ return body;
106
+ }
107
+ function normalizeLineBreaks(value) {
108
+ return value.replace(/\r\n|\r|\n/g, '\r\n');
109
+ }
package/package.json CHANGED
@@ -1,14 +1,55 @@
1
1
  {
2
2
  "name": "@remix-run/spa",
3
- "version": "0.0.0",
4
- "description": "Placeholder package for Remix CI/OIDC setup",
3
+ "version": "0.1.1",
4
+ "description": "Client-rendered application routing for Remix",
5
+ "author": "Michael Jackson <mjijackson@gmail.com>",
5
6
  "license": "MIT",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/remix-run/remix.git",
9
10
  "directory": "packages/spa"
10
11
  },
11
- "publishConfig": {
12
- "access": "public"
12
+ "homepage": "https://github.com/remix-run/remix/tree/main/packages/spa#readme",
13
+ "files": [
14
+ "LICENSE",
15
+ "README.md",
16
+ "dist",
17
+ "src",
18
+ "!src/**/*.test*.ts",
19
+ "!src/**/*.test*.tsx"
20
+ ],
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "devDependencies": {
30
+ "@types/dom-navigation": "^1.0.7",
31
+ "@types/node": "^24.6.0",
32
+ "typescript": "^7.0.2",
33
+ "@remix-run/test": "^0.6.0",
34
+ "@remix-run/assert": "^0.3.0"
35
+ },
36
+ "dependencies": {
37
+ "@remix-run/fetch-router": "^0.22.0",
38
+ "@remix-run/render-middleware": "^0.3.0",
39
+ "@remix-run/ui": "^0.9.0"
40
+ },
41
+ "keywords": [
42
+ "remix",
43
+ "spa",
44
+ "router",
45
+ "client",
46
+ "browser"
47
+ ],
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.build.json",
50
+ "clean": "git clean -fdX",
51
+ "test": "remix test",
52
+ "test:bun": "bun x --bun remix test",
53
+ "typecheck": "tsc --noEmit"
13
54
  }
14
- }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export {
2
+ render,
3
+ run,
4
+ type Render,
5
+ type RenderTransform,
6
+ type Router,
7
+ type RunOptions,
8
+ type Runtime,
9
+ } from './lib/spa.ts'
package/src/lib/spa.ts ADDED
@@ -0,0 +1,187 @@
1
+ import type { Middleware, RequestContext } from '@remix-run/fetch-router'
2
+ import { renderWith, type Renderer } from '@remix-run/render-middleware'
3
+ import {
4
+ run as runRuntime,
5
+ spaResponse,
6
+ type AppRuntime,
7
+ type RemixNode,
8
+ type ResolveFrameOptions,
9
+ } from '@remix-run/ui'
10
+
11
+ /** Creates a response that the SPA runtime can render. */
12
+ export interface Render {
13
+ /**
14
+ * Creates a renderable route response.
15
+ *
16
+ * @param node Node to render.
17
+ * @param init Optional response status and headers.
18
+ * @returns A response understood by the SPA runtime.
19
+ */
20
+ (node: RemixNode, init?: ResponseInit): Response
21
+ }
22
+
23
+ /** Transforms a route node before the SPA runtime renders it. */
24
+ export interface RenderTransform {
25
+ /**
26
+ * Transforms a node using the active request context.
27
+ *
28
+ * @param node Node returned by the route.
29
+ * @param context Active request context.
30
+ * @returns The node to render.
31
+ */
32
+ (node: RemixNode, context: RequestContext): RemixNode
33
+ }
34
+
35
+ /** Minimal router contract used by {@link run}. */
36
+ export interface Router {
37
+ fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>
38
+ }
39
+
40
+ /** Client runtime returned by {@link run}. */
41
+ export type Runtime = Omit<AppRuntime, 'ready'> & {
42
+ /** Resolves after the client runtime starts and the initial route renders. */
43
+ ready(): Promise<void>
44
+ }
45
+
46
+ /** Options for starting a client-rendered Remix application. */
47
+ export interface RunOptions {
48
+ /** Remix node to display while the initial route loads. */
49
+ fallback?: RemixNode
50
+ }
51
+
52
+ type RenderMiddleware = Middleware<{
53
+ key: typeof Renderer
54
+ value: Render
55
+ property: 'render'
56
+ }>
57
+
58
+ const redirectStatuses = new Set([301, 302, 303, 307, 308])
59
+ const maxRedirects = 10
60
+
61
+ /**
62
+ * Creates middleware that exposes `context.render()` for SPA route responses.
63
+ *
64
+ * @param transform Optional transform that wraps or replaces route nodes.
65
+ * @returns Middleware that installs the SPA renderer on request context.
66
+ */
67
+ export function render(transform?: RenderTransform): RenderMiddleware {
68
+ return renderWith(
69
+ (context) =>
70
+ function render(node: RemixNode, init?: ResponseInit): Response {
71
+ return spaResponse.create(transform ? transform(node, context) : node, init)
72
+ },
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Starts a client-rendered Remix application for the current document.
78
+ *
79
+ * The current URL and subsequent same-origin navigations are dispatched through `router`. Route
80
+ * handlers return responses created by the {@link render} middleware, and their associated nodes
81
+ * are rendered into the document's top frame.
82
+ *
83
+ * @param router Router that resolves browser requests to SPA route responses.
84
+ * @param options Options for the initial render.
85
+ * @returns The running application runtime.
86
+ */
87
+ export function run(router: Router, options: RunOptions = {}): Runtime {
88
+ let app = runRuntime({
89
+ loadModule() {
90
+ throw new Error('SPA responses cannot hydrate client entries')
91
+ },
92
+ async resolveFrame(src, options) {
93
+ let url = new URL(src, document.baseURI)
94
+ let { response, redirectedTo } = await followFrameRedirects(router, url, {
95
+ method: options?.method,
96
+ body: getRequestBody(options),
97
+ signal: options?.signal,
98
+ })
99
+ return spaResponse.finalize(response, redirectedTo)
100
+ },
101
+ })
102
+
103
+ let readyPromise = app.ready().then(async () => {
104
+ if (options.fallback !== undefined) {
105
+ await app.frames.top.replace(options.fallback)
106
+ }
107
+ await app.frames.top.reload()
108
+ })
109
+
110
+ return Object.assign(app, {
111
+ ready: () => readyPromise,
112
+ })
113
+ }
114
+
115
+ async function followFrameRedirects(
116
+ router: Router,
117
+ url: URL,
118
+ init: RequestInit,
119
+ ): Promise<{ response: Response; redirectedTo?: string }> {
120
+ let initialOrigin = url.origin
121
+ let method = init.method?.toUpperCase() ?? 'GET'
122
+ let body = init.body
123
+ let redirectedTo: string | undefined
124
+
125
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
126
+ let response = await router.fetch(url, { ...init, method, body })
127
+ if (!redirectStatuses.has(response.status)) {
128
+ return { response, redirectedTo }
129
+ }
130
+
131
+ let location = response.headers.get('Location')
132
+ if (!location) return { response }
133
+ if (redirectCount === maxRedirects) {
134
+ throw new TypeError(`SPA route exceeded ${maxRedirects} redirects`)
135
+ }
136
+
137
+ let nextUrl = new URL(location, url)
138
+ if (nextUrl.origin !== initialOrigin) {
139
+ throw new TypeError('SPA routes cannot redirect to another origin')
140
+ }
141
+
142
+ if (
143
+ (response.status === 303 && method !== 'GET' && method !== 'HEAD') ||
144
+ ((response.status === 301 || response.status === 302) && method === 'POST')
145
+ ) {
146
+ method = 'GET'
147
+ body = undefined
148
+ }
149
+
150
+ url = nextUrl
151
+ redirectedTo = url.href
152
+ }
153
+
154
+ throw new TypeError(`SPA route exceeded ${maxRedirects} redirects`)
155
+ }
156
+
157
+ // Frame reloads can receive raw FormData without going through form navigation. Encode it here so
158
+ // manual reloads use the requested form encoding instead of always sending multipart bodies.
159
+ function getRequestBody(options?: ResolveFrameOptions): BodyInit | undefined {
160
+ let formData = options?.formData
161
+ let method = options?.method
162
+ if (!formData || !method || ['get', 'head'].includes(method.toLowerCase())) return
163
+
164
+ let encType = options?.encType
165
+
166
+ if (encType === 'text/plain') {
167
+ let body = ''
168
+ for (let [name, value] of formData) {
169
+ name = normalizeLineBreaks(name)
170
+ value = normalizeLineBreaks(typeof value === 'string' ? value : value.name)
171
+ body += `${name}=${value}\r\n`
172
+ }
173
+ return new Blob([body], { type: 'text/plain' })
174
+ }
175
+
176
+ if (encType !== 'application/x-www-form-urlencoded') return formData
177
+
178
+ let body = new URLSearchParams()
179
+ for (let [name, value] of formData) {
180
+ body.append(name, typeof value === 'string' ? value : value.name)
181
+ }
182
+ return body
183
+ }
184
+
185
+ function normalizeLineBreaks(value: string): string {
186
+ return value.replace(/\r\n|\r|\n/g, '\r\n')
187
+ }