@tinvar/renderer 0.0.0-stage → 0.4.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.
@@ -0,0 +1,306 @@
1
+ // Where the browser loads the build's client assets from, and what each
2
+ // script's bytes must be (ADR 0097).
3
+ //
4
+ // tinvar-spec.md, "Deployment": "Static assets from Vite go to a CDN or are
5
+ // served by PHP with long cache headers." TINVAR_ASSET_ORIGIN, at build
6
+ // time, names the CDN: an https origin and nothing after it. The build's
7
+ // base becomes that origin, so every URL the build writes -- the client
8
+ // entry, each client component's chunk in the payload, every preload and
9
+ // stylesheet, a chunk's import of another -- is on it. Unset, the base is
10
+ // `/` and the assets are this origin's, served by Caddy or, without it, by
11
+ // PHP (Tinvar\Http\BuiltAssets).
12
+ //
13
+ // A CDN is another party's server between the build and the browser, so a
14
+ // build for one also records what every script it wrote is: a SHA-384
15
+ // digest of each file as written, put in the document's import map
16
+ // (`integrity`), which the browser holds every module it loads to --
17
+ // statically, dynamically, or preloaded by a <link> after the map. A CDN
18
+ // that serves different bytes for a script serves a page that does not
19
+ // run, rather than one that runs what it chose. `integrity: true` adds the
20
+ // map to a same-origin build as well; `false` leaves it out of either.
21
+ //
22
+ // Stylesheets are held to theirs too (ADR 0108). An import map covers
23
+ // modules alone, so the digest goes on the <link> itself: the RSC plugin's
24
+ // loadCss() writes each stylesheet's link from its assets manifest, and the
25
+ // module it makes is wrapped here to add the link's `integrity` from the
26
+ // same manifest, which the client build fills in -- on the server render and
27
+ // in every payload, so a stylesheet React inserts on a client navigation is
28
+ // held to it as well. A stylesheet a client component imports itself is
29
+ // preinited by the plugin's own code, which no digest reaches: a build with
30
+ // digests that has one is refused, not shipped unchecked.
31
+ //
32
+ // The origin is recorded in the rsc output, `rsc/assets.json`, where PHP
33
+ // reads it (Tinvar\Http\BuiltAssets) to name it in the page's
34
+ // Content-Security-Policy -- and, once the client build is written, the
35
+ // digests and what every page loads first: the client entry, its imports and
36
+ // the stylesheets every page links. PHP sends those as 103 Early Hints
37
+ // before a page renders, and the page's own modules in its Link header
38
+ // after, each with its digest, so a preload fetched from a header -- before
39
+ // the document's import map is read -- is held to it too. Chromium honours
40
+ // `integrity` on a header's modulepreload and on an Early Hint; a header
41
+ // preload without one put the module in the module map unchecked, and the
42
+ // import map's digest was never asked. The import map reaches the ssr entry
43
+ // through a module the build makes, `virtual:tinvar/assets`: the ssr
44
+ // environment is built after the client one, so the digests are those of
45
+ // the files the client build wrote.
46
+
47
+ import { readFileSync, writeFileSync } from 'node:fs';
48
+ import { createHash } from 'node:crypto';
49
+ import { join, resolve } from 'node:path';
50
+ import { getPluginApi } from '@vitejs/plugin-rsc';
51
+
52
+ /** The record's name, in the rsc environment's output. */
53
+ export const ASSETS_FILE = 'assets.json';
54
+
55
+ /** The module the ssr entry reads the import map from. */
56
+ export const ASSETS_MODULE = 'virtual:tinvar/assets';
57
+
58
+ const RESOLVED = `\0${ASSETS_MODULE}`;
59
+
60
+ /** The modules the RSC plugin makes for each loadCss(). */
61
+ const CSS_MODULE = '\0virtual:vite-rsc/css?';
62
+
63
+ /** The component loadCss() renders, as the plugin's module exports it. */
64
+ const RESOURCES_EXPORT = 'export const Resources = (';
65
+
66
+ /** Where the client build's digests go in the plugin's assets manifest. */
67
+ export const MANIFEST_INTEGRITY = 'tinvarIntegrity';
68
+
69
+ /** A build asked for an asset origin it may not have. */
70
+ export class AssetOriginRefused extends Error {}
71
+
72
+ /**
73
+ * The asset origin $env names, or null for none: https, a host, perhaps a
74
+ * port, and nothing else -- no path, no query, no credentials, written as
75
+ * a browser writes an origin.
76
+ *
77
+ * @param {Record<string, string | undefined>} env
78
+ * @returns {string | null}
79
+ */
80
+ export function assetOriginOf(env) {
81
+ const raw = (env.TINVAR_ASSET_ORIGIN ?? '').trim();
82
+ if (raw === '') return null;
83
+
84
+ let url;
85
+ try {
86
+ url = new URL(raw);
87
+ } catch {
88
+ url = null;
89
+ }
90
+ if (url === null || url.protocol !== 'https:' || url.origin !== raw) {
91
+ throw new AssetOriginRefused(
92
+ `TINVAR_ASSET_ORIGIN='${raw}' is not an origin assets are served from: https, a host and perhaps a port, ` +
93
+ 'as https://cdn.example.com, with no path and no trailing slash.',
94
+ );
95
+ }
96
+
97
+ return raw;
98
+ }
99
+
100
+ /**
101
+ * Whether TINVAR_ASSET_INTEGRITY asks for the digests: `1` for them on any
102
+ * build, `0` for none, or null to leave it to the asset origin.
103
+ *
104
+ * @param {Record<string, string | undefined>} env
105
+ * @returns {boolean | null}
106
+ */
107
+ export function integrityAskedOf(env) {
108
+ const raw = (env.TINVAR_ASSET_INTEGRITY ?? '').trim();
109
+ if (raw === '') return null;
110
+ if (raw === '1') return true;
111
+ if (raw === '0') return false;
112
+ throw new AssetOriginRefused(`TINVAR_ASSET_INTEGRITY='${raw}' is neither 1 nor 0.`);
113
+ }
114
+
115
+ /**
116
+ * The digest of $bytes, as an integrity value.
117
+ *
118
+ * @param {Uint8Array | string} bytes
119
+ */
120
+ export function integrityOf(bytes) {
121
+ return `sha384-${createHash('sha384').update(bytes).digest('base64')}`;
122
+ }
123
+
124
+ /**
125
+ * The plugin.
126
+ *
127
+ * @param {{ env?: Record<string, string | undefined>, integrity?: boolean | undefined }} [options]
128
+ * the environment, the process's own by default; and whether to hold
129
+ * the assets to their digests -- the import map, the stylesheets'
130
+ * links and the preload headers -- which TINVAR_ASSET_INTEGRITY says
131
+ * where this does not, and which is otherwise on with an asset origin
132
+ * and off without
133
+ * @returns {import('vite').Plugin}
134
+ */
135
+ export function assetOrigin({ env = process.env, integrity } = {}) {
136
+ const origin = assetOriginOf(env);
137
+ const withIntegrity = integrity ?? integrityAskedOf(env) ?? origin !== null;
138
+ /** @type {Record<string, string>} */
139
+ const digests = {};
140
+ let base = '/';
141
+ /** @type {import('vite').ResolvedConfig | null} */
142
+ let resolved = null;
143
+
144
+ return {
145
+ name: 'tinvar:asset-origin',
146
+ // After every other plugin has had the bundle, so what is hashed is
147
+ // what was written.
148
+ enforce: 'post',
149
+ config(_config, { command }) {
150
+ if (command === 'build' && origin !== null) return { base: `${origin}/` };
151
+ return undefined;
152
+ },
153
+ configResolved(config) {
154
+ base = config.base;
155
+ resolved = config;
156
+ },
157
+ resolveId(id) {
158
+ return id === ASSETS_MODULE ? RESOLVED : undefined;
159
+ },
160
+ transform(code, id) {
161
+ if (!withIntegrity || !id.startsWith(CSS_MODULE) || this.environment?.name !== 'rsc') return undefined;
162
+ if (this.environment.config.command !== 'build') return undefined;
163
+ // The plugin's first pass, which reads the module graph alone, strips
164
+ // every module to its imports: nothing of it is shipped.
165
+ if (resolved !== null && getPluginApi(resolved)?.manager?.isScanBuild === true) return undefined;
166
+ return withStylesheetIntegrity(code, id);
167
+ },
168
+ load(id) {
169
+ if (id !== RESOLVED) return undefined;
170
+ // Modules alone: an import map says nothing about a stylesheet.
171
+ const scripts = Object.fromEntries(Object.entries(digests).filter(([url]) => url.endsWith('.js')));
172
+ const map = withIntegrity && Object.keys(scripts).length > 0 ? { integrity: scripts } : undefined;
173
+
174
+ return `export const importMap = ${JSON.stringify(map)};\nexport const assetOrigin = ${JSON.stringify(origin)};\n`;
175
+ },
176
+ generateBundle() {
177
+ if (this.environment?.name !== 'rsc' || this.environment.config.command !== 'build') return;
178
+ this.emitFile({
179
+ type: 'asset',
180
+ fileName: ASSETS_FILE,
181
+ source: JSON.stringify({ format: 1, origin }) + '\n',
182
+ });
183
+ },
184
+ writeBundle(options, bundle) {
185
+ if (this.environment?.name !== 'client') return;
186
+ const outDir = options.dir ?? this.environment.config.build.outDir;
187
+ if (withIntegrity) {
188
+ // Read back as written: a plugin before this one may have appended
189
+ // to a stylesheet as it wrote it, as StyleX does.
190
+ for (const fileName of Object.keys(bundle)) {
191
+ if (!fileName.endsWith('.js') && !fileName.endsWith('.css')) continue;
192
+ digests[`${base}${fileName}`] = integrityOf(readFileSync(join(outDir, fileName)));
193
+ }
194
+ }
195
+
196
+ const manifest = resolved === null ? undefined : getPluginApi(resolved)?.manager?.buildAssetsManifest;
197
+ if (manifest === undefined) return;
198
+ if (withIntegrity) {
199
+ refuseUncheckedStylesheets(manifest);
200
+ // Read where loadCss() renders each stylesheet's link: the manifest
201
+ // is written for the server builds once every environment is built.
202
+ manifest[MANIFEST_INTEGRITY] = Object.fromEntries(
203
+ Object.entries(digests).filter(([url]) => url.endsWith('.css')),
204
+ );
205
+ }
206
+
207
+ const rscOut = resolve(resolved?.root ?? '.', resolved?.environments?.rsc?.build?.outDir ?? 'dist/rsc');
208
+ writeFileSync(
209
+ join(rscOut, ASSETS_FILE),
210
+ JSON.stringify({
211
+ format: 1,
212
+ origin,
213
+ ...(withIntegrity ? { integrity: { ...digests } } : {}),
214
+ hints: hintsOf(manifest),
215
+ }) + '\n',
216
+ );
217
+ },
218
+ };
219
+ }
220
+
221
+ /**
222
+ * What every page loads first, from the RSC plugin's assets manifest: the
223
+ * client entry and what it imports, and the stylesheets every loadCss()
224
+ * links -- those all of them share, so a stylesheet one view links alone,
225
+ * a panel's, is never hinted to another's pages.
226
+ *
227
+ * @param {Record<string, any>} manifest
228
+ * @returns {{ modules: string[], styles: string[] }}
229
+ */
230
+ export function hintsOf(manifest) {
231
+ const modules = [manifest.clientEntryUrl, ...(manifest.clientEntryDeps?.js ?? [])].filter(
232
+ (url) => typeof url === 'string',
233
+ );
234
+ /** @type {string[][]} */
235
+ const sheets = Object.values(manifest.serverResources ?? {}).map((deps) =>
236
+ (deps?.css ?? []).filter((/** @type {unknown} */ url) => typeof url === 'string'),
237
+ );
238
+ const styles =
239
+ sheets.length === 0 ? [] : sheets[0].filter((url) => sheets.every((list) => list.includes(url)));
240
+
241
+ return { modules: [...new Set(modules)], styles: [...new Set(styles)] };
242
+ }
243
+
244
+ /**
245
+ * Refuses a build with digests that has a stylesheet the RSC plugin links
246
+ * itself: one a client component or the client entry imports, which its
247
+ * runtime preinits by URL alone.
248
+ *
249
+ * @param {Record<string, any>} manifest
250
+ */
251
+ function refuseUncheckedStylesheets(manifest) {
252
+ const unchecked = new Set([
253
+ ...(manifest.clientEntryDeps?.css ?? []),
254
+ ...Object.values(manifest.clientReferenceDeps ?? {}).flatMap((deps) => deps?.css ?? []),
255
+ ]);
256
+ if (unchecked.size > 0) {
257
+ throw new Error(
258
+ `[tinvar] A build held to its digests has stylesheets client code imports, which nothing would hold to theirs: ` +
259
+ `${[...unchecked].join(', ')}. Import them from a server component, whose loadCss() links them with their digest.`,
260
+ );
261
+ }
262
+ }
263
+
264
+ /**
265
+ * The module the RSC plugin makes for a loadCss(), with each stylesheet's
266
+ * link given its digest from the assets manifest. A stylesheet the manifest
267
+ * has no digest for is refused as the page renders, never linked unchecked.
268
+ *
269
+ * @param {string} code the plugin's module
270
+ * @param {string} id
271
+ * @returns {string}
272
+ */
273
+ export function withStylesheetIntegrity(code, id) {
274
+ if (!code.includes(RESOURCES_EXPORT) || !code.includes('__vite_rsc_assets_manifest__')) {
275
+ throw new Error(
276
+ `[tinvar] The RSC plugin's stylesheet module (${id}) is not of the shape assetOrigin() holds to digests; ` +
277
+ 'its links would go out unchecked.',
278
+ );
279
+ }
280
+
281
+ return (
282
+ code.replace(RESOURCES_EXPORT, 'const __tinvar_unchecked_resources = (') +
283
+ `
284
+ const __tinvar_digests = __vite_rsc_assets_manifest__[${JSON.stringify(MANIFEST_INTEGRITY)}] ?? {};
285
+ function __tinvar_held(element) {
286
+ if (element === null || typeof element !== 'object' || element.props == null) return element;
287
+ const { rel, href } = element.props;
288
+ if (element.type === 'link' && rel === 'stylesheet') {
289
+ const digest = typeof href === 'string' && Object.hasOwn(__tinvar_digests, href) ? __tinvar_digests[href] : null;
290
+ if (digest === null) throw new Error('[tinvar] No digest for the stylesheet ' + String(href) + '; build again.');
291
+ return __vite_rsc_react__.cloneElement(element, { integrity: digest });
292
+ }
293
+ const children = element.props.children;
294
+ if (children === undefined) return element;
295
+ return __vite_rsc_react__.cloneElement(
296
+ element,
297
+ undefined,
298
+ ...(Array.isArray(children) ? children.map(__tinvar_held) : [__tinvar_held(children)]),
299
+ );
300
+ }
301
+ export function Resources(props) {
302
+ return __tinvar_held(__tinvar_unchecked_resources(props));
303
+ }
304
+ `
305
+ );
306
+ }
@@ -0,0 +1,108 @@
1
+ // The build's profile, written into the build (ADR 0066).
2
+ //
3
+ // tinvar-spec.md, "Dev and test tooling": tooling is controlled by the build
4
+ // profile -- only `--profile=test` has the browser test helpers. PHP is not
5
+ // built, so the helpers' code cannot be left out of it; what can be made a
6
+ // property of the artefact is the JavaScript build. `tinvar build
7
+ // --profile=test` runs Vite with TINVAR_PROFILE=test, and this plugin records
8
+ // the profile in the rsc environment's output, `rsc/profile.json`, where PHP
9
+ // reads it (Tinvar\Support\BuildProfile): a server whose profile is test
10
+ // refuses a build that does not say test, and a server whose profile is not
11
+ // refuses one that does. A build with no record is a production build, so an
12
+ // application without this plugin simply cannot serve the test profile.
13
+ //
14
+ // TINVAR_BUILD_DIR, when set, is where the build goes -- `<dir>/client`,
15
+ // `<dir>/ssr`, `<dir>/rsc` -- so a test build can sit beside the production
16
+ // one rather than over it.
17
+
18
+ import { isAbsolute, resolve } from 'node:path';
19
+
20
+ /** The record's name, in the rsc environment's output. */
21
+ export const PROFILE_FILE = 'profile.json';
22
+
23
+ export const PROFILES = ['production', 'staging', 'test'];
24
+
25
+ /** The variables any of which, saying production, rule the test and staging profiles out. */
26
+ const PRODUCTION_MARKERS = ['TINVAR_ENV', 'APP_ENV'];
27
+
28
+ /** A build asked for a profile it may not have. */
29
+ export class BuildProfileRefused extends Error {}
30
+
31
+ /**
32
+ * The profile $env asks for: production unless TINVAR_PROFILE says staging
33
+ * or test.
34
+ *
35
+ * NODE_ENV is not read as a production marker here: Vite sets it to
36
+ * production for every build. The server reads it, beside the others, when
37
+ * it decides whether to serve.
38
+ *
39
+ * @param {Record<string, string | undefined>} env
40
+ * @returns {'production' | 'staging' | 'test'}
41
+ */
42
+ export function profileOf(env) {
43
+ const profile = (env.TINVAR_PROFILE ?? '').trim().toLowerCase() || 'production';
44
+ if (!PROFILES.includes(profile)) {
45
+ throw new BuildProfileRefused(
46
+ `TINVAR_PROFILE='${profile}' is not a profile: it is production, staging or test.`,
47
+ );
48
+ }
49
+ if (profile !== 'production') {
50
+ for (const name of PRODUCTION_MARKERS) {
51
+ if ((env[name] ?? '').trim().toLowerCase() === 'production') {
52
+ throw new BuildProfileRefused(
53
+ `A ${profile} build is refused: ${name}=production. ` +
54
+ (profile === 'test'
55
+ ? 'The test profile serves the browser test endpoint, which never runs in production.'
56
+ : 'A staging build is served under keys bound to staging, which production never has.'),
57
+ );
58
+ }
59
+ }
60
+ }
61
+
62
+ return /** @type {'production' | 'staging' | 'test'} */ (profile);
63
+ }
64
+
65
+ /**
66
+ * The plugin: the profile recorded in the rsc output, and the build put in
67
+ * TINVAR_BUILD_DIR when that is set. Listed after the RSC plugin, so the
68
+ * output directories it gives win over that plugin's defaults.
69
+ *
70
+ * @param {{ env?: Record<string, string | undefined> }} [options] the environment; the process's own by default
71
+ * @returns {import('vite').Plugin}
72
+ */
73
+ export function buildProfile({ env = process.env } = {}) {
74
+ const profile = profileOf(env);
75
+ const out = (env.TINVAR_BUILD_DIR ?? '').trim();
76
+ // Absolute, as the server requires: each process that reads the build
77
+ // runs in a directory of its own.
78
+ if (out !== '' && !isAbsolute(out)) {
79
+ throw new BuildProfileRefused(
80
+ `TINVAR_BUILD_DIR='${out}' is not an absolute path; name the build by one.`,
81
+ );
82
+ }
83
+
84
+ return {
85
+ name: 'tinvar:build-profile',
86
+ apply: 'build',
87
+ config() {
88
+ if (out === '') return undefined;
89
+ const directory = resolve(out);
90
+
91
+ return {
92
+ environments: {
93
+ client: { build: { outDir: resolve(directory, 'client') } },
94
+ ssr: { build: { outDir: resolve(directory, 'ssr') } },
95
+ rsc: { build: { outDir: resolve(directory, 'rsc') } },
96
+ },
97
+ };
98
+ },
99
+ generateBundle() {
100
+ if (this.environment?.name !== 'rsc') return;
101
+ this.emitFile({
102
+ type: 'asset',
103
+ fileName: PROFILE_FILE,
104
+ source: JSON.stringify({ format: 1, profile }) + '\n',
105
+ });
106
+ },
107
+ };
108
+ }