@vesk/adapter 0.2.67 → 0.2.69
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/dist/client-bundle.d.ts.map +1 -1
- package/dist/client-bundle.js +68 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/prod-server.d.ts +4 -4
- package/dist/prod-server.d.ts.map +1 -1
- package/dist/prod-server.js +118 -22
- package/dist/response-pipeline.d.ts +54 -0
- package/dist/response-pipeline.d.ts.map +1 -0
- package/dist/response-pipeline.js +422 -0
- package/dist/server-only-plugin.d.ts +77 -0
- package/dist/server-only-plugin.d.ts.map +1 -0
- package/dist/server-only-plugin.js +233 -0
- package/dist/server-only.d.ts +99 -0
- package/dist/server-only.d.ts.map +1 -0
- package/dist/server-only.js +283 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-level response pipeline: conditional GET (ETag → 304) and
|
|
3
|
+
* content-encoding negotiation (brotli / gzip).
|
|
4
|
+
*
|
|
5
|
+
* Both live in one wrapper because they share the same constraint: the decision
|
|
6
|
+
* can only be made once the whole body is known. `res.writeHead` sends headers
|
|
7
|
+
* to the socket immediately, so a wrapper that compressed or tagged at
|
|
8
|
+
* `writeHead` time would either have to buffer every response in the server (a
|
|
9
|
+
* second copy of every page) or get the answer wrong for streamed bodies.
|
|
10
|
+
* Instead this captures `writeHead` and defers it until the body is either
|
|
11
|
+
* complete (`end`) or discovered to be a stream (`write`).
|
|
12
|
+
*
|
|
13
|
+
* Consequences worth knowing:
|
|
14
|
+
*
|
|
15
|
+
* - **One install covers every call site.** The prod server writes headers in
|
|
16
|
+
* ~15 places; wrapping `res` once means a new one cannot be forgotten. A
|
|
17
|
+
* static file, an SSR page, a JSON API body and a middleware short-circuit
|
|
18
|
+
* all pass through the same path.
|
|
19
|
+
* - **The first `write()` opts the response out of everything.** A stream
|
|
20
|
+
* (SSE, a chunked `renderPageStream`) cannot be re-encoded after the first
|
|
21
|
+
* bytes are out, so `write()` flushes the captured headers verbatim and every
|
|
22
|
+
* later `write`/`end` is a passthrough. No `Content-Encoding` is ever
|
|
23
|
+
* announced for a body that was not encoded.
|
|
24
|
+
* - **The wrapper composes with header decorators.** Installing *after* another
|
|
25
|
+
* `writeHead` wrapper means this layer's captured reference IS that wrapper,
|
|
26
|
+
* so security headers and per-request overrides are still applied — at flush
|
|
27
|
+
* time, exactly once.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately not here: range requests, `Last-Modified`/`If-Modified-Since`
|
|
30
|
+
* (the ETag covers the same revalidation need and cannot disagree with itself),
|
|
31
|
+
* and compression of already-encoded bodies.
|
|
32
|
+
*/
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import { brotliCompress, constants as zlibConstants, createBrotliCompress, createGzip, gzip, } from 'node:zlib';
|
|
35
|
+
import { promisify } from 'node:util';
|
|
36
|
+
const brotliCompressAsync = promisify(brotliCompress);
|
|
37
|
+
const gzipAsync = promisify(gzip);
|
|
38
|
+
/**
|
|
39
|
+
* Pick a content coding from `Accept-Encoding`.
|
|
40
|
+
*
|
|
41
|
+
* Quality values are honoured because a client is entitled to say "I accept
|
|
42
|
+
* gzip but not brotli" (`br;q=0`) or "gzip only at half priority". `*` applies
|
|
43
|
+
* to codings the header does not name. Ties go to brotli: every browser that
|
|
44
|
+
* sends both lists them equally, and brotli is smaller.
|
|
45
|
+
*/
|
|
46
|
+
export function negotiateEncoding(header) {
|
|
47
|
+
if (!header)
|
|
48
|
+
return null;
|
|
49
|
+
// `header` is `string | string[]` per Node's typings, but a duplicated
|
|
50
|
+
// header arrives joined by ", " in practice; both shapes fold to one list.
|
|
51
|
+
const value = Array.isArray(header) ? header.join(',') : header;
|
|
52
|
+
const q = {};
|
|
53
|
+
for (const part of value.split(',')) {
|
|
54
|
+
const seg = part.trim();
|
|
55
|
+
if (!seg)
|
|
56
|
+
continue;
|
|
57
|
+
const semi = seg.indexOf(';');
|
|
58
|
+
const token = (semi === -1 ? seg : seg.slice(0, semi)).trim().toLowerCase();
|
|
59
|
+
if (!token)
|
|
60
|
+
continue;
|
|
61
|
+
let weight = 1;
|
|
62
|
+
if (semi !== -1) {
|
|
63
|
+
for (const param of seg.slice(semi + 1).split(';')) {
|
|
64
|
+
const eq = param.indexOf('=');
|
|
65
|
+
if (eq === -1)
|
|
66
|
+
continue;
|
|
67
|
+
if (param.slice(0, eq).trim().toLowerCase() !== 'q')
|
|
68
|
+
continue;
|
|
69
|
+
const parsed = Number.parseFloat(param.slice(eq + 1).trim());
|
|
70
|
+
if (Number.isFinite(parsed))
|
|
71
|
+
weight = parsed;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// First occurrence wins, per RFC 9110 — a client cannot raise its own
|
|
75
|
+
// preference by listing a coding twice.
|
|
76
|
+
if (!(token in q))
|
|
77
|
+
q[token] = weight;
|
|
78
|
+
}
|
|
79
|
+
const star = q['*'];
|
|
80
|
+
const br = q['br'] !== undefined ? q['br'] : star;
|
|
81
|
+
const gz = q['gzip'] !== undefined ? q['gzip'] : star;
|
|
82
|
+
if (br !== undefined && br > 0 && (gz === undefined || gz <= 0 || br >= gz))
|
|
83
|
+
return 'br';
|
|
84
|
+
if (gz !== undefined && gz > 0)
|
|
85
|
+
return 'gzip';
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Would compressing this content type actually pay?
|
|
90
|
+
*
|
|
91
|
+
* The rule is "text-like, not already compressed". Images, fonts, video and
|
|
92
|
+
* archives are already compressed — a second pass costs CPU and usually makes
|
|
93
|
+
* the body *larger*, which is why the size check in `installResponsePipeline`
|
|
94
|
+
* is kept as a second line of defence rather than the only one.
|
|
95
|
+
*/
|
|
96
|
+
export function isCompressibleType(contentType) {
|
|
97
|
+
if (!contentType)
|
|
98
|
+
return false;
|
|
99
|
+
const type = (contentType.split(';')[0] || '').trim().toLowerCase();
|
|
100
|
+
if (!type)
|
|
101
|
+
return false;
|
|
102
|
+
if (type.startsWith('text/'))
|
|
103
|
+
return true;
|
|
104
|
+
if (type === 'image/svg+xml' || type === 'image/x-icon' || type === 'image/bmp')
|
|
105
|
+
return true;
|
|
106
|
+
if (type.endsWith('+json') || type.endsWith('+xml'))
|
|
107
|
+
return true;
|
|
108
|
+
switch (type) {
|
|
109
|
+
case 'application/javascript':
|
|
110
|
+
case 'application/x-javascript':
|
|
111
|
+
case 'application/ecmascript':
|
|
112
|
+
case 'application/json':
|
|
113
|
+
case 'application/manifest+json':
|
|
114
|
+
case 'application/ld+json':
|
|
115
|
+
case 'application/graphql':
|
|
116
|
+
case 'application/x-ndjson':
|
|
117
|
+
case 'application/xml':
|
|
118
|
+
case 'application/xhtml+xml':
|
|
119
|
+
case 'application/rss+xml':
|
|
120
|
+
case 'application/atom+xml':
|
|
121
|
+
return true;
|
|
122
|
+
default:
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A validator for these exact bytes.
|
|
128
|
+
*
|
|
129
|
+
* sha1 rather than sha256 because this is a cache validator, not a security
|
|
130
|
+
* primitive (RFC 9110 requires "a function of the representation", and nginx
|
|
131
|
+
* ships md5 here), and every request hashes its body. Truncated to 20 bytes of
|
|
132
|
+
* base64 — 27 characters — which is far beyond collision range for a validator.
|
|
133
|
+
*/
|
|
134
|
+
export function etagFor(body) {
|
|
135
|
+
return `"${createHash('sha1').update(body).digest('base64').slice(0, 27)}"`;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* `If-None-Match` satisfaction, weak comparison as RFC 9110 §13.1.2 requires
|
|
139
|
+
* for GET. Weak comparison ignores the `W/` prefix on either side, which is what
|
|
140
|
+
* makes a `W/"x"` from a proxy match our strong `"x"`.
|
|
141
|
+
*/
|
|
142
|
+
export function ifNoneMatchSatisfied(header, etag) {
|
|
143
|
+
if (!header)
|
|
144
|
+
return false;
|
|
145
|
+
const value = Array.isArray(header) ? header.join(',') : header;
|
|
146
|
+
const candidates = value.split(',').map(s => s.trim()).filter(Boolean);
|
|
147
|
+
if (candidates.includes('*'))
|
|
148
|
+
return true;
|
|
149
|
+
const strip = (s) => (s.startsWith('W/') ? s.slice(2) : s).trim();
|
|
150
|
+
const target = strip(etag);
|
|
151
|
+
for (const candidate of candidates) {
|
|
152
|
+
if (strip(candidate) === target)
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
function getHeader(headers, name) {
|
|
158
|
+
const wanted = name.toLowerCase();
|
|
159
|
+
for (const key of Object.keys(headers)) {
|
|
160
|
+
if (key.toLowerCase() === wanted) {
|
|
161
|
+
const v = headers[key];
|
|
162
|
+
if (v === undefined)
|
|
163
|
+
return undefined;
|
|
164
|
+
return Array.isArray(v) ? v.join(', ') : String(v);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
function hasNoStore(cacheControl) {
|
|
170
|
+
if (!cacheControl)
|
|
171
|
+
return false;
|
|
172
|
+
return cacheControl.split(',').some(part => part.trim().toLowerCase() === 'no-store');
|
|
173
|
+
}
|
|
174
|
+
/** Add `value` to `Vary` without duplicating an entry already there. */
|
|
175
|
+
function withVary(current, value) {
|
|
176
|
+
if (!current)
|
|
177
|
+
return value;
|
|
178
|
+
const existing = current.split(',').map(s => s.trim().toLowerCase());
|
|
179
|
+
if (existing.includes(value.toLowerCase()))
|
|
180
|
+
return current;
|
|
181
|
+
if (existing.includes('*'))
|
|
182
|
+
return current;
|
|
183
|
+
return `${current}, ${value}`;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Wrap `res` so every buffered response is tagged with an ETag, revalidated
|
|
187
|
+
* against `If-None-Match`, and encoded — in that order, because a 304 must be
|
|
188
|
+
* decided on the *unencoded* bytes.
|
|
189
|
+
*/
|
|
190
|
+
export function installResponsePipeline(res, req, options = {}) {
|
|
191
|
+
const compressEnabled = options.compress !== false;
|
|
192
|
+
const etagEnabled = options.etag !== false;
|
|
193
|
+
const threshold = options.compressThreshold ?? 1024;
|
|
194
|
+
const brotliQuality = options.brotliQuality ?? 5;
|
|
195
|
+
const gzipLevel = options.gzipLevel ?? 6;
|
|
196
|
+
const realWriteHead = res.writeHead.bind(res);
|
|
197
|
+
const realWrite = res.write.bind(res);
|
|
198
|
+
const realEnd = res.end.bind(res);
|
|
199
|
+
const realFlushHeaders = res.flushHeaders.bind(res);
|
|
200
|
+
let pendingStatus = 200;
|
|
201
|
+
let pendingHeaders = {};
|
|
202
|
+
let flushed = false;
|
|
203
|
+
let streaming = false;
|
|
204
|
+
let streamCompressor = null;
|
|
205
|
+
/** Emit the captured status+headers exactly once, merging in `extra`. */
|
|
206
|
+
function flush(extra) {
|
|
207
|
+
if (flushed)
|
|
208
|
+
return;
|
|
209
|
+
flushed = true;
|
|
210
|
+
const headers = {};
|
|
211
|
+
for (const [key, value] of Object.entries(pendingHeaders)) {
|
|
212
|
+
if (value !== undefined)
|
|
213
|
+
headers[key] = value;
|
|
214
|
+
}
|
|
215
|
+
for (const [key, value] of Object.entries(extra || {})) {
|
|
216
|
+
if (value === undefined)
|
|
217
|
+
delete headers[key];
|
|
218
|
+
else
|
|
219
|
+
headers[key] = value;
|
|
220
|
+
}
|
|
221
|
+
realWriteHead(pendingStatus, headers);
|
|
222
|
+
}
|
|
223
|
+
res.writeHead = ((statusCode, headers) => {
|
|
224
|
+
if (flushed || streaming)
|
|
225
|
+
return realWriteHead(statusCode, headers);
|
|
226
|
+
pendingStatus = statusCode;
|
|
227
|
+
pendingHeaders = { ...(headers || {}) };
|
|
228
|
+
return res;
|
|
229
|
+
});
|
|
230
|
+
/**
|
|
231
|
+
* Would this response's bytes differ if the client sent a different
|
|
232
|
+
* `Accept-Encoding`? If yes it must be announced in `Vary` whether or not
|
|
233
|
+
* an encoding was actually applied — a cache that stored the identity copy
|
|
234
|
+
* without seeing `Vary` would then serve it to a client that can decode br.
|
|
235
|
+
*/
|
|
236
|
+
function encodingIsVariable() {
|
|
237
|
+
return compressEnabled
|
|
238
|
+
&& !getHeader(pendingHeaders, 'content-encoding')
|
|
239
|
+
&& isCompressibleType(getHeader(pendingHeaders, 'content-type'));
|
|
240
|
+
}
|
|
241
|
+
function startStreamCompression(encoding) {
|
|
242
|
+
const compressor = encoding === 'br'
|
|
243
|
+
? createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: brotliQuality } })
|
|
244
|
+
: createGzip({ level: gzipLevel });
|
|
245
|
+
// The compressor owns the socket from here: its output is what the client
|
|
246
|
+
// receives, and its `end` is what actually finishes the response. This is
|
|
247
|
+
// what lets a *streamed* body still be encoded — buffering it to compute a
|
|
248
|
+
// validator would defeat the streaming in the first place.
|
|
249
|
+
compressor.on('data', (chunk) => { realWrite(chunk); });
|
|
250
|
+
compressor.on('end', () => { realEnd(); });
|
|
251
|
+
compressor.on('error', () => {
|
|
252
|
+
// A compressor failure must still produce a complete response object;
|
|
253
|
+
// the bytes already written are the unencoded prefix, which is why the
|
|
254
|
+
// socket has to be closed rather than left open.
|
|
255
|
+
try {
|
|
256
|
+
realEnd();
|
|
257
|
+
}
|
|
258
|
+
catch { /* already finished */ }
|
|
259
|
+
});
|
|
260
|
+
streamCompressor = compressor;
|
|
261
|
+
}
|
|
262
|
+
res.write = ((chunk, encoding) => {
|
|
263
|
+
if (!flushed && !streaming) {
|
|
264
|
+
streaming = true;
|
|
265
|
+
const vary = withVary(getHeader(pendingHeaders, 'vary'), 'Accept-Encoding');
|
|
266
|
+
const encodingChosen = encodingIsVariable() ? negotiateEncoding(req.headers['accept-encoding']) : null;
|
|
267
|
+
if (encodingChosen) {
|
|
268
|
+
startStreamCompression(encodingChosen);
|
|
269
|
+
// Content-Length cannot survive: the encoded stream's length is
|
|
270
|
+
// unknown until it ends, and a wrong one truncates the response.
|
|
271
|
+
flush({ 'Content-Encoding': encodingChosen, Vary: vary, 'Content-Length': undefined });
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
flush({ Vary: encodingIsVariable() ? vary : undefined });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (streamCompressor) {
|
|
278
|
+
streamCompressor.write(toBuffer(chunk, encoding) || Buffer.alloc(0));
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
return typeof encoding === 'undefined'
|
|
282
|
+
? realWrite(chunk)
|
|
283
|
+
: realWrite(chunk, encoding);
|
|
284
|
+
});
|
|
285
|
+
res.flushHeaders = (() => {
|
|
286
|
+
if (!flushed) {
|
|
287
|
+
streaming = true;
|
|
288
|
+
flush();
|
|
289
|
+
}
|
|
290
|
+
realFlushHeaders.call(res);
|
|
291
|
+
});
|
|
292
|
+
function toBuffer(chunk, encoding) {
|
|
293
|
+
if (chunk === undefined || chunk === null)
|
|
294
|
+
return null;
|
|
295
|
+
if (Buffer.isBuffer(chunk))
|
|
296
|
+
return chunk;
|
|
297
|
+
if (typeof chunk === 'string') {
|
|
298
|
+
return Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8');
|
|
299
|
+
}
|
|
300
|
+
if (chunk instanceof Uint8Array)
|
|
301
|
+
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
function finish(body, endArgs) {
|
|
305
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
306
|
+
// Nothing to compress, tag or revalidate. Empty bodies include 204 and
|
|
307
|
+
// 304 responses, which must not carry a Content-Length anyway.
|
|
308
|
+
if (!body || body.length === 0 || streaming || flushed) {
|
|
309
|
+
flush();
|
|
310
|
+
realEnd(...endArgs);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const cacheable = method === 'GET' || method === 'HEAD';
|
|
314
|
+
const existingEtag = getHeader(pendingHeaders, 'etag');
|
|
315
|
+
// `no-store` means the bytes are not reusable, so a validator would only
|
|
316
|
+
// produce a 304 that hands the client back a body the server has already
|
|
317
|
+
// consumed. This is load-bearing for the single-use `/ssr-data.js` and
|
|
318
|
+
// `/store-init.js` payloads, which are deleted as they are read.
|
|
319
|
+
const canValidate = cacheable && pendingStatus === 200 && !hasNoStore(getHeader(pendingHeaders, 'cache-control'));
|
|
320
|
+
if (!canValidate) {
|
|
321
|
+
maybeCompress(body, endArgs);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const etag = existingEtag || (etagEnabled ? etagFor(body) : undefined);
|
|
325
|
+
if (etag && ifNoneMatchSatisfied(req.headers['if-none-match'], etag)) {
|
|
326
|
+
// A 304 carries the validator and the caching directives and nothing
|
|
327
|
+
// else — no body, and no Content-Type/Content-Length describing one.
|
|
328
|
+
const headers304 = { ETag: etag };
|
|
329
|
+
const cc = getHeader(pendingHeaders, 'cache-control');
|
|
330
|
+
if (cc)
|
|
331
|
+
headers304['Cache-Control'] = cc;
|
|
332
|
+
headers304.Vary = withVary(getHeader(pendingHeaders, 'vary'), 'Accept-Encoding');
|
|
333
|
+
flushed = true;
|
|
334
|
+
realWriteHead(304, headers304);
|
|
335
|
+
realEnd();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (etag && !existingEtag) {
|
|
339
|
+
pendingHeaders = { ...pendingHeaders, ETag: etag };
|
|
340
|
+
}
|
|
341
|
+
maybeCompress(body, endArgs);
|
|
342
|
+
}
|
|
343
|
+
function maybeCompress(body, endArgs) {
|
|
344
|
+
// Below the threshold the bytes are identical whatever the client asks
|
|
345
|
+
// for, so no `Vary` is owed — announcing one would fragment caches for
|
|
346
|
+
// nothing. At or above it, the representation does vary by encoding and
|
|
347
|
+
// `Vary` is announced whether or not a coding was chosen.
|
|
348
|
+
if (compressEnabled
|
|
349
|
+
&& body.length < threshold
|
|
350
|
+
|| !encodingIsVariable()) {
|
|
351
|
+
flush();
|
|
352
|
+
realEnd(...endArgs);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const encoding = negotiateEncoding(req.headers['accept-encoding']);
|
|
356
|
+
if (!encoding) {
|
|
357
|
+
flush({ Vary: withVary(getHeader(pendingHeaders, 'vary'), 'Accept-Encoding') });
|
|
358
|
+
realEnd(...endArgs);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const compress = encoding === 'br' ? brotliCompressAsync : gzipAsync;
|
|
362
|
+
const params = encoding === 'br'
|
|
363
|
+
? { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: brotliQuality } }
|
|
364
|
+
: { level: gzipLevel };
|
|
365
|
+
void Promise.resolve()
|
|
366
|
+
.then(() => compress(body, params))
|
|
367
|
+
.then((encoded) => {
|
|
368
|
+
// A body that did not get smaller is not worth the encoding: it
|
|
369
|
+
// costs the client CPU on every fetch for nothing.
|
|
370
|
+
if (flushed || streaming) {
|
|
371
|
+
flush();
|
|
372
|
+
realEnd(...endArgs);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (encoded.length >= body.length) {
|
|
376
|
+
flush({ Vary: withVary(getHeader(pendingHeaders, 'vary'), 'Accept-Encoding') });
|
|
377
|
+
realEnd(...endArgs);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
flush({
|
|
381
|
+
'Content-Encoding': encoding,
|
|
382
|
+
Vary: withVary(getHeader(pendingHeaders, 'vary'), 'Accept-Encoding'),
|
|
383
|
+
'Content-Length': String(encoded.length),
|
|
384
|
+
});
|
|
385
|
+
realEnd(encoded);
|
|
386
|
+
})
|
|
387
|
+
.catch(() => {
|
|
388
|
+
// Compression must never be the reason a response is lost.
|
|
389
|
+
flush({ Vary: withVary(getHeader(pendingHeaders, 'vary'), 'Accept-Encoding') });
|
|
390
|
+
realEnd(...endArgs);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
res.end = ((chunk, encoding, callback) => {
|
|
394
|
+
if (streamCompressor) {
|
|
395
|
+
// Ending the compressor is what ends the response — it flushes its
|
|
396
|
+
// own buffered tail first, so the client gets a complete stream.
|
|
397
|
+
if (typeof callback === 'function')
|
|
398
|
+
res.once('finish', callback);
|
|
399
|
+
const tail = toBuffer(chunk, encoding);
|
|
400
|
+
if (tail)
|
|
401
|
+
streamCompressor.end(tail);
|
|
402
|
+
else
|
|
403
|
+
streamCompressor.end();
|
|
404
|
+
return res;
|
|
405
|
+
}
|
|
406
|
+
if (streaming || flushed) {
|
|
407
|
+
return realEnd(chunk, encoding, callback);
|
|
408
|
+
}
|
|
409
|
+
// Node's end() has four shapes — (), (cb), (data), (data, enc),
|
|
410
|
+
// (data, cb), (data, enc, cb). Forward only the arguments that were
|
|
411
|
+
// actually supplied: `end(body, undefined, undefined)` is NOT the same
|
|
412
|
+
// as `end(body)` for a callback-taking overload.
|
|
413
|
+
const endArgs = chunk === undefined
|
|
414
|
+
? (callback === undefined ? [] : [callback])
|
|
415
|
+
: encoding === undefined
|
|
416
|
+
? (callback === undefined ? [chunk] : [chunk, callback])
|
|
417
|
+
: (callback === undefined ? [chunk, encoding] : [chunk, encoding, callback]);
|
|
418
|
+
const body = toBuffer(chunk, encoding);
|
|
419
|
+
finish(body, endArgs);
|
|
420
|
+
return res;
|
|
421
|
+
});
|
|
422
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface ServerOnlyHit {
|
|
2
|
+
specifier: string;
|
|
3
|
+
binding: string;
|
|
4
|
+
importer: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ServerOnlyPluginOptions {
|
|
7
|
+
/** One call per (specifier, binding) replaced by a throwing stub. */
|
|
8
|
+
onStub?: (hit: ServerOnlyHit) => void;
|
|
9
|
+
/** One call per TCP driver seen while bundling. Never stubbed. */
|
|
10
|
+
onDriver?: (hit: {
|
|
11
|
+
specifier: string;
|
|
12
|
+
importer: string;
|
|
13
|
+
}) => void;
|
|
14
|
+
/**
|
|
15
|
+
* One call per binding used at module-evaluation position, where a stub would
|
|
16
|
+
* throw while the chunk loads rather than at a call site. Reported, never
|
|
17
|
+
* stubbed — this is a build error.
|
|
18
|
+
*/
|
|
19
|
+
onEager?: (hit: {
|
|
20
|
+
specifier: string;
|
|
21
|
+
bindings: string[];
|
|
22
|
+
importer: string;
|
|
23
|
+
}) => void;
|
|
24
|
+
}
|
|
25
|
+
/** The slice of esbuild's plugin API this uses, declared so the module needs no esbuild types. */
|
|
26
|
+
export interface EsbuildPluginLike {
|
|
27
|
+
name: string;
|
|
28
|
+
setup: (build: {
|
|
29
|
+
onResolve: (opts: {
|
|
30
|
+
filter: RegExp;
|
|
31
|
+
namespace?: string;
|
|
32
|
+
}, cb: (args: {
|
|
33
|
+
path: string;
|
|
34
|
+
importer: string;
|
|
35
|
+
}) => unknown) => void;
|
|
36
|
+
onLoad: (opts: {
|
|
37
|
+
filter: RegExp;
|
|
38
|
+
namespace: string;
|
|
39
|
+
}, cb: (args: {
|
|
40
|
+
path: string;
|
|
41
|
+
}) => {
|
|
42
|
+
contents: string;
|
|
43
|
+
loader: string;
|
|
44
|
+
}) => void;
|
|
45
|
+
}) => void;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The export names one file asks for from `specifier`.
|
|
49
|
+
*
|
|
50
|
+
* Read from the importer's own source rather than guessed: a stub declaring the
|
|
51
|
+
* wrong set of names is either an unresolvable-import build failure or, worse,
|
|
52
|
+
* a silently undefined binding.
|
|
53
|
+
*/
|
|
54
|
+
export declare function importedNamesFor(code: string, specifier: string): string[];
|
|
55
|
+
/** The shared throwing-proxy factory, emitted once per stubbed package. */
|
|
56
|
+
export declare function stubFactorySource(specifier: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* Is `name` used at module-evaluation position — i.e. NOT inside a function?
|
|
59
|
+
*
|
|
60
|
+
* This is the difference between a stub that is fine and one that is a trap.
|
|
61
|
+
*
|
|
62
|
+
* export function allRows() { return orm.getAll(); } // never runs in the
|
|
63
|
+
* // browser → stub is fine
|
|
64
|
+
* export const orm = createORM({}); // runs the moment the
|
|
65
|
+
* // chunk evaluates
|
|
66
|
+
*
|
|
67
|
+
* The second form cannot be rescued by any amount of stubbing: the browser
|
|
68
|
+
* evaluates it while loading the chunk, so the page dies before a single
|
|
69
|
+
* component exists. A build error is the honest answer there, and it is
|
|
70
|
+
* decidable from the source alone — no types, no dataflow.
|
|
71
|
+
*
|
|
72
|
+
* Non-computed member keys (`obj.orm`) and object/class property keys are not
|
|
73
|
+
* references, for the same reason the reactivity diagnostics skip them.
|
|
74
|
+
*/
|
|
75
|
+
export declare function referencedAtTopLevel(code: string, name: string): boolean;
|
|
76
|
+
export declare function serverOnlyPlugin(options?: ServerOnlyPluginOptions): EsbuildPluginLike;
|
|
77
|
+
//# sourceMappingURL=server-only-plugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-only-plugin.d.ts","sourceRoot":"","sources":["../src/server-only-plugin.ts"],"names":[],"mappings":"AA0BA,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,uBAAuB;IACtC,qEAAqE;IACrE,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,aAAa,KAAK,IAAI,CAAC;IACtC,kEAAkE;IAClE,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAClE;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACtF;AAED,kGAAkG;AAClG,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,KAAK,EAAE;QACb,SAAS,EAAE,CACT,IAAI,EAAE;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,SAAS,CAAC,EAAE,MAAM,CAAA;SAAE,EAC5C,EAAE,EAAE,CAAC,IAAI,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAA;SAAE,KAAK,OAAO,KACtD,IAAI,CAAC;QACV,MAAM,EAAE,CACN,IAAI,EAAE;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,MAAM,CAAA;SAAE,EAC3C,EAAE,EAAE,CAAC,IAAI,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,KAAK;YAAE,QAAQ,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAA;SAAE,KACjE,IAAI,CAAC;KACX,KAAK,IAAI,CAAC;CACZ;AAMD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CA+C1E;AAED,2EAA2E;AAC3E,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAmB3D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAoDxE;AAED,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,uBAA4B,GAAG,iBAAiB,CAoDzF"}
|