@chidchanun/bcp 0.1.5 → 0.1.7

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,559 @@
1
+ import * as http from "node:http";
2
+ import {
3
+ brotliCompressSync,
4
+ brotliDecompressSync,
5
+ deflateSync,
6
+ gunzipSync,
7
+ gzipSync,
8
+ inflateSync,
9
+ } from "node:zlib";
10
+
11
+ import {
12
+ canInlineStylesheet,
13
+ inlineSmallStylesheet,
14
+ } from "./critical-css.js";
15
+
16
+ export interface CriticalCssProxyOptions {
17
+ port: number;
18
+ hostname: string;
19
+ upstreamPort: number;
20
+ upstreamHostname: string;
21
+ cssFile: string;
22
+ }
23
+
24
+ type SupportedContentEncoding =
25
+ | "identity"
26
+ | "gzip"
27
+ | "br"
28
+ | "deflate";
29
+
30
+ export function createCriticalCssProxy(
31
+ options: CriticalCssProxyOptions
32
+ ) {
33
+ const server =
34
+ http.createServer(
35
+ (
36
+ req,
37
+ res
38
+ ) => {
39
+ const upstream =
40
+ http.request({
41
+ hostname:
42
+ options.upstreamHostname,
43
+ port:
44
+ options.upstreamPort,
45
+ method:
46
+ req.method ??
47
+ "GET",
48
+ path:
49
+ req.url ??
50
+ "/",
51
+ headers: {
52
+ ...req.headers,
53
+ host:
54
+ req.headers.host ??
55
+ `${options.hostname}:${options.port}`,
56
+ },
57
+ });
58
+
59
+ upstream.on(
60
+ "response",
61
+ (upstreamResponse) => {
62
+ const encoding =
63
+ getSupportedContentEncoding(
64
+ upstreamResponse
65
+ );
66
+
67
+ if (
68
+ !canInlineStylesheet(
69
+ options.cssFile
70
+ ) ||
71
+ !encoding ||
72
+ !shouldTransformHtml(
73
+ req,
74
+ upstreamResponse
75
+ )
76
+ ) {
77
+ writeUpstreamHead(
78
+ res,
79
+ upstreamResponse
80
+ );
81
+ upstreamResponse.pipe(
82
+ res
83
+ );
84
+ return;
85
+ }
86
+
87
+ void transformHtmlResponse(
88
+ res,
89
+ upstreamResponse,
90
+ options.cssFile,
91
+ encoding
92
+ );
93
+ }
94
+ );
95
+
96
+ upstream.on(
97
+ "error",
98
+ (error) => {
99
+ if (
100
+ res.headersSent
101
+ ) {
102
+ res.destroy(
103
+ error
104
+ );
105
+ return;
106
+ }
107
+
108
+ res.writeHead(
109
+ 502,
110
+ {
111
+ "Content-Type":
112
+ "text/plain; charset=utf-8",
113
+ "Cache-Control":
114
+ "no-store",
115
+ }
116
+ );
117
+ res.end(
118
+ "Bad Gateway"
119
+ );
120
+ }
121
+ );
122
+
123
+ req.pipe(
124
+ upstream
125
+ );
126
+ }
127
+ );
128
+
129
+ server.keepAliveTimeout =
130
+ 65_000;
131
+ server.headersTimeout =
132
+ 66_000;
133
+
134
+ return {
135
+ async start(): Promise<void> {
136
+ await listen(
137
+ server,
138
+ options.port,
139
+ options.hostname
140
+ );
141
+ },
142
+
143
+ async stop(): Promise<void> {
144
+ if (
145
+ !server.listening
146
+ ) {
147
+ return;
148
+ }
149
+
150
+ await new Promise<void>(
151
+ (
152
+ resolve,
153
+ reject
154
+ ) => {
155
+ server.close(
156
+ (error) => {
157
+ if (error) {
158
+ reject(
159
+ error
160
+ );
161
+ return;
162
+ }
163
+
164
+ resolve();
165
+ }
166
+ );
167
+ }
168
+ );
169
+ },
170
+ };
171
+ }
172
+
173
+ function shouldTransformHtml(
174
+ req: http.IncomingMessage,
175
+ response: http.IncomingMessage
176
+ ): boolean {
177
+ if (
178
+ (req.method ?? "GET").toUpperCase() !==
179
+ "GET"
180
+ ) {
181
+ return false;
182
+ }
183
+
184
+ if (
185
+ response.statusCode !== 200
186
+ ) {
187
+ return false;
188
+ }
189
+
190
+ const contentType =
191
+ response.headers[
192
+ "content-type"
193
+ ];
194
+ const value =
195
+ Array.isArray(
196
+ contentType
197
+ )
198
+ ? contentType[0]
199
+ : contentType;
200
+
201
+ return (
202
+ typeof value === "string" &&
203
+ /^text\/html\b/i.test(
204
+ value
205
+ ) &&
206
+ allowsInlineStyles(
207
+ response
208
+ )
209
+ );
210
+ }
211
+
212
+ function allowsInlineStyles(
213
+ response: http.IncomingMessage
214
+ ): boolean {
215
+ const header =
216
+ response.headers[
217
+ "content-security-policy"
218
+ ];
219
+ const policies =
220
+ Array.isArray(
221
+ header
222
+ )
223
+ ? header
224
+ : header
225
+ ? [header]
226
+ : [];
227
+
228
+ if (
229
+ policies.length === 0
230
+ ) {
231
+ return true;
232
+ }
233
+
234
+ return policies.every(
235
+ (policy) =>
236
+ policyAllowsInlineStyles(
237
+ policy
238
+ )
239
+ );
240
+ }
241
+
242
+ function policyAllowsInlineStyles(
243
+ policy: string
244
+ ): boolean {
245
+ const directives =
246
+ new Map<string, string[]>();
247
+
248
+ for (
249
+ const rawDirective
250
+ of policy.split(";")
251
+ ) {
252
+ const parts =
253
+ rawDirective
254
+ .trim()
255
+ .split(/\s+/)
256
+ .filter(Boolean);
257
+
258
+ if (
259
+ parts.length === 0
260
+ ) {
261
+ continue;
262
+ }
263
+
264
+ directives.set(
265
+ parts[0].toLowerCase(),
266
+ parts.slice(1)
267
+ );
268
+ }
269
+
270
+ for (
271
+ const name
272
+ of [
273
+ "style-src-elem",
274
+ "style-src",
275
+ "default-src",
276
+ ]
277
+ ) {
278
+ const sources =
279
+ directives.get(
280
+ name
281
+ );
282
+
283
+ if (!sources) {
284
+ continue;
285
+ }
286
+
287
+ return sources.some(
288
+ (source) =>
289
+ source.toLowerCase() ===
290
+ "'unsafe-inline'"
291
+ );
292
+ }
293
+
294
+ return true;
295
+ }
296
+
297
+ async function transformHtmlResponse(
298
+ res: http.ServerResponse,
299
+ upstreamResponse: http.IncomingMessage,
300
+ cssFile: string,
301
+ encoding: SupportedContentEncoding
302
+ ): Promise<void> {
303
+ try {
304
+ const chunks:
305
+ Buffer[] = [];
306
+
307
+ for await (
308
+ const chunk
309
+ of upstreamResponse
310
+ ) {
311
+ chunks.push(
312
+ Buffer.isBuffer(
313
+ chunk
314
+ )
315
+ ? chunk
316
+ : Buffer.from(
317
+ chunk
318
+ )
319
+ );
320
+ }
321
+
322
+ const decodedBody =
323
+ decodeBody(
324
+ Buffer.concat(
325
+ chunks
326
+ ),
327
+ encoding
328
+ );
329
+
330
+ const html =
331
+ inlineSmallStylesheet(
332
+ decodedBody.toString(
333
+ "utf8"
334
+ ),
335
+ cssFile
336
+ );
337
+
338
+ const responseBody =
339
+ encodeBody(
340
+ Buffer.from(
341
+ html,
342
+ "utf8"
343
+ ),
344
+ encoding
345
+ );
346
+
347
+ const headers:
348
+ http.OutgoingHttpHeaders = {
349
+ ...upstreamResponse.headers,
350
+ "content-length":
351
+ responseBody.length,
352
+ };
353
+
354
+ delete headers[
355
+ "transfer-encoding"
356
+ ];
357
+ delete headers[
358
+ "etag"
359
+ ];
360
+ delete headers[
361
+ "content-md5"
362
+ ];
363
+
364
+ if (
365
+ encoding === "identity"
366
+ ) {
367
+ delete headers[
368
+ "content-encoding"
369
+ ];
370
+ }
371
+
372
+ writeUpstreamHead(
373
+ res,
374
+ upstreamResponse,
375
+ headers
376
+ );
377
+ res.end(
378
+ responseBody
379
+ );
380
+ } catch (error) {
381
+ if (
382
+ res.headersSent
383
+ ) {
384
+ res.destroy(
385
+ error instanceof Error
386
+ ? error
387
+ : undefined
388
+ );
389
+ return;
390
+ }
391
+
392
+ res.writeHead(
393
+ 500,
394
+ {
395
+ "Content-Type":
396
+ "text/plain; charset=utf-8",
397
+ "Cache-Control":
398
+ "no-store",
399
+ }
400
+ );
401
+ res.end(
402
+ "Internal Server Error"
403
+ );
404
+ }
405
+ }
406
+
407
+ function getSupportedContentEncoding(
408
+ response: http.IncomingMessage
409
+ ): SupportedContentEncoding | null {
410
+ const header =
411
+ response.headers[
412
+ "content-encoding"
413
+ ];
414
+ const value =
415
+ (
416
+ Array.isArray(
417
+ header
418
+ )
419
+ ? header[0]
420
+ : header
421
+ )
422
+ ?.trim()
423
+ .toLowerCase();
424
+
425
+ if (
426
+ !value ||
427
+ value === "identity"
428
+ ) {
429
+ return "identity";
430
+ }
431
+
432
+ if (
433
+ value === "gzip" ||
434
+ value === "br" ||
435
+ value === "deflate"
436
+ ) {
437
+ return value;
438
+ }
439
+
440
+ return null;
441
+ }
442
+
443
+ function decodeBody(
444
+ body: Buffer,
445
+ encoding: SupportedContentEncoding
446
+ ): Buffer {
447
+ switch (encoding) {
448
+ case "gzip":
449
+ return gunzipSync(
450
+ body
451
+ );
452
+ case "br":
453
+ return brotliDecompressSync(
454
+ body
455
+ );
456
+ case "deflate":
457
+ return inflateSync(
458
+ body
459
+ );
460
+ default:
461
+ return body;
462
+ }
463
+ }
464
+
465
+ function encodeBody(
466
+ body: Buffer,
467
+ encoding: SupportedContentEncoding
468
+ ): Buffer {
469
+ switch (encoding) {
470
+ case "gzip":
471
+ return gzipSync(
472
+ body
473
+ );
474
+ case "br":
475
+ return brotliCompressSync(
476
+ body
477
+ );
478
+ case "deflate":
479
+ return deflateSync(
480
+ body
481
+ );
482
+ default:
483
+ return body;
484
+ }
485
+ }
486
+
487
+ function writeUpstreamHead(
488
+ res: http.ServerResponse,
489
+ upstreamResponse: http.IncomingMessage,
490
+ headers:
491
+ http.OutgoingHttpHeaders =
492
+ upstreamResponse.headers
493
+ ): void {
494
+ const statusCode =
495
+ upstreamResponse.statusCode ??
496
+ 502;
497
+
498
+ if (
499
+ upstreamResponse.statusMessage
500
+ ) {
501
+ res.writeHead(
502
+ statusCode,
503
+ upstreamResponse.statusMessage,
504
+ headers
505
+ );
506
+ return;
507
+ }
508
+
509
+ res.writeHead(
510
+ statusCode,
511
+ headers
512
+ );
513
+ }
514
+
515
+ async function listen(
516
+ server: http.Server,
517
+ port: number,
518
+ hostname: string
519
+ ): Promise<void> {
520
+ await new Promise<void>(
521
+ (
522
+ resolve,
523
+ reject
524
+ ) => {
525
+ const onError =
526
+ (error: Error) => {
527
+ server.off(
528
+ "listening",
529
+ onListening
530
+ );
531
+ reject(
532
+ error
533
+ );
534
+ };
535
+
536
+ const onListening =
537
+ () => {
538
+ server.off(
539
+ "error",
540
+ onError
541
+ );
542
+ resolve();
543
+ };
544
+
545
+ server.once(
546
+ "error",
547
+ onError
548
+ );
549
+ server.once(
550
+ "listening",
551
+ onListening
552
+ );
553
+ server.listen(
554
+ port,
555
+ hostname
556
+ );
557
+ }
558
+ );
559
+ }
@@ -0,0 +1,132 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const DEFAULT_INLINE_STYLESHEET_LIMIT =
5
+ 8 * 1024;
6
+
7
+ export interface InlineStylesheetOptions {
8
+ href?: string;
9
+ maxBytes?: number;
10
+ }
11
+
12
+ export function canInlineStylesheet(
13
+ cssFile: string,
14
+ maxBytes =
15
+ DEFAULT_INLINE_STYLESHEET_LIMIT
16
+ ): boolean {
17
+ assertValidMaxBytes(
18
+ maxBytes
19
+ );
20
+
21
+ try {
22
+ const stat =
23
+ fs.statSync(
24
+ cssFile
25
+ );
26
+
27
+ return (
28
+ stat.isFile() &&
29
+ stat.size <= maxBytes
30
+ );
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export function inlineSmallStylesheet(
37
+ html: string,
38
+ cssFile: string,
39
+ options: InlineStylesheetOptions = {}
40
+ ): string {
41
+ const href =
42
+ options.href ??
43
+ "/bcp.css";
44
+ const maxBytes =
45
+ options.maxBytes ??
46
+ DEFAULT_INLINE_STYLESHEET_LIMIT;
47
+
48
+ assertValidMaxBytes(
49
+ maxBytes
50
+ );
51
+
52
+ const stylesheetPattern =
53
+ createStylesheetPattern(
54
+ href
55
+ );
56
+
57
+ if (
58
+ !stylesheetPattern.test(
59
+ html
60
+ ) ||
61
+ !canInlineStylesheet(
62
+ cssFile,
63
+ maxBytes
64
+ )
65
+ ) {
66
+ return html;
67
+ }
68
+
69
+ const css =
70
+ fs.readFileSync(
71
+ cssFile,
72
+ "utf8"
73
+ );
74
+
75
+ const safeCss =
76
+ css.replace(
77
+ /<\/style/gi,
78
+ "<\\/style"
79
+ );
80
+
81
+ return html.replace(
82
+ stylesheetPattern,
83
+ `<style data-bcp-inline-css>${safeCss}</style>`
84
+ );
85
+ }
86
+
87
+ export function resolveBcpStylesheetFile(
88
+ publicDirectory: string
89
+ ): string {
90
+ return path.join(
91
+ publicDirectory,
92
+ "bcp.css"
93
+ );
94
+ }
95
+
96
+ function assertValidMaxBytes(
97
+ maxBytes: number
98
+ ): void {
99
+ if (
100
+ !Number.isFinite(
101
+ maxBytes
102
+ ) ||
103
+ maxBytes < 0
104
+ ) {
105
+ throw new Error(
106
+ "BCP Framework: inline stylesheet maxBytes must be a non-negative finite number."
107
+ );
108
+ }
109
+ }
110
+
111
+ function createStylesheetPattern(
112
+ href: string
113
+ ): RegExp {
114
+ const escapedHref =
115
+ escapeRegExp(
116
+ href
117
+ );
118
+
119
+ return new RegExp(
120
+ `<link\\b(?=[^>]*\\brel=["']stylesheet["'])(?=[^>]*\\bhref=["']${escapedHref}(?:\\?[^"']*)?["'])[^>]*>`,
121
+ "i"
122
+ );
123
+ }
124
+
125
+ function escapeRegExp(
126
+ value: string
127
+ ): string {
128
+ return value.replace(
129
+ /[.*+?^${}()|[\]\\]/g,
130
+ "\\$&"
131
+ );
132
+ }