-
-
Notifications
You must be signed in to change notification settings - Fork 1
/
serve.mjs
372 lines (323 loc) · 11.8 KB
/
serve.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
// @ts-check
import Cache from "graphql-react/Cache.mjs";
import CacheContext from "graphql-react/CacheContext.mjs";
import Loading from "graphql-react/Loading.mjs";
import LoadingContext from "graphql-react/LoadingContext.mjs";
import { createElement as h, Fragment } from "react";
import { renderToString } from "react-dom/server";
import waterfallRender from "react-waterfall-render/waterfallRender.mjs";
import { Status, STATUS_TEXT } from "std/http/http_status.ts";
import { serve as serveHttp } from "std/http/server.ts";
import { toFileUrl } from "std/path/mod.ts";
import assertImportMap from "./assertImportMap.mjs";
import HeadManager from "./HeadManager.mjs";
import HeadManagerContext from "./HeadManagerContext.mjs";
import Html from "./Html.mjs";
import jsonToRawHtmlScriptValue from "./jsonToRawHtmlScriptValue.mjs";
import publicFileResponse from "./publicFileResponse.mjs";
import readImportMapFile from "./readImportMapFile.mjs";
import RouteContext from "./RouteContext.mjs";
import TransferContext from "./TransferContext.mjs";
/**
* Serves a Ruck app.
* @param {object} options Options.
* @param {import("./assertImportMap.mjs").ImportMap
* | URL} options.clientImportMap Client import map object or JSON file URL.
* @param {string} [options.esModuleShimsSrc]
* [`es-module-shims`](https://github.com/guybedford/es-module-shims) script
* `src` URL. Defaults to `"https://unpkg.com/es-module-shims"`.
* @param {URL} [options.publicDir] Public directory file URL. Defaults to a
* `public` directory in the CWD.
* @param {HtmlComponent} [options.htmlComponent] React component that renders
* the HTML for Ruck app page responses. Defaults to {@linkcode Html}.
* @param {number} options.port Port to serve on.
* @param {AbortSignal} [options.signal] Abort controller signal to close the
* server.
* @returns {Promise<{ close: Promise<void> }>} Resolves once the server is
* listening a `close` promise that resolves once the server closes.
*/
export default async function serve({
clientImportMap,
esModuleShimsSrc = "https://unpkg.com/es-module-shims",
publicDir = new URL("public/", toFileUrl(Deno.cwd() + "/")),
htmlComponent = Html,
port,
signal,
}) {
if (
!(clientImportMap instanceof URL) &&
(typeof clientImportMap !== "object" || !clientImportMap)
) {
throw new TypeError(
"Option `clientImportMap` must be an import map object or `URL` instance.",
);
}
if (typeof esModuleShimsSrc !== "string") {
throw new TypeError("Option `esModuleShimsSrc` must be a string.");
}
if (!(publicDir instanceof URL)) {
throw new TypeError("Option `publicDir` must be a `URL` instance.");
}
if (!publicDir.href.endsWith("/")) {
throw new TypeError("Option `publicDir` must be a URL ending with `/`.");
}
if (typeof htmlComponent !== "function") {
throw new TypeError("Option `htmlComponent` must be a function.");
}
if (typeof port !== "number") {
throw new TypeError("Option `port` must be a number.");
}
if (signal !== undefined && !(signal instanceof AbortSignal)) {
throw new TypeError("Option `signal` must be an `AbortSignal` instance.");
}
/** @type {import("./assertImportMap.mjs").ImportMap} */
let clientImportMapContent;
if (clientImportMap instanceof URL) {
clientImportMapContent = await readImportMapFile(clientImportMap);
} else {
try {
assertImportMap(clientImportMap);
} catch (cause) {
throw new TypeError(
"Option `clientImportMap` must be an import map object.",
{ cause },
);
}
clientImportMapContent = clientImportMap;
}
// Todo: Validate and handle predictable client import map issues, such as
// missing Ruck dependencies.
const routerFileUrl = new URL("router.mjs", publicDir);
/** @type {Router} */
let router;
try {
({ default: router } = await import(routerFileUrl.href));
} catch (cause) {
throw new Error(`Error importing \`${routerFileUrl.href}\`.`, { cause });
}
const appFileUrl = new URL("components/App.mjs", publicDir);
/** @type {AppComponent} */
let App;
try {
({ default: App } = await import(appFileUrl.href));
} catch (cause) {
throw new Error(`Error importing \`${appFileUrl.href}\`.`, { cause });
}
const close = serveHttp(
async (request) => {
// The route URL should be what the client originally used to start the
// request.
const routeUrl = new URL(request.url);
// Reverse proxy servers (load balancers, CDNs, etc.) may have forwarded
// the original client request using a different protocol or host. E.g.
// Fly.io forwards `https:` requests to the deployed server using `http:`.
const headerXForwardedProto = request.headers.get("x-forwarded-proto");
if (headerXForwardedProto) {
routeUrl.protocol = headerXForwardedProto + ":";
}
const headerXForwardedHost = request.headers.get("x-forwarded-host");
if (headerXForwardedHost) {
routeUrl.hostname = headerXForwardedHost;
}
// Todo: Investigate supporting the `x-forwarded-port` header.
// Todo: Investigate supporting the standard `Forwarded` header.
// First, try serving the request as a file from the public directory.
// If no such file exists the request is for an app route.
if (
// Public files have a URL pathname; the homepage is an app route.
routeUrl.pathname !== "/"
) {
try {
return await publicFileResponse(request, publicDir);
} catch (cause) {
if (!(cause instanceof Deno.errors.NotFound)) {
throw new Error("Ruck couldn’t serve a public file.", { cause });
}
}
}
const headManager = new HeadManager();
/** @type {RoutePlan} */
let routePlan;
try {
routePlan = router(routeUrl, headManager, true);
} catch (cause) {
throw new Error(
`Ruck couldn’t plan the route for URL ${routeUrl.href}.`,
{ cause },
);
}
if (typeof routePlan !== "object" || !routePlan) {
throw new TypeError(
`Ruck route plan is invalid for URL ${routeUrl.href}.`,
);
}
/** @type {import("react").ReactNode} */
let routeContent;
try {
routeContent = await routePlan.content;
} catch (cause) {
throw new Error(
`Ruck couldn’t resolve the route content for URL ${routeUrl.href}.`,
{ cause },
);
}
// Todo: Validate the route content.
try {
const cache = new Cache();
const loading = new Loading();
/** @type {ResponseInit} */
const responseInit = {
status: Status.OK,
statusText: STATUS_TEXT[Status.OK],
headers: new Headers({
"content-type": "text/html; charset=utf-8",
}),
};
/** @type {Transfer} */
const transfer = { request, responseInit };
const bodyReactRootInnerHtml = await waterfallRender(
h(
TransferContext.Provider,
{ value: transfer },
h(
RouteContext.Provider,
{
value: {
url: routeUrl,
content: routeContent,
},
},
h(
HeadManagerContext.Provider,
{ value: headManager },
h(
CacheContext.Provider,
{ value: cache },
h(LoadingContext.Provider, { value: loading }, h(App)),
),
),
),
),
renderToString,
);
const responseBody = `<!DOCTYPE html>
${
renderToString(
h(
TransferContext.Provider,
{ value: transfer },
h(htmlComponent, {
esModuleShimsScript: h("script", {
async: true,
src: esModuleShimsSrc,
}),
importMapScript: h("script", {
type: "importmap",
dangerouslySetInnerHTML: {
__html: JSON.stringify(clientImportMapContent),
},
}),
headReactRoot: h(
Fragment,
null,
h("meta", { name: "ruck-head-start" }),
headManager.getHeadContent(),
h("meta", { name: "ruck-head-end" }),
),
bodyReactRoot: h("div", {
id: "ruck-app",
dangerouslySetInnerHTML: { __html: bodyReactRootInnerHtml },
}),
hydrationScript: h("script", {
type: "module",
dangerouslySetInnerHTML: {
__html: /* JS */ `import hydrate from "ruck/hydrate.mjs";
import App from "/components/App.mjs";
import router from "/router.mjs";
hydrate({
router,
appComponent: App,
cacheData: ${jsonToRawHtmlScriptValue(JSON.stringify(cache.store))},
});
`,
},
}),
}),
),
)
}`;
return new Response(responseBody, responseInit);
} catch (cause) {
throw new Error("Ruck couldn’t serve the rendered route.", { cause });
}
},
{ port, signal },
);
return { close };
}
/**
* Isomorphic React component that renders the Ruck React app.
* @callback AppComponent
* @returns {import("react").ReactElement}
*/
/**
* Server only React component that renders a HTML page for a server side
* rendered Ruck app page.
* @callback HtmlComponent
* @param {HtmlComponentProps} props Props.
* @returns {import("react").ReactElement}
*/
/**
* {@linkcode HtmlComponent} React component props.
* @typedef {object} HtmlComponentProps
* @prop {import("react").ReactElement} esModuleShimsScript
* [`es-module-shims`](https://github.com/guybedford/es-module-shims) script.
* @prop {import("react").ReactElement} importMapScript Import map script.
* Should be the first script in the HTML.
* @prop {import("react").ReactNode} headReactRoot HTML head React root for Ruck
* managed head tags. Should be early in the HTML head, typically after the
* import map script as it may contain scripts.
* @prop {import("react").ReactNode} bodyReactRoot HTML body React root for the
* main Ruck app content.
* @prop {import("react").ReactElement} hydrationScript Ruck app hydration
* script. Should be towards the end of the HTML body.
*/
/**
* Ruck response init.
* @typedef {object} ResponseInit
* @prop {Headers} headers Headers.
* @prop {number} status HTTP status code.
* @prop {string} [statusText] HTTP status text.
*/
/**
* Ruck app route that has loaded and is ready to render.
* @typedef {object} Route
* @prop {URL} url Route URL.
* @prop {import("react").ReactNode} content Route content.
* @prop {() => void} [cleanup] Callback that runs when navigation to this route
* aborts, or after navigation to the next route for a different page. Doesn’t
* run during SSR.
*/
/**
* Ruck app route plan.
* @typedef {object} RoutePlan
* @prop {import("react").ReactNode
* | Promise<import("react").ReactNode>} content Route content.
* @prop {() => void} [cleanup] Callback that runs when navigation to this route
* aborts, or after navigation to the next route for a different page. Doesn’t
* run during SSR.
*/
/**
* Isomorphic function that gets the Ruck app route for a URL.
* @callback Router
* @param {URL} url Ruck app route URL.
* @param {import("./HeadManager.mjs").default} headManager Head tag manager.
* @param {boolean} isInitialRoute Is it the initial route.
* @returns {RoutePlan}
*/
/**
* Ruck app request and response context.
* @typedef {object} Transfer
* @prop {Readonly<Request>} request Request.
* @prop {ResponseInit} responseInit Response initialization options.
*/