@tinvar/renderer 0.0.0-stage → 0.4.1

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,142 @@
1
+ // Asking PHP to sign a fragment token.
2
+ //
3
+ // The renderer holds no signing key -- the brief's security rules say it gets
4
+ // no secrets, and the trust boundary puts it on the "runs third-party npm
5
+ // code" side. But it is the only side that knows which <Deferred> boundaries
6
+ // a tree contains and what props they were given, because those facts exist
7
+ // only while the tree renders.
8
+ //
9
+ // So it asks. It sends the component and the props; PHP chooses the route,
10
+ // params, panel, build, session hash and expiry, and signs. A compromised
11
+ // renderer can therefore ask for a token it should not have, and PHP will
12
+ // sign it -- bounded on purpose: the token is not a capability (the fragment
13
+ // endpoint re-runs the originating route's #[Authorize] chain on use), the
14
+ // binding fields it cannot influence are the ones that matter, and PHP caps
15
+ // the requests one render may make (RenderChannel; Frame::MAX_MINTS_PER_RENDER):
16
+ // nothing here counts them, since nothing here is to be trusted to.
17
+ //
18
+ // Shaped like QueryClient deliberately: same batching, same settle-by-id, so
19
+ // there is one pattern on this socket rather than two.
20
+
21
+ export class MintFailed extends Error {}
22
+
23
+ export class MintClient {
24
+ #send;
25
+ #pending = new Map();
26
+ #queued = [];
27
+ // The ids of each batch sent and not yet answered, oldest first. PHP
28
+ // answers every mint frame with one minted frame, in order, so a reply
29
+ // belongs to the oldest batch here.
30
+ #sent = [];
31
+ #nextId = 0;
32
+ #scheduled = false;
33
+ #closed = null;
34
+
35
+ /** @param {(requests: Array<{id: number, component: string, props: object}>) => void} send */
36
+ constructor(send) {
37
+ this.#send = send;
38
+ }
39
+
40
+ /**
41
+ * @param {string} component
42
+ * @param {Record<string, unknown>} props
43
+ * @returns {Promise<string>} the encoded token
44
+ */
45
+ mint(component, props = {}) {
46
+ if (this.#closed) return Promise.reject(new MintFailed(this.#closed));
47
+
48
+ const id = this.#nextId++;
49
+
50
+ const promise = new Promise((resolve, reject) => {
51
+ this.#pending.set(id, { resolve, reject });
52
+ this.#queued.push({ id, component, props });
53
+ this.#schedule();
54
+ });
55
+
56
+ // A subtree that started a mint and was then abandoned -- its render
57
+ // threw, or the boundary was discarded -- leaves nobody attached to this
58
+ // promise. close() rejects it, and Node treats an unhandled rejection as
59
+ // fatal, so tearing down a failed render killed the process. The caller's
60
+ // own rejection path is unaffected: this handler is attached to an
61
+ // internal reference, not to what is returned.
62
+ promise.catch(() => {});
63
+
64
+ return promise;
65
+ }
66
+
67
+ // One frame per tick, as queries do: a page with twelve <Deferred>
68
+ // boundaries costs one round trip, not twelve.
69
+ #schedule() {
70
+ if (this.#scheduled) return;
71
+ this.#scheduled = true;
72
+
73
+ queueMicrotask(() => {
74
+ this.#scheduled = false;
75
+ if (this.#queued.length === 0) return;
76
+
77
+ const batch = this.#queued;
78
+ this.#queued = [];
79
+
80
+ // Encoded here, in a microtask with nobody above it: props a frame
81
+ // cannot carry threw out of it and took the whole renderer down. They
82
+ // fail this batch's boundaries and nothing else -- and a batch that
83
+ // never left expects no reply.
84
+ try {
85
+ this.#send(batch);
86
+ } catch (error) {
87
+ for (const { id } of batch) {
88
+ this.#pending
89
+ .get(id)
90
+ ?.reject(new MintFailed(`The token request could not be sent: ${error?.message ?? error}`));
91
+ this.#pending.delete(id);
92
+ }
93
+ return;
94
+ }
95
+ this.#sent.push(batch.map(({ id }) => id));
96
+ });
97
+ }
98
+
99
+ /**
100
+ * The reply to the oldest batch still waiting.
101
+ *
102
+ * A null token is PHP refusing that one boundary. A boundary the reply
103
+ * leaves out is refused too: nothing else will ever answer it, and
104
+ * waiting for an answer held the whole render until PHP's deadline. Both
105
+ * reject only that boundary's promise, so <Deferred> reports it and the
106
+ * page completes.
107
+ *
108
+ * @param {Array<{id: number, token: string | null}>} tokens
109
+ */
110
+ settle(tokens) {
111
+ const batch = this.#sent.shift() ?? [];
112
+
113
+ for (const { id, token } of tokens) {
114
+ const waiting = this.#pending.get(id);
115
+ if (!waiting) continue;
116
+ this.#pending.delete(id);
117
+
118
+ if (typeof token === 'string' && token !== '') {
119
+ waiting.resolve(token);
120
+ } else {
121
+ waiting.reject(new MintFailed('PHP returned no token for this boundary.'));
122
+ }
123
+ }
124
+
125
+ for (const id of batch) {
126
+ const waiting = this.#pending.get(id);
127
+ if (!waiting) continue;
128
+ this.#pending.delete(id);
129
+ waiting.reject(new MintFailed('PHP returned no token for this boundary.'));
130
+ }
131
+ }
132
+
133
+ /** @param {string} [reason] */
134
+ close(reason = 'render_finished') {
135
+ this.#closed = reason;
136
+ for (const { reject } of this.#pending.values()) {
137
+ reject(new MintFailed(`Fragment token never arrived: ${reason}.`));
138
+ }
139
+ this.#pending.clear();
140
+ this.#queued = [];
141
+ }
142
+ }
@@ -0,0 +1,254 @@
1
+ // A panel's styles, in a stylesheet of its own.
2
+ //
3
+ // panelChunks() keeps a panel's client code out of every other page's
4
+ // downloads; its styles went everywhere still. StyleX compiles every
5
+ // stylex.create() the build reaches -- the admin panel's pages' and their
6
+ // components' as well as the shop's -- into one stylesheet, which the
7
+ // document links on every page. Nothing in it runs, but it maps out what
8
+ // staff see, it grows every customer's download, and a panel is meant to be
9
+ // a boundary (ADR 0109).
10
+ //
11
+ // So, given the StyleX plugin, panelChunks() splits StyleX's rules once the
12
+ // client build has the rest of the graph. The stylesheet the document links
13
+ // on every page keeps the app's own CSS and the shared rules alone, and each
14
+ // panel with rules of its own gets a sheet holding the shared rules and its
15
+ // own, whole, which its pages link after it: the panel's rules in StyleX's
16
+ // own order, where they meet the shared ones repeated after themselves, so
17
+ // two rules never meet out of it. A rule goes to a panel's sheet alone when
18
+ // every view that reaches the module declaring it -- through server imports,
19
+ // then a client component's own -- is in that panel, and nothing reaches it
20
+ // outside the views: the document, a layout kept outside the pages, the
21
+ // client entry. Anything the build cannot trace stays shared.
22
+ //
23
+ // A page outside every panel links one stylesheet, as before: a second, even
24
+ // one of nothing, put a page's queries in two frames instead of one, React
25
+ // scheduling its render around the stylesheet. A panel's page links its
26
+ // sheet through `virtual:tinvar/styles`, read from the RSC plugin's assets
27
+ // manifest, which the client build fills in; in dev StyleX hands the browser
28
+ // its CSS itself, and the component renders nothing.
29
+
30
+ import { createRequire } from 'node:module';
31
+ import { join } from 'node:path';
32
+
33
+ /** The module the document imports its stylesheet component from. */
34
+ export const STYLES_MODULE = 'virtual:tinvar/styles';
35
+
36
+ /** Where the client build records the sheets, in the RSC plugin's assets manifest. */
37
+ export const MANIFEST_STYLES = 'tinvarStyles';
38
+
39
+ /** A client-reference group's virtual module, as @vitejs/plugin-rsc names it. */
40
+ const GROUP_PREFIX = '\0virtual:vite-rsc/client-references/group/';
41
+
42
+ /** @param {string} id */
43
+ function fileOf(id) {
44
+ return id.replace(/[?#].*$/, '');
45
+ }
46
+
47
+ /**
48
+ * The client graph's id for a client reference the rsc graph names: its
49
+ * import id, which a package's proxy names by URL.
50
+ *
51
+ * @param {string} importId
52
+ */
53
+ export function clientIdOf(importId) {
54
+ return importId.startsWith('/@id/__x00__') ? `\0${importId.slice('/@id/__x00__'.length)}` : importId;
55
+ }
56
+
57
+ /**
58
+ * Which panel owns each module the build reached, by file: a module is a
59
+ * panel's when every view reaching it is that panel's and nothing reaches
60
+ * it outside the views.
61
+ *
62
+ * @param {{
63
+ * rsc: Map<string, string[]>,
64
+ * client: Map<string, string[]>,
65
+ * references: Map<string, string>,
66
+ * views: Map<string, string>,
67
+ * rscEntries: string[],
68
+ * clientEntries: string[],
69
+ * panelOf: (view: string) => string | null,
70
+ * }} graphs `references` maps a client reference's rsc id to its import id;
71
+ * `views` each view's name to its rsc id
72
+ * @returns {Map<string, string>} file => the panel that owns it
73
+ */
74
+ export function moduleOwnership({ rsc, client, references, views, rscEntries, clientEntries, panelOf }) {
75
+ const viewIds = new Set(views.values());
76
+
77
+ /**
78
+ * Every file reached from $starts: server imports, and at a client
79
+ * reference, its client module's imports -- never into another view (from
80
+ * an entry), nor into the client-reference groups the client entry loads
81
+ * on demand.
82
+ *
83
+ * @param {string[]} starts rsc ids
84
+ * @param {string[]} clientStarts client ids
85
+ * @param {boolean} stopAtViews
86
+ */
87
+ const reach = (starts, clientStarts, stopAtViews) => {
88
+ const files = new Set();
89
+ const seen = new Set();
90
+ /** @type {Array<[string, 'rsc' | 'client']>} */
91
+ const queue = [
92
+ ...starts.map((id) => /** @type {[string, 'rsc']} */ ([id, 'rsc'])),
93
+ ...clientStarts.map((id) => /** @type {[string, 'client']} */ ([id, 'client'])),
94
+ ];
95
+ while (queue.length > 0) {
96
+ const [id, side] = /** @type {[string, 'rsc' | 'client']} */ (queue.shift());
97
+ const key = `${side}:${id}`;
98
+ if (seen.has(key)) continue;
99
+ seen.add(key);
100
+ // Another view is that view's own: the entry imports every page, and
101
+ // reaches none of them for that.
102
+ if (side === 'rsc' && stopAtViews && viewIds.has(id) && !starts.includes(id)) continue;
103
+ files.add(fileOf(id));
104
+
105
+ if (side === 'rsc') {
106
+ const reference = references.get(id);
107
+ if (reference !== undefined) {
108
+ const clientId = clientIdOf(reference);
109
+ const found = client.has(clientId) ? clientId : client.has(id) ? id : null;
110
+ // None in the client build: a reference the server bundle shook
111
+ // out, whose code never ships -- nothing past it to follow.
112
+ if (found === null) continue;
113
+ queue.push([found, 'client']);
114
+ continue;
115
+ }
116
+ for (const next of rsc.get(id) ?? []) queue.push([next, 'rsc']);
117
+ continue;
118
+ }
119
+ for (const next of client.get(id) ?? []) {
120
+ if (!next.startsWith(GROUP_PREFIX)) queue.push([next, 'client']);
121
+ }
122
+ }
123
+ return files;
124
+ };
125
+
126
+ const shared = reach(rscEntries, clientEntries, true);
127
+ /** @type {Map<string, Set<string>>} panel => files */
128
+ const byPanel = new Map();
129
+ for (const [view, id] of views) {
130
+ const panel = panelOf(view);
131
+ const files = reach([id], [], true);
132
+ if (panel === null) {
133
+ for (const file of files) shared.add(file);
134
+ continue;
135
+ }
136
+ if (!byPanel.has(panel)) byPanel.set(panel, new Set());
137
+ const own = /** @type {Set<string>} */ (byPanel.get(panel));
138
+ for (const file of files) own.add(file);
139
+ }
140
+
141
+ /** @type {Map<string, string>} */
142
+ const owned = new Map();
143
+ /** @type {Map<string, number>} */
144
+ const claims = new Map();
145
+ for (const files of byPanel.values()) {
146
+ for (const file of files) claims.set(file, (claims.get(file) ?? 0) + 1);
147
+ }
148
+ for (const [panel, files] of byPanel) {
149
+ for (const file of files) {
150
+ if (!shared.has(file) && claims.get(file) === 1) owned.set(file, panel);
151
+ }
152
+ }
153
+
154
+ return owned;
155
+ }
156
+
157
+ /**
158
+ * The StyleX rules each sheet holds: the shared sheet every rule no panel
159
+ * owns, and each panel's sheet the same and its own.
160
+ *
161
+ * @param {Map<string, unknown[]>} rulesById StyleX's rules, by the module that declared them
162
+ * @param {Map<string, string>} owned file => panel, from moduleOwnership()
163
+ * @param {string[]} panels
164
+ * @returns {{ shared: unknown[], panels: Record<string, unknown[]>, owned: Record<string, number> }}
165
+ * the rules, and how many each panel owns
166
+ */
167
+ export function partitionRules(rulesById, owned, panels) {
168
+ const shared = [];
169
+ /** @type {Record<string, unknown[]>} */
170
+ const own = Object.fromEntries(panels.map((panel) => [panel, []]));
171
+ for (const [id, rules] of [...rulesById].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
172
+ const panel = owned.get(fileOf(id));
173
+ if (panel !== undefined && own[panel] !== undefined) own[panel].push(...rules);
174
+ else shared.push(...rules);
175
+ }
176
+
177
+ return {
178
+ shared,
179
+ panels: Object.fromEntries(panels.map((panel) => [panel, [...shared, ...(own[panel] ?? [])]])),
180
+ owned: Object.fromEntries(panels.map((panel) => [panel, own[panel]?.length ?? 0])),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Renders StyleX rules as the StyleX plugin does -- its babel plugin's
186
+ * ordering, then lightningcss for the browsers the app names -- with the
187
+ * plugin's own copies of both, found beside it.
188
+ *
189
+ * @param {string} root the app's root, which resolves @stylexjs/unplugin
190
+ * @returns {(rules: unknown[]) => string}
191
+ */
192
+ export function styleXRenderer(root) {
193
+ const fromApp = createRequire(join(root, 'package.json'));
194
+ const fromPlugin = createRequire(fromApp.resolve('@stylexjs/unplugin'));
195
+ const babel = fromPlugin('@stylexjs/babel-plugin');
196
+ const lightningcss = fromPlugin('lightningcss');
197
+ const browserslist = fromPlugin('browserslist');
198
+ const process = babel.processStylexRules ?? babel.default?.processStylexRules;
199
+
200
+ return (rules) => {
201
+ if (rules.length === 0) return '';
202
+ const css = process(rules, { useLayers: false });
203
+ const { code } = lightningcss.transform({
204
+ targets: lightningcss.browserslistToTargets(browserslist()),
205
+ filename: 'stylex.css',
206
+ code: Buffer.from(css),
207
+ });
208
+ return code.toString();
209
+ };
210
+ }
211
+
212
+ /**
213
+ * The module the document imports `Stylesheets` from: a panel's view's
214
+ * sheet, read from the assets manifest, with its digest where the build has
215
+ * them -- and refused without one there. Any other view, and dev, nothing.
216
+ *
217
+ * @param {Record<string, string>} panels panel => directory under the pages
218
+ * @param {boolean} build
219
+ */
220
+ export function stylesModule(panels, build) {
221
+ if (!build) return 'export function Stylesheets() {\n return null;\n}\n';
222
+
223
+ const directories = Object.entries(panels).map(([panel, directory]) => [
224
+ panel,
225
+ directory.replace(/\/+$/, ''),
226
+ ]);
227
+ return `import __tinvar_manifest__ from 'virtual:vite-rsc/assets-manifest';
228
+ import { createElement } from 'react';
229
+
230
+ const sheets = __tinvar_manifest__[${JSON.stringify(MANIFEST_STYLES)}] ?? null;
231
+ const digests = __tinvar_manifest__.tinvarIntegrity ?? null;
232
+ const panels = ${JSON.stringify(directories)};
233
+
234
+ function panelOf(view) {
235
+ for (const [panel, directory] of panels) {
236
+ if (view === directory || view.startsWith(directory + '/')) return panel;
237
+ }
238
+ return null;
239
+ }
240
+
241
+ export function Stylesheets({ view }) {
242
+ if (sheets === null) return null;
243
+ const panel = typeof view === 'string' ? panelOf(view) : null;
244
+ if (panel === null || !Object.hasOwn(sheets.panels, panel)) return null;
245
+ const href = sheets.panels[panel];
246
+ const props = { rel: 'stylesheet', href, precedence: 'tinvar/styles', crossOrigin: 'anonymous' };
247
+ if (digests !== null) {
248
+ if (!Object.hasOwn(digests, href)) throw new Error('[tinvar] No digest for the stylesheet ' + href + '; build again.');
249
+ props.integrity = digests[href];
250
+ }
251
+ return createElement('link', props);
252
+ }
253
+ `;
254
+ }