@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.
- package/LICENSE +21 -0
- package/bin/dev-renderer.js +117 -0
- package/bin/renderer-probe.js +52 -0
- package/bin/renderer.js +327 -0
- package/package.json +44 -4
- package/src/asset-origin.js +306 -0
- package/src/build-profile.js +108 -0
- package/src/can-branch.js +1666 -0
- package/src/classify.js +157 -0
- package/src/client-chunks.js +54 -0
- package/src/client-props.js +540 -0
- package/src/codec.js +297 -0
- package/src/dev-app.js +45 -0
- package/src/dev-guard.js +33 -0
- package/src/dev-server.js +460 -0
- package/src/eslint.js +469 -0
- package/src/head.js +524 -0
- package/src/html-stream.js +479 -0
- package/src/log.js +131 -0
- package/src/mint-client.js +142 -0
- package/src/panel-styles.js +254 -0
- package/src/pool.js +664 -0
- package/src/protocol.js +71 -0
- package/src/query-client.js +198 -0
- package/src/query-map.js +116 -0
- package/src/render-session.js +580 -0
- package/src/sandbox.js +710 -0
- package/src/server.js +466 -0
- package/src/socket-group.js +72 -0
- package/src/vite.js +2416 -0
- package/src/vitest.d.ts +20 -0
- package/src/vitest.js +179 -0
- package/src/warm.d.ts +9 -0
- package/src/warm.js +60 -0
- package/src/workbench.d.ts +146 -0
- package/src/workbench.js +754 -0
- package/README.md +0 -3
package/src/protocol.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Mirror of packages/framework/src/Protocol/Frame.php.
|
|
2
|
+
|
|
3
|
+
export const FRAME = {
|
|
4
|
+
RENDER: 'render',
|
|
5
|
+
FRAGMENT: 'fragment',
|
|
6
|
+
RESULT: 'result',
|
|
7
|
+
MINTED: 'minted',
|
|
8
|
+
// An email template to render from its props: answered with the HTML as
|
|
9
|
+
// chunks and the plain-text part in a meta frame.
|
|
10
|
+
MAIL: 'mail',
|
|
11
|
+
CHUNK: 'chunk',
|
|
12
|
+
QUERY: 'query',
|
|
13
|
+
META: 'meta',
|
|
14
|
+
MINT: 'mint',
|
|
15
|
+
// A fragment subtree failed and was rendered as an error row; PHP logs it.
|
|
16
|
+
REPORT: 'report',
|
|
17
|
+
DONE: 'done',
|
|
18
|
+
ERROR: 'error',
|
|
19
|
+
// A health check: the only frame on its connection, answered with one
|
|
20
|
+
// 'healthy' frame and the end of it. renderer:check and the pool's
|
|
21
|
+
// supervisor ask it; it carries nothing of any request, and an answer
|
|
22
|
+
// proves the event loop turns, which a connect alone does not -- the
|
|
23
|
+
// kernel accepts connections for a process that is stuck.
|
|
24
|
+
HEALTH: 'health',
|
|
25
|
+
HEALTHY: 'healthy',
|
|
26
|
+
// Asked first on a connection by PHP where the renderer runs as a user of
|
|
27
|
+
// its own, and answered at once: the kernel attaches the writing
|
|
28
|
+
// process's user to the answer (SCM_CREDENTIALS), which is how PHP knows
|
|
29
|
+
// who is at the other end before it sends anything of a request. It
|
|
30
|
+
// carries nothing, either way.
|
|
31
|
+
HELLO: 'hello',
|
|
32
|
+
READY: 'ready',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** What PHP may send. Anything else on this socket is refused. */
|
|
36
|
+
export const FROM_PHP = [
|
|
37
|
+
FRAME.RENDER,
|
|
38
|
+
FRAME.FRAGMENT,
|
|
39
|
+
FRAME.RESULT,
|
|
40
|
+
FRAME.MINTED,
|
|
41
|
+
FRAME.MAIL,
|
|
42
|
+
FRAME.HEALTH,
|
|
43
|
+
FRAME.HELLO,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** What the renderer may send. One-way: no flash data, nothing PHP persists. */
|
|
47
|
+
export const FROM_RENDERER = [
|
|
48
|
+
FRAME.CHUNK,
|
|
49
|
+
FRAME.QUERY,
|
|
50
|
+
FRAME.META,
|
|
51
|
+
FRAME.MINT,
|
|
52
|
+
FRAME.REPORT,
|
|
53
|
+
FRAME.DONE,
|
|
54
|
+
FRAME.ERROR,
|
|
55
|
+
FRAME.HEALTHY,
|
|
56
|
+
FRAME.READY,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export class ProtocolError extends Error {}
|
|
60
|
+
|
|
61
|
+
/** @param {Record<string, unknown>} frame */
|
|
62
|
+
export function assertFromPhp(frame) {
|
|
63
|
+
const type = frame?.t;
|
|
64
|
+
if (typeof type !== 'string' || type === '') {
|
|
65
|
+
throw new ProtocolError('Frame has a missing or non-string type.');
|
|
66
|
+
}
|
|
67
|
+
if (!FROM_PHP.includes(type)) {
|
|
68
|
+
throw new ProtocolError(`PHP may not send a '${type}' frame. Expected one of: ${FROM_PHP.join(', ')}.`);
|
|
69
|
+
}
|
|
70
|
+
return type;
|
|
71
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Per-tick query batching on the renderer side.
|
|
2
|
+
//
|
|
3
|
+
// "The renderer collects every query issued in one microtask tick and sends
|
|
4
|
+
// them as one message." Identical calls collapse into one, and results are
|
|
5
|
+
// memoized for the request, matching React cache() semantics.
|
|
6
|
+
|
|
7
|
+
export class QueryFailed extends Error {
|
|
8
|
+
/** @param {{code: string, digest?: string, message?: string}} error */
|
|
9
|
+
constructor(error) {
|
|
10
|
+
super(error.message ?? `Query failed (${error.code})`);
|
|
11
|
+
this.name = 'QueryFailed';
|
|
12
|
+
this.code = error.code;
|
|
13
|
+
this.digest = error.digest;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A BigInt as its digits. An id past 2^53 arrives from PHP as one and is
|
|
19
|
+
* passed straight back into a query, and JSON.stringify throws on it. Raw,
|
|
20
|
+
* not a string: 5n and 5 are the same integer to PHP, and "5" is not.
|
|
21
|
+
*/
|
|
22
|
+
const bigIntAware = (_key, value) => (typeof value === 'bigint' ? JSON.rawJSON(value.toString()) : value);
|
|
23
|
+
|
|
24
|
+
/** Key that ignores property order, so two spellings of one call dedupe. */
|
|
25
|
+
function stableKey(name, args) {
|
|
26
|
+
const keys = Object.keys(args).sort();
|
|
27
|
+
const parts = keys.map((k) => `${JSON.stringify(k)}:${JSON.stringify(args[k], bigIntAware)}`);
|
|
28
|
+
return `${name}\0{${parts.join(',')}}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Callers kept on one call, however many components asked for it. */
|
|
32
|
+
const MAX_BY = 16;
|
|
33
|
+
|
|
34
|
+
/** Whether a chain of callers is what callers() makes: names, the component first. */
|
|
35
|
+
const isChain = (chain) =>
|
|
36
|
+
Array.isArray(chain) && chain.length > 0 && chain.every((name) => typeof name === 'string');
|
|
37
|
+
|
|
38
|
+
export class QueryClient {
|
|
39
|
+
#send;
|
|
40
|
+
#nextId = 0;
|
|
41
|
+
#inflight = new Map();
|
|
42
|
+
#memo = new Map();
|
|
43
|
+
#queue = [];
|
|
44
|
+
/** The calls in #queue by key, until they leave: a call made again before then adds its callers. */
|
|
45
|
+
#queued = new Map();
|
|
46
|
+
#scheduled = false;
|
|
47
|
+
#trailing = false;
|
|
48
|
+
#closed = false;
|
|
49
|
+
|
|
50
|
+
stats = { calls: 0, deduped: 0, batches: 0, largestBatch: 0 };
|
|
51
|
+
|
|
52
|
+
/** @param {(calls: Array<{id: number, name: string, args: Record<string, unknown>, by?: string[][]}>) => void} send */
|
|
53
|
+
constructor(send) {
|
|
54
|
+
this.#send = send;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A trailing call waits to leave with the next call that is not: in the
|
|
59
|
+
* same frame as the tick's queries, or -- if none comes before the event
|
|
60
|
+
* loop turns -- alone. A shared read is one (ADR 0060). The layout that
|
|
61
|
+
* reads it renders before the page inside it, whose components React
|
|
62
|
+
* renders a few microtasks later; leaving on its own tick, the read cost
|
|
63
|
+
* every page a round trip of its own.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} name
|
|
66
|
+
* @param {Record<string, unknown>} args
|
|
67
|
+
* `by` names the component making the call and its owners, in
|
|
68
|
+
* development only (callers() in @tinvar/react/server). It travels beside
|
|
69
|
+
* the call as a list of such chains, for the dev toolbar, and PHP reads
|
|
70
|
+
* the call without it. A call made again before it leaves -- by another
|
|
71
|
+
* component, deduped into this one -- adds its chain.
|
|
72
|
+
*
|
|
73
|
+
* @param {{ trailing?: boolean, by?: string[] }} [options]
|
|
74
|
+
* @returns {Promise<unknown>}
|
|
75
|
+
*/
|
|
76
|
+
call(name, args, options = {}) {
|
|
77
|
+
if (this.#closed) {
|
|
78
|
+
return Promise.reject(new QueryFailed({ code: 'render_closed' }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const key = stableKey(name, args);
|
|
82
|
+
const memoized = this.#memo.get(key);
|
|
83
|
+
if (memoized) {
|
|
84
|
+
this.stats.deduped++;
|
|
85
|
+
const waiting = this.#queued.get(key);
|
|
86
|
+
const by = options.by;
|
|
87
|
+
if (waiting !== undefined && isChain(by) && waiting.by.length < MAX_BY) {
|
|
88
|
+
if (!waiting.by.some((chain) => chain.join('\0') === by.join('\0'))) waiting.by.push([...by]);
|
|
89
|
+
}
|
|
90
|
+
return memoized;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const id = this.#nextId++;
|
|
94
|
+
const promise = new Promise((resolve, reject) => {
|
|
95
|
+
this.#inflight.set(id, { resolve, reject });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// A component that started a query and never awaited it -- a prefetch,
|
|
99
|
+
// a subtree discarded mid-render -- leaves nobody attached, and Node
|
|
100
|
+
// treats an unhandled rejection as fatal: one request's failed query
|
|
101
|
+
// would end every render on the process. The caller's own rejection
|
|
102
|
+
// path is unaffected, as for MintClient.
|
|
103
|
+
promise.catch(() => {});
|
|
104
|
+
|
|
105
|
+
this.#memo.set(key, promise);
|
|
106
|
+
const call = { id, name, args };
|
|
107
|
+
if (isChain(options.by)) {
|
|
108
|
+
call.by = [[...options.by]];
|
|
109
|
+
this.#queued.set(key, call);
|
|
110
|
+
}
|
|
111
|
+
this.#queue.push(call);
|
|
112
|
+
this.stats.calls++;
|
|
113
|
+
if (options.trailing === true) this.#scheduleTrailing();
|
|
114
|
+
else this.#schedule();
|
|
115
|
+
|
|
116
|
+
return promise;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// One flush per microtask tick. Every component that asked during this tick
|
|
120
|
+
// -- 42 units, say -- leaves in a single frame.
|
|
121
|
+
#schedule() {
|
|
122
|
+
if (this.#scheduled) return;
|
|
123
|
+
this.#scheduled = true;
|
|
124
|
+
|
|
125
|
+
queueMicrotask(() => {
|
|
126
|
+
this.#scheduled = false;
|
|
127
|
+
this.#flush();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Once the event loop turns, for trailing calls no other call took along.
|
|
132
|
+
#scheduleTrailing() {
|
|
133
|
+
if (this.#trailing) return;
|
|
134
|
+
this.#trailing = true;
|
|
135
|
+
|
|
136
|
+
setImmediate(() => {
|
|
137
|
+
this.#trailing = false;
|
|
138
|
+
this.#flush();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#flush() {
|
|
143
|
+
// A flush the closed render never reached sends nothing: its waiters
|
|
144
|
+
// were rejected by close(), and the socket is not theirs any more.
|
|
145
|
+
if (this.#closed) return;
|
|
146
|
+
|
|
147
|
+
const batch = this.#queue;
|
|
148
|
+
this.#queue = [];
|
|
149
|
+
this.#queued.clear();
|
|
150
|
+
if (batch.length === 0) return;
|
|
151
|
+
|
|
152
|
+
this.stats.batches++;
|
|
153
|
+
this.stats.largestBatch = Math.max(this.stats.largestBatch, batch.length);
|
|
154
|
+
|
|
155
|
+
// Encoded here, with nobody above it on the stack: a callback or a
|
|
156
|
+
// symbol among the arguments threw out of it and took the whole
|
|
157
|
+
// renderer down, every render on it included. It fails this batch
|
|
158
|
+
// and nothing else.
|
|
159
|
+
try {
|
|
160
|
+
this.#send(batch);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
for (const { id } of batch) {
|
|
163
|
+
this.#inflight.get(id)?.reject(
|
|
164
|
+
new QueryFailed({
|
|
165
|
+
code: 'query_bad_arguments',
|
|
166
|
+
message: `The query could not be sent: ${error?.message ?? error}`,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
this.#inflight.delete(id);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @param {Array<{id: number, value?: unknown, error?: {code: string, digest?: string, message?: string}}>} results */
|
|
175
|
+
settle(results) {
|
|
176
|
+
for (const result of results) {
|
|
177
|
+
const waiter = this.#inflight.get(result.id);
|
|
178
|
+
if (!waiter) continue;
|
|
179
|
+
this.#inflight.delete(result.id);
|
|
180
|
+
|
|
181
|
+
if (result.error) waiter.reject(new QueryFailed(result.error));
|
|
182
|
+
else waiter.resolve(result.value);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Rejects anything still waiting, so a dead socket never hangs a render. */
|
|
187
|
+
close(reason = 'render_closed') {
|
|
188
|
+
this.#closed = true;
|
|
189
|
+
for (const [, waiter] of this.#inflight) {
|
|
190
|
+
waiter.reject(new QueryFailed({ code: reason }));
|
|
191
|
+
}
|
|
192
|
+
this.#inflight.clear();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
get pending() {
|
|
196
|
+
return this.#inflight.size;
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/query-map.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// What `tinvar codegen` writes about the queries, for the tools that read
|
|
2
|
+
// server components' source: the renderer build (vite.js), which puts a
|
|
3
|
+
// page's calls on its route's allowlist, and the lint (eslint.js), which
|
|
4
|
+
// holds a query called inside a can() branch to its own #[Authorize].
|
|
5
|
+
//
|
|
6
|
+
// Its own module, apart from vite.js, so the lint loads it without loading
|
|
7
|
+
// Vite and the RSC plugin into ESLint.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
/** Where `tinvar codegen` writes which query each @queries/* function calls, under the root. */
|
|
12
|
+
export const QUERY_MAP = 'resources/types/queries.json';
|
|
13
|
+
|
|
14
|
+
/** A module codegen types: `@queries`, or `@queries/` and a prefix of query names. */
|
|
15
|
+
export const QUERIES_MODULE = /^@queries(?:\/|$)/;
|
|
16
|
+
|
|
17
|
+
/** Why the build cannot list the queries a module calls. */
|
|
18
|
+
export class UnreadableQuery extends Error {
|
|
19
|
+
/** @param {string} message */
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'UnreadableQuery';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The parsed file, or null when there is none.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} file
|
|
30
|
+
* @returns {Record<string, unknown> | null}
|
|
31
|
+
* @throws {UnreadableQuery} for a file that is not one codegen writes
|
|
32
|
+
*/
|
|
33
|
+
function readFile(file) {
|
|
34
|
+
if (!existsSync(file)) return null;
|
|
35
|
+
|
|
36
|
+
let data;
|
|
37
|
+
try {
|
|
38
|
+
data = JSON.parse(readFileSync(file, 'utf8'));
|
|
39
|
+
} catch {
|
|
40
|
+
throw malformed(file, 'is not valid JSON');
|
|
41
|
+
}
|
|
42
|
+
if (data?.format !== 1 || !isRecord(data.modules))
|
|
43
|
+
throw malformed(file, 'is not a query map this version reads');
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The @queries/* functions and the query each calls, as `tinvar codegen`
|
|
49
|
+
* wrote them -- none when it has written nothing, and an import of one then
|
|
50
|
+
* fails the build rather than going unlisted. The names come from codegen,
|
|
51
|
+
* not from the function's name: `racks.UnitLoad` is typed as `unitLoad()`,
|
|
52
|
+
* and nothing in the name says which.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} file
|
|
55
|
+
* @returns {Record<string, Record<string, string>>} module => function => query
|
|
56
|
+
* @throws {UnreadableQuery} for a file that is not one codegen writes
|
|
57
|
+
*/
|
|
58
|
+
export function readQueryMap(file) {
|
|
59
|
+
const data = readFile(file);
|
|
60
|
+
if (data === null) return {};
|
|
61
|
+
|
|
62
|
+
/** @type {Record<string, Record<string, string>>} */
|
|
63
|
+
const modules = Object.create(null);
|
|
64
|
+
for (const [module, functions] of Object.entries(/** @type {Record<string, unknown>} */ (data.modules))) {
|
|
65
|
+
if (!QUERIES_MODULE.test(module) || !isRecord(functions)) {
|
|
66
|
+
throw malformed(file, `lists ${module}, which is not a @queries module`);
|
|
67
|
+
}
|
|
68
|
+
modules[module] = Object.create(null);
|
|
69
|
+
for (const [name, query] of Object.entries(functions)) {
|
|
70
|
+
if (typeof query !== 'string' || query === '')
|
|
71
|
+
throw malformed(file, `names no query for ${module}'s ${name}()`);
|
|
72
|
+
modules[module][name] = query;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return modules;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The abilities each query's own #[Authorize] checks, as `tinvar codegen`
|
|
80
|
+
* wrote them: every query is listed, one declaring none -- a
|
|
81
|
+
* #[NoAuthorization] query -- with an empty list. Null when the map is
|
|
82
|
+
* missing or was written before codegen wrote this, which a reader asking
|
|
83
|
+
* about a query must refuse rather than read as "no checks".
|
|
84
|
+
*
|
|
85
|
+
* @param {string} file
|
|
86
|
+
* @returns {Record<string, string[]> | null} query => abilities
|
|
87
|
+
* @throws {UnreadableQuery} for a file that is not one codegen writes
|
|
88
|
+
*/
|
|
89
|
+
export function readQueryAuthorization(file) {
|
|
90
|
+
const data = readFile(file);
|
|
91
|
+
if (data === null || data.authorize === undefined) return null;
|
|
92
|
+
if (!isRecord(data.authorize)) throw malformed(file, "has an 'authorize' that is not a map");
|
|
93
|
+
|
|
94
|
+
/** @type {Record<string, string[]>} */
|
|
95
|
+
const queries = Object.create(null);
|
|
96
|
+
for (const [query, abilities] of Object.entries(data.authorize)) {
|
|
97
|
+
if (
|
|
98
|
+
!Array.isArray(abilities) ||
|
|
99
|
+
abilities.some((ability) => typeof ability !== 'string' || ability === '')
|
|
100
|
+
) {
|
|
101
|
+
throw malformed(file, `lists the abilities of ${query} as something other than names`);
|
|
102
|
+
}
|
|
103
|
+
queries[query] = [...abilities];
|
|
104
|
+
}
|
|
105
|
+
return queries;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {string} file @param {string} why */
|
|
109
|
+
function malformed(file, why) {
|
|
110
|
+
return new UnreadableQuery(`[tinvar] ${file} ${why}; run tinvar codegen to write it again.`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @returns {value is Record<string, unknown>} */
|
|
114
|
+
export function isRecord(value) {
|
|
115
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
116
|
+
}
|