@remix-run/spa 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/spa.d.ts +65 -0
- package/dist/lib/spa.d.ts.map +1 -0
- package/dist/lib/spa.js +109 -0
- package/package.json +46 -5
- package/src/index.ts +9 -0
- package/src/lib/spa.ts +187 -0
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
|
-
#
|
|
1
|
+
# spa
|
|
2
2
|
|
|
3
|
-
|
|
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)
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|
package/dist/lib/spa.js
ADDED
|
@@ -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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
-
"
|
|
12
|
-
|
|
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/assert": "^0.3.0",
|
|
34
|
+
"@remix-run/test": "^0.6.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@remix-run/render-middleware": "^0.2.0",
|
|
38
|
+
"@remix-run/ui": "^0.8.0",
|
|
39
|
+
"@remix-run/fetch-router": "^0.21.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
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
|
+
}
|