react-cheminfo 0.60.0 → 0.61.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/README.md +29 -0
- package/lib/server/siteFiles.d.ts +28 -0
- package/lib/server/siteFiles.d.ts.map +1 -0
- package/lib/server/siteFiles.js +57 -0
- package/lib/server/siteFiles.js.map +1 -0
- package/lib/server/staticSite.d.ts +42 -0
- package/lib/server/staticSite.d.ts.map +1 -0
- package/lib/server/staticSite.js +114 -0
- package/lib/server/staticSite.js.map +1 -0
- package/lib/server.d.ts +4 -0
- package/lib/server.d.ts.map +1 -0
- package/lib/server.js +3 -0
- package/lib/server.js.map +1 -0
- package/package.json +12 -1
- package/src/server/siteFiles.ts +64 -0
- package/src/server/staticSite.ts +188 -0
- package/src/server.ts +7 -0
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ only when it drags a heavy dependency behind it.
|
|
|
36
36
|
| `react-cheminfo/ui` | every React component and hook — 60 value exports | React |
|
|
37
37
|
| `react-cheminfo/vite` | the prerender plugin and the OG card | nothing; React on the card |
|
|
38
38
|
| `react-cheminfo/pages` | the Cloudflare Pages analytics middleware | nothing |
|
|
39
|
+
| `react-cheminfo/server` | the prerendered build, served from Node | fastify, @fastify/static; Node only |
|
|
39
40
|
| `react-cheminfo/orbital` | the 3D atomic-orbital viewer | React, molstar |
|
|
40
41
|
| `react-cheminfo/molecule3d` | the 3D molecule viewer and its toolbar | React, molstar |
|
|
41
42
|
| `react-cheminfo/structure` | the structure editor and renderer | React, react-ocl, OCL |
|
|
@@ -1222,6 +1223,34 @@ view costs one invocation rather than one per asset:
|
|
|
1222
1223
|
{ "version": 1, "include": ["/*"], "exclude": ["/assets/*"] }
|
|
1223
1224
|
```
|
|
1224
1225
|
|
|
1226
|
+
### On a Node server, next to an API
|
|
1227
|
+
|
|
1228
|
+
A prerendered site can also be deployed as a server, with its API and its MCP
|
|
1229
|
+
tools beside the pages. `staticSite` from `react-cheminfo/server` serves the
|
|
1230
|
+
build by the rules the static hosts follow: `/titration` is `titration.html`
|
|
1231
|
+
with no redirect, an unknown address is `404.html` and never the home page,
|
|
1232
|
+
pages are revalidated on every visit and `assets/` is kept for a year, the
|
|
1233
|
+
`TRACKING_SCRIPT` snippet is in every page, and `/health` answers the deploy
|
|
1234
|
+
script. Only a file the build wrote is ever served.
|
|
1235
|
+
|
|
1236
|
+
```ts
|
|
1237
|
+
import { fastify } from 'fastify';
|
|
1238
|
+
import { staticSite } from 'react-cheminfo/server';
|
|
1239
|
+
|
|
1240
|
+
const app = fastify();
|
|
1241
|
+
// …the API under /v1/…
|
|
1242
|
+
await app.register(staticSite, {
|
|
1243
|
+
root: 'dist',
|
|
1244
|
+
trackingScript: process.env.TRACKING_SCRIPT,
|
|
1245
|
+
redirects: { '/precipitation': '/equilibrium' },
|
|
1246
|
+
});
|
|
1247
|
+
```
|
|
1248
|
+
|
|
1249
|
+
Addresses under `reserved` (default `['/v1/']`) are left to the API, which
|
|
1250
|
+
answers its own 404. Without a build, `staticSite` logs it and serves the API
|
|
1251
|
+
alone. `fastify` (5) and `@fastify/static` (10) are optional peers, installed
|
|
1252
|
+
by a site that has a server.
|
|
1253
|
+
|
|
1225
1254
|
## Saying which build is running
|
|
1226
1255
|
|
|
1227
1256
|
A report of something going wrong is worth answering only when we know what was
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every file of a built site, by its path from the root, written with `/`.
|
|
3
|
+
*
|
|
4
|
+
* The build does not change while the server runs, so the list is read once,
|
|
5
|
+
* and only a file on it is ever served: a path that climbs out of the root
|
|
6
|
+
* names nothing on the list.
|
|
7
|
+
* @param root - The directory the build wrote, e.g. `dist`.
|
|
8
|
+
* @returns The paths, e.g. `titration.html` and `assets/index-a1b2.js`, or
|
|
9
|
+
* `undefined` when the directory does not exist.
|
|
10
|
+
*/
|
|
11
|
+
export declare function listSiteFiles(root: string): Promise<Set<string> | undefined>;
|
|
12
|
+
/**
|
|
13
|
+
* The file that answers an address, as the static hosts of the family answer
|
|
14
|
+
* it: `/titration` is `titration.html` (never a redirect to `/titration/`),
|
|
15
|
+
* `/` and `/fr/` are their `index.html`, and any other file is itself.
|
|
16
|
+
* @param files - The files of the site, from {@link listSiteFiles}.
|
|
17
|
+
* @param pathname - The address, without its query, e.g. `/titration`.
|
|
18
|
+
* @returns The file, or `undefined` when the site has no such page.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveAddress(files: ReadonlySet<string>, pathname: string): string | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a file is a bundle named by its content, which a browser may keep
|
|
23
|
+
* for a year: everything under an `assets` directory.
|
|
24
|
+
* @param file - The file, from the root.
|
|
25
|
+
* @returns True for a hashed bundle.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isHashedBundle(file: string): boolean;
|
|
28
|
+
//# sourceMappingURL=siteFiles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"siteFiles.d.ts","sourceRoot":"","sources":["../../src/server/siteFiles.ts"],"names":[],"mappings":"AAIA;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,SAAS,CAAC,CAWlC;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,EAC1B,QAAQ,EAAE,MAAM,GACf,MAAM,GAAG,SAAS,CAapB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEpD"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Every file of a built site, by its path from the root, written with `/`.
|
|
6
|
+
*
|
|
7
|
+
* The build does not change while the server runs, so the list is read once,
|
|
8
|
+
* and only a file on it is ever served: a path that climbs out of the root
|
|
9
|
+
* names nothing on the list.
|
|
10
|
+
* @param root - The directory the build wrote, e.g. `dist`.
|
|
11
|
+
* @returns The paths, e.g. `titration.html` and `assets/index-a1b2.js`, or
|
|
12
|
+
* `undefined` when the directory does not exist.
|
|
13
|
+
*/
|
|
14
|
+
export async function listSiteFiles(root) {
|
|
15
|
+
if (!existsSync(root))
|
|
16
|
+
return undefined;
|
|
17
|
+
const entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
18
|
+
const files = new Set();
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (!entry.isFile())
|
|
21
|
+
continue;
|
|
22
|
+
files.add(relative(root, join(entry.parentPath, entry.name)).split(sep).join('/'));
|
|
23
|
+
}
|
|
24
|
+
return files;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The file that answers an address, as the static hosts of the family answer
|
|
28
|
+
* it: `/titration` is `titration.html` (never a redirect to `/titration/`),
|
|
29
|
+
* `/` and `/fr/` are their `index.html`, and any other file is itself.
|
|
30
|
+
* @param files - The files of the site, from {@link listSiteFiles}.
|
|
31
|
+
* @param pathname - The address, without its query, e.g. `/titration`.
|
|
32
|
+
* @returns The file, or `undefined` when the site has no such page.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveAddress(files, pathname) {
|
|
35
|
+
let path;
|
|
36
|
+
try {
|
|
37
|
+
path = decodeURIComponent(pathname);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const local = path.replace(/^\/+/, '');
|
|
43
|
+
const candidates = local === '' || local.endsWith('/')
|
|
44
|
+
? [`${local}index.html`]
|
|
45
|
+
: [local, `${local}.html`, `${local}/index.html`];
|
|
46
|
+
return candidates.find((candidate) => files.has(candidate));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Whether a file is a bundle named by its content, which a browser may keep
|
|
50
|
+
* for a year: everything under an `assets` directory.
|
|
51
|
+
* @param file - The file, from the root.
|
|
52
|
+
* @returns True for a hashed bundle.
|
|
53
|
+
*/
|
|
54
|
+
export function isHashedBundle(file) {
|
|
55
|
+
return file.startsWith('assets/') || file.includes('/assets/');
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=siteFiles.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"siteFiles.js","sourceRoot":"","sources":["../../src/server/siteFiles.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAC3C,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAEhD;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,IAAY;IAEZ,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,SAAS,CAAC;IACxC,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9E,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE;YAAE,SAAS;QAC9B,KAAK,CAAC,GAAG,CACP,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CACxE,CAAC;IACJ,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAC5B,KAA0B,EAC1B,QAAgB;IAEhB,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IACtC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IACvC,MAAM,UAAU,GACd,KAAK,KAAK,EAAE,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC;QACjC,CAAC,CAAC,CAAC,GAAG,KAAK,YAAY,CAAC;QACxB,CAAC,CAAC,CAAC,KAAK,EAAE,GAAG,KAAK,OAAO,EAAE,GAAG,KAAK,aAAa,CAAC,CAAC;IACtD,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,OAAO,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;AACjE,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
/** Options of {@link staticSite}. */
|
|
3
|
+
export interface StaticSiteOptions {
|
|
4
|
+
/** The directory the build wrote, e.g. `dist`. */
|
|
5
|
+
root: string;
|
|
6
|
+
/**
|
|
7
|
+
* The analytics snippet, put at the end of the head of every page, as
|
|
8
|
+
* `TRACKING_SCRIPT` holds it.
|
|
9
|
+
* @default undefined - no snippet
|
|
10
|
+
*/
|
|
11
|
+
trackingScript?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Addresses the site does not have any more, and the one that does their
|
|
14
|
+
* work. The query is carried over, so a link handed out before the move
|
|
15
|
+
* keeps its configuration.
|
|
16
|
+
* @default {}
|
|
17
|
+
*/
|
|
18
|
+
redirects?: Readonly<Record<string, string>>;
|
|
19
|
+
/**
|
|
20
|
+
* Address prefixes another plugin answers, such as the API, which never
|
|
21
|
+
* answer a page: an unknown address there is the API's own 404.
|
|
22
|
+
* @default ['/v1/']
|
|
23
|
+
*/
|
|
24
|
+
reserved?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Serve a site's build from Node, with the rules every static host of the
|
|
28
|
+
* family follows (static-web-server's `sws.toml`, Cloudflare Pages): a real
|
|
29
|
+
* file per address and no fallback page, `/titration` answered without a
|
|
30
|
+
* redirect, pages checked on every visit and hashed bundles kept for a year,
|
|
31
|
+
* the analytics snippet in every page, `404.html` for an address the site does
|
|
32
|
+
* not have, and `/health` for the deploy script.
|
|
33
|
+
*
|
|
34
|
+
* Register it next to the API; a build missing (a server started before the
|
|
35
|
+
* frontend was built) leaves the API alone:
|
|
36
|
+
*
|
|
37
|
+
* await app.register(staticSite, { root: 'dist', trackingScript });
|
|
38
|
+
* @param fastify - The server.
|
|
39
|
+
* @param options - The build, the snippet and the retired addresses.
|
|
40
|
+
*/
|
|
41
|
+
export declare function staticSite(fastify: FastifyInstance, options: StaticSiteOptions): Promise<void>;
|
|
42
|
+
//# sourceMappingURL=staticSite.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"staticSite.d.ts","sourceRoot":"","sources":["../../src/server/staticSite.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,eAAe,EAIhB,MAAM,SAAS,CAAC;AAMjB,qCAAqC;AACrC,MAAM,WAAW,iBAAiB;IAChC,kDAAkD;IAClD,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9B;AAiBD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,IAAI,CAAC,CA4Df"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { injectTrackingScript } from "../seo/core/trackingScript.js";
|
|
4
|
+
import { isHashedBundle, listSiteFiles, resolveAddress } from "./siteFiles.js";
|
|
5
|
+
/** A page is checked on every visit: it names the bundles of its own build. */
|
|
6
|
+
const PAGE_CACHE = 'no-cache';
|
|
7
|
+
/** A bundle is named by its content, so it never changes under its name. */
|
|
8
|
+
const BUNDLE_CACHE = 'public, max-age=31536000, immutable';
|
|
9
|
+
/** Kept out of the API's documentation (`@fastify/swagger` reads `hide`). */
|
|
10
|
+
const HIDDEN = { hide: true };
|
|
11
|
+
/**
|
|
12
|
+
* Serve a site's build from Node, with the rules every static host of the
|
|
13
|
+
* family follows (static-web-server's `sws.toml`, Cloudflare Pages): a real
|
|
14
|
+
* file per address and no fallback page, `/titration` answered without a
|
|
15
|
+
* redirect, pages checked on every visit and hashed bundles kept for a year,
|
|
16
|
+
* the analytics snippet in every page, `404.html` for an address the site does
|
|
17
|
+
* not have, and `/health` for the deploy script.
|
|
18
|
+
*
|
|
19
|
+
* Register it next to the API; a build missing (a server started before the
|
|
20
|
+
* frontend was built) leaves the API alone:
|
|
21
|
+
*
|
|
22
|
+
* await app.register(staticSite, { root: 'dist', trackingScript });
|
|
23
|
+
* @param fastify - The server.
|
|
24
|
+
* @param options - The build, the snippet and the retired addresses.
|
|
25
|
+
*/
|
|
26
|
+
export async function staticSite(fastify, options) {
|
|
27
|
+
const { trackingScript, redirects = {}, reserved = ['/v1/'] } = options;
|
|
28
|
+
const root = resolve(options.root);
|
|
29
|
+
fastify.get('/health', { logLevel: 'silent', schema: HIDDEN }, (_request, reply) => reply.send({ status: 'ok' }));
|
|
30
|
+
const files = await listSiteFiles(root);
|
|
31
|
+
if (files === undefined) {
|
|
32
|
+
fastify.log.warn({ root }, 'no built site: serving the API alone');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const pages = await readPages(root, files, trackingScript);
|
|
36
|
+
// An optional peer, installed only by a site that has a server: importing
|
|
37
|
+
// react-cheminfo/server must not need it.
|
|
38
|
+
const { default: fastifyStatic } = await import('@fastify/static');
|
|
39
|
+
await fastify.register(fastifyStatic, {
|
|
40
|
+
root,
|
|
41
|
+
serve: false,
|
|
42
|
+
cacheControl: false,
|
|
43
|
+
setHeaders: (reply, path) => {
|
|
44
|
+
const file = relative(root, path).split(sep).join('/');
|
|
45
|
+
if (isHashedBundle(file)) {
|
|
46
|
+
reply.header('cache-control', BUNDLE_CACHE);
|
|
47
|
+
// A page framed by a sandboxed view may run under an opaque origin,
|
|
48
|
+
// and a module bundle is then fetched cross-origin.
|
|
49
|
+
reply.header('access-control-allow-origin', '*');
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
reply.header('cache-control', PAGE_CACHE);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const answer = (request, reply) => {
|
|
57
|
+
const url = new URL(request.url, 'http://site');
|
|
58
|
+
const { pathname, search } = url;
|
|
59
|
+
if (reserved.some((prefix) => pathname.startsWith(prefix))) {
|
|
60
|
+
return reply.callNotFound();
|
|
61
|
+
}
|
|
62
|
+
const destination = redirects[pathname] ?? redirects[pathname.replace(/\/$/, '')];
|
|
63
|
+
if (destination !== undefined) {
|
|
64
|
+
return reply.redirect(`${destination}${search}`, 301);
|
|
65
|
+
}
|
|
66
|
+
const file = resolveAddress(files, pathname);
|
|
67
|
+
if (file === undefined) {
|
|
68
|
+
return sendPage(request, reply, pages.get('404.html'), 404);
|
|
69
|
+
}
|
|
70
|
+
const page = pages.get(file);
|
|
71
|
+
if (page)
|
|
72
|
+
return sendPage(request, reply, page, 200);
|
|
73
|
+
return reply.sendFile(file);
|
|
74
|
+
};
|
|
75
|
+
fastify.get('/', { schema: HIDDEN }, answer);
|
|
76
|
+
fastify.get('/*', { schema: HIDDEN }, answer);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Read every page of the build once, with the snippet in its head.
|
|
80
|
+
* @param root - The build.
|
|
81
|
+
* @param files - Its files.
|
|
82
|
+
* @param trackingScript - The snippet, if any.
|
|
83
|
+
* @returns The pages, by file.
|
|
84
|
+
*/
|
|
85
|
+
async function readPages(root, files, trackingScript) {
|
|
86
|
+
const htmlFiles = [...files].filter((file) => file.endsWith('.html'));
|
|
87
|
+
const pages = await Promise.all(htmlFiles.map(async (file) => {
|
|
88
|
+
const html = injectTrackingScript(await readFile(join(root, file), 'utf8'), trackingScript);
|
|
89
|
+
return [file, { html, etag: await etagOf(html) }];
|
|
90
|
+
}));
|
|
91
|
+
return new Map(pages);
|
|
92
|
+
}
|
|
93
|
+
function sendPage(request, reply, page, status) {
|
|
94
|
+
if (page === undefined) {
|
|
95
|
+
return reply
|
|
96
|
+
.code(status)
|
|
97
|
+
.type('text/plain; charset=utf-8')
|
|
98
|
+
.send('Not found');
|
|
99
|
+
}
|
|
100
|
+
reply.header('cache-control', PAGE_CACHE).header('etag', page.etag);
|
|
101
|
+
if (status === 200 && request.headers['if-none-match'] === page.etag) {
|
|
102
|
+
return reply.code(304).send();
|
|
103
|
+
}
|
|
104
|
+
return reply.code(status).type('text/html; charset=utf-8').send(page.html);
|
|
105
|
+
}
|
|
106
|
+
async function etagOf(text) {
|
|
107
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
108
|
+
const bytes = new Uint8Array(digest).subarray(0, 12);
|
|
109
|
+
let hex = '';
|
|
110
|
+
for (const byte of bytes)
|
|
111
|
+
hex += byte.toString(16).padStart(2, '0');
|
|
112
|
+
return `"${hex}"`;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=staticSite.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"staticSite.js","sourceRoot":"","sources":["../../src/server/staticSite.ts"],"names":[],"mappings":"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"}
|
package/lib/server.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAChE,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EACL,cAAc,EACd,aAAa,EACb,cAAc,GACf,MAAM,uBAAuB,CAAC"}
|
package/lib/server.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EACL,cAAc,EACd,aAAa,EACb,cAAc,GACf,MAAM,uBAAuB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-cheminfo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "React components, hooks and framework-free helpers shared by the cheminfo sites: site chrome, citations, sharing, routing, pedagogy, charts, and the structure, orbital and 3D molecule viewers",
|
|
6
6
|
"author": "Luc Patiny",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"./molstar/*": "./lib/molstar/*/index.js",
|
|
26
26
|
"./orbital": "./lib/orbital.js",
|
|
27
27
|
"./pages": "./lib/pages.js",
|
|
28
|
+
"./server": "./lib/server.js",
|
|
28
29
|
"./slides": "./lib/slides.js",
|
|
29
30
|
"./spectra": "./lib/spectra.js",
|
|
30
31
|
"./structure": "./lib/structure.js",
|
|
@@ -85,6 +86,7 @@
|
|
|
85
86
|
},
|
|
86
87
|
"devDependencies": {
|
|
87
88
|
"@blueprintjs/core": "^6.18.0",
|
|
89
|
+
"@fastify/static": "^10.1.6",
|
|
88
90
|
"@playwright/test": "^1.62.1",
|
|
89
91
|
"@storybook/addon-docs": "^10.5.8",
|
|
90
92
|
"@storybook/react-vite": "^10.5.8",
|
|
@@ -99,6 +101,7 @@
|
|
|
99
101
|
"eslint": "^9.39.5",
|
|
100
102
|
"eslint-config-cheminfo-react": "^20.1.0",
|
|
101
103
|
"eslint-config-cheminfo-typescript": "^22.1.0",
|
|
104
|
+
"fastify": "^5.12.7",
|
|
102
105
|
"jsdom": "^30.1.1",
|
|
103
106
|
"ml-dataset-iris": "^1.2.1",
|
|
104
107
|
"ml-pca": "^4.1.1",
|
|
@@ -119,6 +122,8 @@
|
|
|
119
122
|
},
|
|
120
123
|
"peerDependencies": {
|
|
121
124
|
"@blueprintjs/core": "^6.18.0",
|
|
125
|
+
"@fastify/static": "^10.0.0",
|
|
126
|
+
"fastify": "^5.0.0",
|
|
122
127
|
"ml-pca": "^4.1.1",
|
|
123
128
|
"ml-signal-processing": "^2.2.2",
|
|
124
129
|
"molstar": "^5.11.0",
|
|
@@ -139,6 +144,12 @@
|
|
|
139
144
|
},
|
|
140
145
|
"homepage": "https://github.com/cheminfo/react-cheminfo#readme",
|
|
141
146
|
"peerDependenciesMeta": {
|
|
147
|
+
"@fastify/static": {
|
|
148
|
+
"optional": true
|
|
149
|
+
},
|
|
150
|
+
"fastify": {
|
|
151
|
+
"optional": true
|
|
152
|
+
},
|
|
142
153
|
"ml-pca": {
|
|
143
154
|
"optional": true
|
|
144
155
|
},
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every file of a built site, by its path from the root, written with `/`.
|
|
7
|
+
*
|
|
8
|
+
* The build does not change while the server runs, so the list is read once,
|
|
9
|
+
* and only a file on it is ever served: a path that climbs out of the root
|
|
10
|
+
* names nothing on the list.
|
|
11
|
+
* @param root - The directory the build wrote, e.g. `dist`.
|
|
12
|
+
* @returns The paths, e.g. `titration.html` and `assets/index-a1b2.js`, or
|
|
13
|
+
* `undefined` when the directory does not exist.
|
|
14
|
+
*/
|
|
15
|
+
export async function listSiteFiles(
|
|
16
|
+
root: string,
|
|
17
|
+
): Promise<Set<string> | undefined> {
|
|
18
|
+
if (!existsSync(root)) return undefined;
|
|
19
|
+
const entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
20
|
+
const files = new Set<string>();
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (!entry.isFile()) continue;
|
|
23
|
+
files.add(
|
|
24
|
+
relative(root, join(entry.parentPath, entry.name)).split(sep).join('/'),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return files;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The file that answers an address, as the static hosts of the family answer
|
|
32
|
+
* it: `/titration` is `titration.html` (never a redirect to `/titration/`),
|
|
33
|
+
* `/` and `/fr/` are their `index.html`, and any other file is itself.
|
|
34
|
+
* @param files - The files of the site, from {@link listSiteFiles}.
|
|
35
|
+
* @param pathname - The address, without its query, e.g. `/titration`.
|
|
36
|
+
* @returns The file, or `undefined` when the site has no such page.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveAddress(
|
|
39
|
+
files: ReadonlySet<string>,
|
|
40
|
+
pathname: string,
|
|
41
|
+
): string | undefined {
|
|
42
|
+
let path: string;
|
|
43
|
+
try {
|
|
44
|
+
path = decodeURIComponent(pathname);
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const local = path.replace(/^\/+/, '');
|
|
49
|
+
const candidates =
|
|
50
|
+
local === '' || local.endsWith('/')
|
|
51
|
+
? [`${local}index.html`]
|
|
52
|
+
: [local, `${local}.html`, `${local}/index.html`];
|
|
53
|
+
return candidates.find((candidate) => files.has(candidate));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a file is a bundle named by its content, which a browser may keep
|
|
58
|
+
* for a year: everything under an `assets` directory.
|
|
59
|
+
* @param file - The file, from the root.
|
|
60
|
+
* @returns True for a hashed bundle.
|
|
61
|
+
*/
|
|
62
|
+
export function isHashedBundle(file: string): boolean {
|
|
63
|
+
return file.startsWith('assets/') || file.includes('/assets/');
|
|
64
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
FastifyInstance,
|
|
6
|
+
FastifyReply,
|
|
7
|
+
FastifyRequest,
|
|
8
|
+
FastifySchema,
|
|
9
|
+
} from 'fastify';
|
|
10
|
+
|
|
11
|
+
import { injectTrackingScript } from '../seo/core/trackingScript.ts';
|
|
12
|
+
|
|
13
|
+
import { isHashedBundle, listSiteFiles, resolveAddress } from './siteFiles.ts';
|
|
14
|
+
|
|
15
|
+
/** Options of {@link staticSite}. */
|
|
16
|
+
export interface StaticSiteOptions {
|
|
17
|
+
/** The directory the build wrote, e.g. `dist`. */
|
|
18
|
+
root: string;
|
|
19
|
+
/**
|
|
20
|
+
* The analytics snippet, put at the end of the head of every page, as
|
|
21
|
+
* `TRACKING_SCRIPT` holds it.
|
|
22
|
+
* @default undefined - no snippet
|
|
23
|
+
*/
|
|
24
|
+
trackingScript?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Addresses the site does not have any more, and the one that does their
|
|
27
|
+
* work. The query is carried over, so a link handed out before the move
|
|
28
|
+
* keeps its configuration.
|
|
29
|
+
* @default {}
|
|
30
|
+
*/
|
|
31
|
+
redirects?: Readonly<Record<string, string>>;
|
|
32
|
+
/**
|
|
33
|
+
* Address prefixes another plugin answers, such as the API, which never
|
|
34
|
+
* answer a page: an unknown address there is the API's own 404.
|
|
35
|
+
* @default ['/v1/']
|
|
36
|
+
*/
|
|
37
|
+
reserved?: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A page: the file with the snippet in its head, and the tag a browser checks. */
|
|
41
|
+
interface Page {
|
|
42
|
+
html: string;
|
|
43
|
+
etag: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A page is checked on every visit: it names the bundles of its own build. */
|
|
47
|
+
const PAGE_CACHE = 'no-cache';
|
|
48
|
+
|
|
49
|
+
/** A bundle is named by its content, so it never changes under its name. */
|
|
50
|
+
const BUNDLE_CACHE = 'public, max-age=31536000, immutable';
|
|
51
|
+
|
|
52
|
+
/** Kept out of the API's documentation (`@fastify/swagger` reads `hide`). */
|
|
53
|
+
const HIDDEN: FastifySchema & { hide: boolean } = { hide: true };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Serve a site's build from Node, with the rules every static host of the
|
|
57
|
+
* family follows (static-web-server's `sws.toml`, Cloudflare Pages): a real
|
|
58
|
+
* file per address and no fallback page, `/titration` answered without a
|
|
59
|
+
* redirect, pages checked on every visit and hashed bundles kept for a year,
|
|
60
|
+
* the analytics snippet in every page, `404.html` for an address the site does
|
|
61
|
+
* not have, and `/health` for the deploy script.
|
|
62
|
+
*
|
|
63
|
+
* Register it next to the API; a build missing (a server started before the
|
|
64
|
+
* frontend was built) leaves the API alone:
|
|
65
|
+
*
|
|
66
|
+
* await app.register(staticSite, { root: 'dist', trackingScript });
|
|
67
|
+
* @param fastify - The server.
|
|
68
|
+
* @param options - The build, the snippet and the retired addresses.
|
|
69
|
+
*/
|
|
70
|
+
export async function staticSite(
|
|
71
|
+
fastify: FastifyInstance,
|
|
72
|
+
options: StaticSiteOptions,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const { trackingScript, redirects = {}, reserved = ['/v1/'] } = options;
|
|
75
|
+
const root = resolve(options.root);
|
|
76
|
+
|
|
77
|
+
fastify.get(
|
|
78
|
+
'/health',
|
|
79
|
+
{ logLevel: 'silent', schema: HIDDEN },
|
|
80
|
+
(_request, reply) => reply.send({ status: 'ok' }),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const files = await listSiteFiles(root);
|
|
84
|
+
if (files === undefined) {
|
|
85
|
+
fastify.log.warn({ root }, 'no built site: serving the API alone');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const pages = await readPages(root, files, trackingScript);
|
|
89
|
+
|
|
90
|
+
// An optional peer, installed only by a site that has a server: importing
|
|
91
|
+
// react-cheminfo/server must not need it.
|
|
92
|
+
const { default: fastifyStatic } = await import('@fastify/static');
|
|
93
|
+
await fastify.register(fastifyStatic, {
|
|
94
|
+
root,
|
|
95
|
+
serve: false,
|
|
96
|
+
cacheControl: false,
|
|
97
|
+
setHeaders: (reply, path) => {
|
|
98
|
+
const file = relative(root, path).split(sep).join('/');
|
|
99
|
+
if (isHashedBundle(file)) {
|
|
100
|
+
reply.header('cache-control', BUNDLE_CACHE);
|
|
101
|
+
// A page framed by a sandboxed view may run under an opaque origin,
|
|
102
|
+
// and a module bundle is then fetched cross-origin.
|
|
103
|
+
reply.header('access-control-allow-origin', '*');
|
|
104
|
+
} else {
|
|
105
|
+
reply.header('cache-control', PAGE_CACHE);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const answer = (request: FastifyRequest, reply: FastifyReply) => {
|
|
111
|
+
const url = new URL(request.url, 'http://site');
|
|
112
|
+
const { pathname, search } = url;
|
|
113
|
+
if (reserved.some((prefix) => pathname.startsWith(prefix))) {
|
|
114
|
+
return reply.callNotFound();
|
|
115
|
+
}
|
|
116
|
+
const destination =
|
|
117
|
+
redirects[pathname] ?? redirects[pathname.replace(/\/$/, '')];
|
|
118
|
+
if (destination !== undefined) {
|
|
119
|
+
return reply.redirect(`${destination}${search}`, 301);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const file = resolveAddress(files, pathname);
|
|
123
|
+
if (file === undefined) {
|
|
124
|
+
return sendPage(request, reply, pages.get('404.html'), 404);
|
|
125
|
+
}
|
|
126
|
+
const page = pages.get(file);
|
|
127
|
+
if (page) return sendPage(request, reply, page, 200);
|
|
128
|
+
return reply.sendFile(file);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
fastify.get('/', { schema: HIDDEN }, answer);
|
|
132
|
+
fastify.get('/*', { schema: HIDDEN }, answer);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read every page of the build once, with the snippet in its head.
|
|
137
|
+
* @param root - The build.
|
|
138
|
+
* @param files - Its files.
|
|
139
|
+
* @param trackingScript - The snippet, if any.
|
|
140
|
+
* @returns The pages, by file.
|
|
141
|
+
*/
|
|
142
|
+
async function readPages(
|
|
143
|
+
root: string,
|
|
144
|
+
files: ReadonlySet<string>,
|
|
145
|
+
trackingScript: string | undefined,
|
|
146
|
+
): Promise<Map<string, Page>> {
|
|
147
|
+
const htmlFiles = [...files].filter((file) => file.endsWith('.html'));
|
|
148
|
+
const pages = await Promise.all(
|
|
149
|
+
htmlFiles.map(async (file) => {
|
|
150
|
+
const html = injectTrackingScript(
|
|
151
|
+
await readFile(join(root, file), 'utf8'),
|
|
152
|
+
trackingScript,
|
|
153
|
+
);
|
|
154
|
+
return [file, { html, etag: await etagOf(html) }] as const;
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
return new Map(pages);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sendPage(
|
|
161
|
+
request: FastifyRequest,
|
|
162
|
+
reply: FastifyReply,
|
|
163
|
+
page: Page | undefined,
|
|
164
|
+
status: number,
|
|
165
|
+
) {
|
|
166
|
+
if (page === undefined) {
|
|
167
|
+
return reply
|
|
168
|
+
.code(status)
|
|
169
|
+
.type('text/plain; charset=utf-8')
|
|
170
|
+
.send('Not found');
|
|
171
|
+
}
|
|
172
|
+
reply.header('cache-control', PAGE_CACHE).header('etag', page.etag);
|
|
173
|
+
if (status === 200 && request.headers['if-none-match'] === page.etag) {
|
|
174
|
+
return reply.code(304).send();
|
|
175
|
+
}
|
|
176
|
+
return reply.code(status).type('text/html; charset=utf-8').send(page.html);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function etagOf(text: string): Promise<string> {
|
|
180
|
+
const digest = await crypto.subtle.digest(
|
|
181
|
+
'SHA-256',
|
|
182
|
+
new TextEncoder().encode(text),
|
|
183
|
+
);
|
|
184
|
+
const bytes = new Uint8Array(digest).subarray(0, 12);
|
|
185
|
+
let hex = '';
|
|
186
|
+
for (const byte of bytes) hex += byte.toString(16).padStart(2, '0');
|
|
187
|
+
return `"${hex}"`;
|
|
188
|
+
}
|