react-cheminfo 0.60.0 → 0.62.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 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,36 @@ 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, every
1233
+ answer carries `Access-Control-Allow-Origin: *` (a page framed by a chat's
1234
+ sandboxed view fetches all its files cross-origin), the `TRACKING_SCRIPT`
1235
+ snippet is in every page, and `/health` answers the deploy script. Only a file
1236
+ the build wrote is ever served.
1237
+
1238
+ ```ts
1239
+ import { fastify } from 'fastify';
1240
+ import { staticSite } from 'react-cheminfo/server';
1241
+
1242
+ const app = fastify();
1243
+ // …the API under /v1/…
1244
+ await app.register(staticSite, {
1245
+ root: 'dist',
1246
+ trackingScript: process.env.TRACKING_SCRIPT,
1247
+ redirects: { '/precipitation': '/equilibrium' },
1248
+ });
1249
+ ```
1250
+
1251
+ Addresses under `reserved` (default `['/v1/']`) are left to the API, which
1252
+ answers its own 404. Without a build, `staticSite` logs it and serves the API
1253
+ alone. `fastify` (5) and `@fastify/static` (10) are optional peers, installed
1254
+ by a site that has a server.
1255
+
1225
1256
  ## Saying which build is running
1226
1257
 
1227
1258
  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,43 @@
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
+ * every response open to any origin, the analytics snippet in every page,
32
+ * `404.html` for an address the site does not have, and `/health` for the
33
+ * deploy script.
34
+ *
35
+ * Register it next to the API; a build missing (a server started before the
36
+ * frontend was built) leaves the API alone:
37
+ *
38
+ * await app.register(staticSite, { root: 'dist', trackingScript });
39
+ * @param fastify - The server.
40
+ * @param options - The build, the snippet and the retired addresses.
41
+ */
42
+ export declare function staticSite(fastify: FastifyInstance, options: StaticSiteOptions): Promise<void>;
43
+ //# 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;AAwBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,IAAI,CAAC,CAyDf"}
@@ -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
+ /**
10
+ * Every file of a build is public, and a page framed by a sandboxed view runs
11
+ * under an opaque origin, where everything it fetches from its own site is
12
+ * cross-origin: the static hosts open every response, and so does this server.
13
+ */
14
+ const ALLOW_ORIGIN = 'access-control-allow-origin';
15
+ /** Kept out of the API's documentation (`@fastify/swagger` reads `hide`). */
16
+ const HIDDEN = { hide: true };
17
+ /**
18
+ * Serve a site's build from Node, with the rules every static host of the
19
+ * family follows (static-web-server's `sws.toml`, Cloudflare Pages): a real
20
+ * file per address and no fallback page, `/titration` answered without a
21
+ * redirect, pages checked on every visit and hashed bundles kept for a year,
22
+ * every response open to any origin, the analytics snippet in every page,
23
+ * `404.html` for an address the site does not have, and `/health` for the
24
+ * deploy script.
25
+ *
26
+ * Register it next to the API; a build missing (a server started before the
27
+ * frontend was built) leaves the API alone:
28
+ *
29
+ * await app.register(staticSite, { root: 'dist', trackingScript });
30
+ * @param fastify - The server.
31
+ * @param options - The build, the snippet and the retired addresses.
32
+ */
33
+ export async function staticSite(fastify, options) {
34
+ const { trackingScript, redirects = {}, reserved = ['/v1/'] } = options;
35
+ const root = resolve(options.root);
36
+ fastify.get('/health', { logLevel: 'silent', schema: HIDDEN }, (_request, reply) => reply.header(ALLOW_ORIGIN, '*').send({ status: 'ok' }));
37
+ const files = await listSiteFiles(root);
38
+ if (files === undefined) {
39
+ fastify.log.warn({ root }, 'no built site: serving the API alone');
40
+ return;
41
+ }
42
+ const pages = await readPages(root, files, trackingScript);
43
+ // An optional peer, installed only by a site that has a server: importing
44
+ // react-cheminfo/server must not need it.
45
+ const { default: fastifyStatic } = await import('@fastify/static');
46
+ await fastify.register(fastifyStatic, {
47
+ root,
48
+ serve: false,
49
+ cacheControl: false,
50
+ setHeaders: (reply, path) => {
51
+ const file = relative(root, path).split(sep).join('/');
52
+ reply.header('cache-control', isHashedBundle(file) ? BUNDLE_CACHE : PAGE_CACHE);
53
+ },
54
+ });
55
+ const answer = (request, reply) => {
56
+ const url = new URL(request.url, 'http://site');
57
+ const { pathname, search } = url;
58
+ if (reserved.some((prefix) => pathname.startsWith(prefix))) {
59
+ return reply.callNotFound();
60
+ }
61
+ reply.header(ALLOW_ORIGIN, '*');
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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"}
@@ -0,0 +1,4 @@
1
+ export type { StaticSiteOptions } from './server/staticSite.ts';
2
+ export { staticSite } from './server/staticSite.ts';
3
+ export { isHashedBundle, listSiteFiles, resolveAddress, } from './server/siteFiles.ts';
4
+ //# sourceMappingURL=server.d.ts.map
@@ -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,3 @@
1
+ export { staticSite } from "./server/staticSite.js";
2
+ export { isHashedBundle, listSiteFiles, resolveAddress, } from "./server/siteFiles.js";
3
+ //# sourceMappingURL=server.js.map
@@ -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.60.0",
3
+ "version": "0.62.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,193 @@
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
+ /**
53
+ * Every file of a build is public, and a page framed by a sandboxed view runs
54
+ * under an opaque origin, where everything it fetches from its own site is
55
+ * cross-origin: the static hosts open every response, and so does this server.
56
+ */
57
+ const ALLOW_ORIGIN = 'access-control-allow-origin';
58
+
59
+ /** Kept out of the API's documentation (`@fastify/swagger` reads `hide`). */
60
+ const HIDDEN: FastifySchema & { hide: boolean } = { hide: true };
61
+
62
+ /**
63
+ * Serve a site's build from Node, with the rules every static host of the
64
+ * family follows (static-web-server's `sws.toml`, Cloudflare Pages): a real
65
+ * file per address and no fallback page, `/titration` answered without a
66
+ * redirect, pages checked on every visit and hashed bundles kept for a year,
67
+ * every response open to any origin, the analytics snippet in every page,
68
+ * `404.html` for an address the site does not have, and `/health` for the
69
+ * deploy script.
70
+ *
71
+ * Register it next to the API; a build missing (a server started before the
72
+ * frontend was built) leaves the API alone:
73
+ *
74
+ * await app.register(staticSite, { root: 'dist', trackingScript });
75
+ * @param fastify - The server.
76
+ * @param options - The build, the snippet and the retired addresses.
77
+ */
78
+ export async function staticSite(
79
+ fastify: FastifyInstance,
80
+ options: StaticSiteOptions,
81
+ ): Promise<void> {
82
+ const { trackingScript, redirects = {}, reserved = ['/v1/'] } = options;
83
+ const root = resolve(options.root);
84
+
85
+ fastify.get(
86
+ '/health',
87
+ { logLevel: 'silent', schema: HIDDEN },
88
+ (_request, reply) => reply.header(ALLOW_ORIGIN, '*').send({ status: 'ok' }),
89
+ );
90
+
91
+ const files = await listSiteFiles(root);
92
+ if (files === undefined) {
93
+ fastify.log.warn({ root }, 'no built site: serving the API alone');
94
+ return;
95
+ }
96
+ const pages = await readPages(root, files, trackingScript);
97
+
98
+ // An optional peer, installed only by a site that has a server: importing
99
+ // react-cheminfo/server must not need it.
100
+ const { default: fastifyStatic } = await import('@fastify/static');
101
+ await fastify.register(fastifyStatic, {
102
+ root,
103
+ serve: false,
104
+ cacheControl: false,
105
+ setHeaders: (reply, path) => {
106
+ const file = relative(root, path).split(sep).join('/');
107
+ reply.header(
108
+ 'cache-control',
109
+ isHashedBundle(file) ? BUNDLE_CACHE : PAGE_CACHE,
110
+ );
111
+ },
112
+ });
113
+
114
+ const answer = (request: FastifyRequest, reply: FastifyReply) => {
115
+ const url = new URL(request.url, 'http://site');
116
+ const { pathname, search } = url;
117
+ if (reserved.some((prefix) => pathname.startsWith(prefix))) {
118
+ return reply.callNotFound();
119
+ }
120
+ reply.header(ALLOW_ORIGIN, '*');
121
+ const destination =
122
+ redirects[pathname] ?? redirects[pathname.replace(/\/$/, '')];
123
+ if (destination !== undefined) {
124
+ return reply.redirect(`${destination}${search}`, 301);
125
+ }
126
+
127
+ const file = resolveAddress(files, pathname);
128
+ if (file === undefined) {
129
+ return sendPage(request, reply, pages.get('404.html'), 404);
130
+ }
131
+ const page = pages.get(file);
132
+ if (page) return sendPage(request, reply, page, 200);
133
+ return reply.sendFile(file);
134
+ };
135
+
136
+ fastify.get('/', { schema: HIDDEN }, answer);
137
+ fastify.get('/*', { schema: HIDDEN }, answer);
138
+ }
139
+
140
+ /**
141
+ * Read every page of the build once, with the snippet in its head.
142
+ * @param root - The build.
143
+ * @param files - Its files.
144
+ * @param trackingScript - The snippet, if any.
145
+ * @returns The pages, by file.
146
+ */
147
+ async function readPages(
148
+ root: string,
149
+ files: ReadonlySet<string>,
150
+ trackingScript: string | undefined,
151
+ ): Promise<Map<string, Page>> {
152
+ const htmlFiles = [...files].filter((file) => file.endsWith('.html'));
153
+ const pages = await Promise.all(
154
+ htmlFiles.map(async (file) => {
155
+ const html = injectTrackingScript(
156
+ await readFile(join(root, file), 'utf8'),
157
+ trackingScript,
158
+ );
159
+ return [file, { html, etag: await etagOf(html) }] as const;
160
+ }),
161
+ );
162
+ return new Map(pages);
163
+ }
164
+
165
+ function sendPage(
166
+ request: FastifyRequest,
167
+ reply: FastifyReply,
168
+ page: Page | undefined,
169
+ status: number,
170
+ ) {
171
+ if (page === undefined) {
172
+ return reply
173
+ .code(status)
174
+ .type('text/plain; charset=utf-8')
175
+ .send('Not found');
176
+ }
177
+ reply.header('cache-control', PAGE_CACHE).header('etag', page.etag);
178
+ if (status === 200 && request.headers['if-none-match'] === page.etag) {
179
+ return reply.code(304).send();
180
+ }
181
+ return reply.code(status).type('text/html; charset=utf-8').send(page.html);
182
+ }
183
+
184
+ async function etagOf(text: string): Promise<string> {
185
+ const digest = await crypto.subtle.digest(
186
+ 'SHA-256',
187
+ new TextEncoder().encode(text),
188
+ );
189
+ const bytes = new Uint8Array(digest).subarray(0, 12);
190
+ let hex = '';
191
+ for (const byte of bytes) hex += byte.toString(16).padStart(2, '0');
192
+ return `"${hex}"`;
193
+ }
package/src/server.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type { StaticSiteOptions } from './server/staticSite.ts';
2
+ export { staticSite } from './server/staticSite.ts';
3
+ export {
4
+ isHashedBundle,
5
+ listSiteFiles,
6
+ resolveAddress,
7
+ } from './server/siteFiles.ts';