@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.
- 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/codec.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Framing for the PHP-to-renderer socket, mirroring packages/framework/src/Codec.
|
|
2
|
+
//
|
|
3
|
+
// Every message is a 4-byte big-endian length followed by a MessagePack map
|
|
4
|
+
// carrying a `t` (type) key (tinvar-spec.md, "Socket protocol").
|
|
5
|
+
|
|
6
|
+
import { decode, encode } from '@msgpack/msgpack';
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
// useBigInt64 keeps integers past 2^53 exact in both directions, which is why
|
|
11
|
+
// the spec picks MessagePack over JSON for this hop.
|
|
12
|
+
const ENCODE_OPTIONS = { useBigInt64: true };
|
|
13
|
+
const DECODE_OPTIONS = { useBigInt64: true };
|
|
14
|
+
|
|
15
|
+
export class FrameError extends Error {}
|
|
16
|
+
|
|
17
|
+
const utf8Encoder = new TextEncoder();
|
|
18
|
+
const utf8Decoder = new TextDecoder();
|
|
19
|
+
|
|
20
|
+
const INT32_MIN = -0x80000000;
|
|
21
|
+
const UINT32_MAX = 0xffffffff;
|
|
22
|
+
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
23
|
+
const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER);
|
|
24
|
+
|
|
25
|
+
/** The spec's socket format. */
|
|
26
|
+
export const msgpackCodec = {
|
|
27
|
+
name: 'msgpack',
|
|
28
|
+
encode: (value) => encode(widenIntegers(value), ENCODE_OPTIONS),
|
|
29
|
+
decode: (bytes) => narrowIntegers(decode(bytes, DECODE_OPTIONS)),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const isPlainObject = (value) => {
|
|
33
|
+
if (value === null || typeof value !== 'object') return false;
|
|
34
|
+
const prototype = Object.getPrototypeOf(value);
|
|
35
|
+
return prototype === Object.prototype || prototype === null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Safe integers past 32 bits, made BigInt so they leave as int64.
|
|
40
|
+
*
|
|
41
|
+
* With useBigInt64 on, @msgpack/msgpack writes every number past 32 bits as
|
|
42
|
+
* a float64 -- a millisecond timestamp, a file size -- and PHP decodes it as
|
|
43
|
+
* a float, which an `int` parameter refuses. Only plain objects and arrays
|
|
44
|
+
* are walked: they are what frames are made of, and anything else is left
|
|
45
|
+
* for the encoder to take or refuse as it is.
|
|
46
|
+
*/
|
|
47
|
+
function widenIntegers(value) {
|
|
48
|
+
if (typeof value === 'number') {
|
|
49
|
+
return Number.isSafeInteger(value) && (value > UINT32_MAX || value < INT32_MIN) ? BigInt(value) : value;
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(value)) return value.map(widenIntegers);
|
|
52
|
+
if (isPlainObject(value)) {
|
|
53
|
+
// Defined, not assigned, for the reason lowerBinary() gives: a
|
|
54
|
+
// `__proto__` key is data here, and assignment would run the setter.
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const [key, item] of Object.entries(value)) {
|
|
57
|
+
Object.defineProperty(out, key, {
|
|
58
|
+
value: widenIntegers(item),
|
|
59
|
+
enumerable: true,
|
|
60
|
+
writable: true,
|
|
61
|
+
configurable: true,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every int64 PHP sent comes back as a BigInt, timestamps and file sizes
|
|
71
|
+
* included. tinvar-spec.md: only integers beyond 2^53 need one -- the rest
|
|
72
|
+
* are numbers, which arithmetic, Date and JSON.stringify all expect. In
|
|
73
|
+
* place: the decoder's objects are new and nothing else holds them.
|
|
74
|
+
*/
|
|
75
|
+
function narrowIntegers(value) {
|
|
76
|
+
if (typeof value === 'bigint') {
|
|
77
|
+
return value <= MAX_SAFE && value >= MIN_SAFE ? Number(value) : value;
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
for (let i = 0; i < value.length; i++) value[i] = narrowIntegers(value[i]);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
if (isPlainObject(value)) {
|
|
84
|
+
// The decoder refuses a `__proto__` key, so assignment is safe here.
|
|
85
|
+
for (const key of Object.keys(value)) value[key] = narrowIntegers(value[key]);
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The alternative the spec leaves open, kept wire-comparable with
|
|
92
|
+
* Tinvar\Codec\JsonLinesCodec: same length prefix, same frame maps, binary
|
|
93
|
+
* carried as {"$b64": "..."} because JSON cannot hold bytes.
|
|
94
|
+
*
|
|
95
|
+
* A map key is user data -- a prop, an action's argument -- so a key that is
|
|
96
|
+
* `$b64` behind any number of `$` travels with one `$` more and comes back
|
|
97
|
+
* with one fewer, on both sides: user data shaped like the marker is never
|
|
98
|
+
* read back as bytes.
|
|
99
|
+
*
|
|
100
|
+
* Integers past 2^53 cannot survive this codec; that is the cost the spec's
|
|
101
|
+
* "Why MessagePack" section is pointing at.
|
|
102
|
+
*/
|
|
103
|
+
export const jsonLinesCodec = {
|
|
104
|
+
name: 'json-lines',
|
|
105
|
+
encode: (value) => utf8Encoder.encode(JSON.stringify(liftBinary(value))),
|
|
106
|
+
decode: (bytes) => lowerBinary(JSON.parse(utf8Decoder.decode(bytes))),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export function createCodec(name) {
|
|
110
|
+
if (name === 'msgpack') return msgpackCodec;
|
|
111
|
+
if (name === 'json-lines') return jsonLinesCodec;
|
|
112
|
+
throw new FrameError(`Unknown codec '${name}'. Use 'msgpack' or 'json-lines'.`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `$b64` behind one or more `$`: the binary marker, or a user's key escaped from it. */
|
|
116
|
+
const MARKER_KEY = /^\$+b64$/;
|
|
117
|
+
const ESCAPED_MARKER_KEY = /^\$\$+b64$/;
|
|
118
|
+
|
|
119
|
+
function liftBinary(value) {
|
|
120
|
+
if (value instanceof Uint8Array) {
|
|
121
|
+
return { $b64: Buffer.from(value).toString('base64') };
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(value)) return value.map(liftBinary);
|
|
124
|
+
// Plain maps only: anything else (a Date) is JSON.stringify's to write.
|
|
125
|
+
// Defined, not assigned, for the reason lowerBinary gives.
|
|
126
|
+
if (isPlainObject(value)) {
|
|
127
|
+
const out = {};
|
|
128
|
+
for (const [k, v] of Object.entries(value)) {
|
|
129
|
+
Object.defineProperty(out, MARKER_KEY.test(k) ? `$${k}` : k, {
|
|
130
|
+
value: liftBinary(v),
|
|
131
|
+
enumerable: true,
|
|
132
|
+
writable: true,
|
|
133
|
+
configurable: true,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
if (typeof value === 'bigint') {
|
|
139
|
+
throw new FrameError('json-lines cannot carry an integer past 2^53; use msgpack.');
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function lowerBinary(value) {
|
|
145
|
+
if (Array.isArray(value)) return value.map(lowerBinary);
|
|
146
|
+
if (value && typeof value === 'object') {
|
|
147
|
+
const keys = Object.keys(value);
|
|
148
|
+
if (keys.length === 1 && keys[0] === '$b64' && typeof value.$b64 === 'string') {
|
|
149
|
+
return new Uint8Array(Buffer.from(value.$b64, 'base64'));
|
|
150
|
+
}
|
|
151
|
+
// Defined, not assigned: map keys are user data, and `out[k] =` with a
|
|
152
|
+
// `__proto__` key runs the prototype setter, so the key disappears and its
|
|
153
|
+
// value is inherited by every lookup on the map. A null-prototype object
|
|
154
|
+
// would also keep it, but React refuses to pass one to a client component.
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const [k, v] of Object.entries(value)) {
|
|
157
|
+
Object.defineProperty(out, ESCAPED_MARKER_KEY.test(k) ? k.slice(1) : k, {
|
|
158
|
+
value: lowerBinary(v),
|
|
159
|
+
enumerable: true,
|
|
160
|
+
writable: true,
|
|
161
|
+
configurable: true,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param {Record<string, unknown>} frame
|
|
171
|
+
* @param {number} [maxFrameBytes]
|
|
172
|
+
* @param {{encode: Function, decode: Function}} [codec]
|
|
173
|
+
* @returns {Uint8Array}
|
|
174
|
+
*/
|
|
175
|
+
export function encodeFrame(frame, maxFrameBytes = DEFAULT_MAX_FRAME_BYTES, codec = msgpackCodec) {
|
|
176
|
+
const body = codec.encode(frame);
|
|
177
|
+
if (body.length > maxFrameBytes) {
|
|
178
|
+
throw new FrameError(`Frame of ${body.length} bytes exceeds the ${maxFrameBytes}-byte cap.`);
|
|
179
|
+
}
|
|
180
|
+
const out = new Uint8Array(4 + body.length);
|
|
181
|
+
new DataView(out.buffer).setUint32(0, body.length, false);
|
|
182
|
+
out.set(body, 4);
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Accumulates socket reads and yields whole frames. A stream delivers arbitrary
|
|
188
|
+
* slices, so partial frames have to survive between chunks.
|
|
189
|
+
*
|
|
190
|
+
* Reads are kept as they came and joined once, when the frame they make up
|
|
191
|
+
* is whole. Merging each read into one buffer as it arrived copied all that
|
|
192
|
+
* had come so far every time: a 15 MB frame in 64 KB reads was seconds of
|
|
193
|
+
* copying. Here each byte is copied at most once, and a frame that sits in
|
|
194
|
+
* a single read is not copied at all.
|
|
195
|
+
*/
|
|
196
|
+
export class FrameReader {
|
|
197
|
+
/** @type {Uint8Array[]} */
|
|
198
|
+
#chunks = [];
|
|
199
|
+
#length = 0;
|
|
200
|
+
#max;
|
|
201
|
+
#codec;
|
|
202
|
+
|
|
203
|
+
constructor(maxFrameBytes = DEFAULT_MAX_FRAME_BYTES, codec = msgpackCodec) {
|
|
204
|
+
this.#max = maxFrameBytes;
|
|
205
|
+
this.#codec = codec;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {Uint8Array} chunk
|
|
210
|
+
* @returns {Record<string, unknown>[]}
|
|
211
|
+
*/
|
|
212
|
+
push(chunk) {
|
|
213
|
+
if (chunk.length > 0) {
|
|
214
|
+
this.#chunks.push(chunk);
|
|
215
|
+
this.#length += chunk.length;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const frames = [];
|
|
219
|
+
while (this.#length >= 4) {
|
|
220
|
+
const header = this.#peek(4);
|
|
221
|
+
const length = new DataView(header.buffer, header.byteOffset, 4).getUint32(0, false);
|
|
222
|
+
if (length > this.#max) {
|
|
223
|
+
throw new FrameError(`Incoming frame of ${length} bytes exceeds the ${this.#max}-byte cap.`);
|
|
224
|
+
}
|
|
225
|
+
if (4 + length > this.#length) break;
|
|
226
|
+
|
|
227
|
+
const body = this.#take(4 + length).subarray(4);
|
|
228
|
+
const value = this.#codec.decode(body);
|
|
229
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
230
|
+
throw new FrameError('Frame body must decode to a map.');
|
|
231
|
+
}
|
|
232
|
+
frames.push(/** @type {Record<string, unknown>} */ (value));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return frames;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The first `n` buffered bytes, left in the buffer. Copied only when they
|
|
240
|
+
* span reads, which for a length prefix is four bytes.
|
|
241
|
+
*
|
|
242
|
+
* @param {number} n
|
|
243
|
+
* @returns {Uint8Array}
|
|
244
|
+
*/
|
|
245
|
+
#peek(n) {
|
|
246
|
+
const first = this.#chunks[0];
|
|
247
|
+
if (first.length >= n) return first.subarray(0, n);
|
|
248
|
+
const out = new Uint8Array(n);
|
|
249
|
+
let filled = 0;
|
|
250
|
+
for (const chunk of this.#chunks) {
|
|
251
|
+
const part = chunk.subarray(0, n - filled);
|
|
252
|
+
out.set(part, filled);
|
|
253
|
+
filled += part.length;
|
|
254
|
+
if (filled === n) break;
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The first `n` buffered bytes, taken out of the buffer: a view of the
|
|
261
|
+
* first read when they all sit in it, else one allocation filled from
|
|
262
|
+
* the reads they span.
|
|
263
|
+
*
|
|
264
|
+
* @param {number} n
|
|
265
|
+
* @returns {Uint8Array}
|
|
266
|
+
*/
|
|
267
|
+
#take(n) {
|
|
268
|
+
this.#length -= n;
|
|
269
|
+
const first = this.#chunks[0];
|
|
270
|
+
if (first.length >= n) {
|
|
271
|
+
if (first.length === n) this.#chunks.shift();
|
|
272
|
+
else this.#chunks[0] = first.subarray(n);
|
|
273
|
+
return first.subarray(0, n);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const out = new Uint8Array(n);
|
|
277
|
+
let filled = 0;
|
|
278
|
+
while (filled < n) {
|
|
279
|
+
const chunk = /** @type {Uint8Array} */ (this.#chunks[0]);
|
|
280
|
+
const wanted = n - filled;
|
|
281
|
+
if (chunk.length <= wanted) {
|
|
282
|
+
out.set(chunk, filled);
|
|
283
|
+
filled += chunk.length;
|
|
284
|
+
this.#chunks.shift();
|
|
285
|
+
} else {
|
|
286
|
+
out.set(chunk.subarray(0, wanted), filled);
|
|
287
|
+
filled += wanted;
|
|
288
|
+
this.#chunks[0] = chunk.subarray(wanted);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
get pending() {
|
|
295
|
+
return this.#length;
|
|
296
|
+
}
|
|
297
|
+
}
|
package/src/dev-app.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The app entry the renderer imports in dev: four functions that render
|
|
2
|
+
// with whatever the Vite dev server currently has for the app's rsc entry.
|
|
3
|
+
//
|
|
4
|
+
// createRenderer() imports an entry module once and calls it for every
|
|
5
|
+
// render. In dev that module must not be fixed at start: the point is that
|
|
6
|
+
// an edit shows up on the next render. So the renderer is handed this module
|
|
7
|
+
// instead, and each call asks the dev server's module runner for the entry
|
|
8
|
+
// -- the cached module until a file under it changes, then the new one.
|
|
9
|
+
|
|
10
|
+
/** @type {(() => Promise<Record<string, Function>>) | null} */
|
|
11
|
+
let load = null;
|
|
12
|
+
|
|
13
|
+
/** Sets where the entry comes from: dev-server.js, once Vite is up. */
|
|
14
|
+
export function useDevEntry(loader) {
|
|
15
|
+
load = loader;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function entry() {
|
|
19
|
+
if (load === null) throw new Error('The dev entry was imported before the dev server started.');
|
|
20
|
+
return load();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function renderHtml(...args) {
|
|
24
|
+
return (await entry()).renderHtml(...args);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function renderRsc(...args) {
|
|
28
|
+
return (await entry()).renderRsc(...args);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function renderFragment(...args) {
|
|
32
|
+
const app = await entry();
|
|
33
|
+
if (typeof app.renderFragment !== 'function') {
|
|
34
|
+
throw new Error('This app has no renderFragment entry; it cannot serve deferred components.');
|
|
35
|
+
}
|
|
36
|
+
return app.renderFragment(...args);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function renderMail(...args) {
|
|
40
|
+
const app = await entry();
|
|
41
|
+
if (typeof app.renderMail !== 'function') {
|
|
42
|
+
throw new Error('This app has no renderMail entry; it cannot render email.');
|
|
43
|
+
}
|
|
44
|
+
return app.renderMail(...args);
|
|
45
|
+
}
|
package/src/dev-guard.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The one check dev mode makes before anything of it is loaded: that this is
|
|
2
|
+
// not production. Its own module, importing nothing, so the refusal happens
|
|
3
|
+
// before Vite -- or any dependency of the app -- has run a line.
|
|
4
|
+
|
|
5
|
+
/** The variables any of which, saying `production`, rule the dev server out. Mirrors Tinvar\Support\DevMode. */
|
|
6
|
+
export const PRODUCTION_MARKERS = ['NODE_ENV', 'TINVAR_ENV', 'APP_ENV'];
|
|
7
|
+
|
|
8
|
+
export class DevServerRefused extends Error {}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Refuses, loudly, anywhere that says production.
|
|
12
|
+
*
|
|
13
|
+
* The dev server runs unsandboxed, serves source over HTTP and shows stacks
|
|
14
|
+
* to whoever loads a failing page. A NODE_ENV or TINVAR_ENV left saying
|
|
15
|
+
* production is the one signal there is that this is the wrong place, so
|
|
16
|
+
* any of them saying so stops it.
|
|
17
|
+
*
|
|
18
|
+
* @param {Record<string, string | undefined>} env
|
|
19
|
+
*/
|
|
20
|
+
export function assertDevelopment(env = process.env) {
|
|
21
|
+
for (const name of PRODUCTION_MARKERS) {
|
|
22
|
+
if (
|
|
23
|
+
String(env[name] ?? '')
|
|
24
|
+
.trim()
|
|
25
|
+
.toLowerCase() === 'production'
|
|
26
|
+
) {
|
|
27
|
+
throw new DevServerRefused(
|
|
28
|
+
`The dev server is refused: ${name}=production. It runs without the renderer's sandbox and serves ` +
|
|
29
|
+
'source to the browser, so it never runs in production. Use the built renderer there.',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|