@chidchanun/bcp 0.1.5 → 0.1.6
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/CHANGELOG.md +16 -0
- package/README.md +7 -1
- package/package.json +1 -1
- package/packages/server/src/critical-css-proxy.ts +559 -0
- package/packages/server/src/critical-css.ts +132 -0
- package/packages/server/src/middleware-dev-server.ts +59 -3
- package/packages/server/src/standalone-production-runtime-v6.ts +205 -0
- package/packages/server/src/standalone-production-server.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable framework changes are tracked here before release.
|
|
4
4
|
|
|
5
|
+
## 0.1.6 - Critical CSS and release visibility
|
|
6
|
+
|
|
7
|
+
### Performance
|
|
8
|
+
|
|
9
|
+
- Small generated `/bcp.css` stylesheets are inlined into SSR HTML when they are 8 KiB or smaller, removing the stylesheet request from the initial render-critical path.
|
|
10
|
+
- Critical CSS optimization is applied in development and standalone production.
|
|
11
|
+
- Gzip, Brotli and deflate HTML responses are decoded, transformed and re-encoded so response compression remains intact.
|
|
12
|
+
- Applications with CSP policies that do not allow inline styles automatically keep the external `/bcp.css` link instead of breaking page styling.
|
|
13
|
+
- Critical CSS transformation is skipped entirely when the stylesheet is missing or larger than the inline threshold.
|
|
14
|
+
|
|
15
|
+
### Reliability and release tooling
|
|
16
|
+
|
|
17
|
+
- Added regression tests for critical CSS inlining, size fallback, closing-style escaping, compressed responses and CSP fallback.
|
|
18
|
+
- Added `release:visibility-check` so an npm release is not considered ready until the exact framework and generator versions are readable from the registry.
|
|
19
|
+
- `release:publish:yes` and `release:resume` now run the registry visibility check after npm accepts the publish.
|
|
20
|
+
|
|
5
21
|
## 0.1.5 - Application module boundaries
|
|
6
22
|
|
|
7
23
|
### Developer experience
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
BCP Framework is a React full-stack framework with file-based routing, SSR, client navigation, API routes, middleware, metadata, client islands, cache/revalidation, security defaults and standalone production builds.
|
|
4
4
|
|
|
5
|
-
> Current development version: `0.1.
|
|
5
|
+
> Current development version: `0.1.6`. BCP is still pre-1.0 and validates each release candidate before the manual npm publish step.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -77,6 +77,10 @@ Database helpers generated by `create-bcp-app` include the server-only marker au
|
|
|
77
77
|
|
|
78
78
|
See [Application Modules](docs/application-modules.md) for the complete boundary model and examples.
|
|
79
79
|
|
|
80
|
+
## Tailwind and critical CSS
|
|
81
|
+
|
|
82
|
+
Generated Tailwind projects compile to `public/bcp.css`. BCP 0.1.6 inlines that stylesheet into SSR HTML when it is 8 KiB or smaller, removing the stylesheet request from the initial render-critical path. Larger stylesheets remain external so the browser can cache them normally. If the application's Content Security Policy does not allow inline styles, BCP automatically keeps the external stylesheet link.
|
|
83
|
+
|
|
80
84
|
## Commands
|
|
81
85
|
|
|
82
86
|
```bash
|
|
@@ -248,6 +252,8 @@ npm run rc:check
|
|
|
248
252
|
|
|
249
253
|
`rc:check` validates tests/release metadata, checks npm package-name availability or ownership, performs `npm publish --dry-run`, and clean-installs both generated tarballs into temporary projects.
|
|
250
254
|
|
|
255
|
+
After a real publish, `npm run release:visibility-check` verifies that the exact framework and generator versions are readable from the npm registry before the release is treated as ready for installation.
|
|
256
|
+
|
|
251
257
|
`package:prepare` stages the framework at `.package/bcp`. The default package name is `bcp`; set `BCP_PACKAGE_NAME` when preparing a scoped or alternate package name.
|
|
252
258
|
|
|
253
259
|
No real npm publish command is run automatically by the repository.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chidchanun/bcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,10 @@ import type {
|
|
|
5
5
|
DevServerOptions,
|
|
6
6
|
} from "./index.js";
|
|
7
7
|
|
|
8
|
+
import {
|
|
9
|
+
createCriticalCssProxy,
|
|
10
|
+
} from "./critical-css-proxy.js";
|
|
11
|
+
|
|
8
12
|
import {
|
|
9
13
|
loadProjectMiddleware,
|
|
10
14
|
} from "./middleware-loader.js";
|
|
@@ -56,6 +60,12 @@ export function createMiddlewareDevServer(
|
|
|
56
60
|
> | null =
|
|
57
61
|
null;
|
|
58
62
|
|
|
63
|
+
let criticalCssGateway:
|
|
64
|
+
ReturnType<
|
|
65
|
+
typeof createCriticalCssProxy
|
|
66
|
+
> | null =
|
|
67
|
+
null;
|
|
68
|
+
|
|
59
69
|
let started =
|
|
60
70
|
false;
|
|
61
71
|
|
|
@@ -68,6 +78,8 @@ export function createMiddlewareDevServer(
|
|
|
68
78
|
await findFreePort();
|
|
69
79
|
const middlewarePort =
|
|
70
80
|
await findFreePort();
|
|
81
|
+
const securityPort =
|
|
82
|
+
await findFreePort();
|
|
71
83
|
|
|
72
84
|
internalServer =
|
|
73
85
|
createStaticDevServer({
|
|
@@ -111,8 +123,10 @@ export function createMiddlewareDevServer(
|
|
|
111
123
|
|
|
112
124
|
securityGateway =
|
|
113
125
|
createSecurityProxy({
|
|
114
|
-
port
|
|
115
|
-
|
|
126
|
+
port:
|
|
127
|
+
securityPort,
|
|
128
|
+
hostname:
|
|
129
|
+
"127.0.0.1",
|
|
116
130
|
upstreamPort:
|
|
117
131
|
middlewarePort,
|
|
118
132
|
upstreamHostname:
|
|
@@ -133,12 +147,48 @@ export function createMiddlewareDevServer(
|
|
|
133
147
|
throw error;
|
|
134
148
|
}
|
|
135
149
|
|
|
150
|
+
criticalCssGateway =
|
|
151
|
+
createCriticalCssProxy({
|
|
152
|
+
port,
|
|
153
|
+
hostname,
|
|
154
|
+
upstreamPort:
|
|
155
|
+
securityPort,
|
|
156
|
+
upstreamHostname:
|
|
157
|
+
"127.0.0.1",
|
|
158
|
+
cssFile:
|
|
159
|
+
path.join(
|
|
160
|
+
rootDirectory,
|
|
161
|
+
"public",
|
|
162
|
+
"bcp.css"
|
|
163
|
+
),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await criticalCssGateway.start();
|
|
168
|
+
} catch (error) {
|
|
169
|
+
await securityGateway.stop();
|
|
170
|
+
await middlewareGateway.stop();
|
|
171
|
+
await internalServer.stop();
|
|
172
|
+
internalServer =
|
|
173
|
+
null;
|
|
174
|
+
middlewareGateway =
|
|
175
|
+
null;
|
|
176
|
+
securityGateway =
|
|
177
|
+
null;
|
|
178
|
+
criticalCssGateway =
|
|
179
|
+
null;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
|
|
136
183
|
started =
|
|
137
184
|
true;
|
|
138
185
|
|
|
139
186
|
console.log(
|
|
140
187
|
`[BCP Security] Dev gateway: http://${hostname}:${port}`
|
|
141
188
|
);
|
|
189
|
+
console.log(
|
|
190
|
+
`[BCP CSS] Inline /bcp.css when <= 8 KiB and allowed by CSP.`
|
|
191
|
+
);
|
|
142
192
|
console.log(
|
|
143
193
|
`[BCP Middleware] File: ${path.join(rootDirectory, "middleware.ts")} (optional; .tsx/.js/.jsx also supported)`
|
|
144
194
|
);
|
|
@@ -146,6 +196,12 @@ export function createMiddlewareDevServer(
|
|
|
146
196
|
}
|
|
147
197
|
|
|
148
198
|
async function stop(): Promise<void> {
|
|
199
|
+
if (criticalCssGateway) {
|
|
200
|
+
await criticalCssGateway.stop();
|
|
201
|
+
criticalCssGateway =
|
|
202
|
+
null;
|
|
203
|
+
}
|
|
204
|
+
|
|
149
205
|
if (securityGateway) {
|
|
150
206
|
await securityGateway.stop();
|
|
151
207
|
securityGateway =
|
|
@@ -257,4 +313,4 @@ async function findFreePort(): Promise<number> {
|
|
|
257
313
|
);
|
|
258
314
|
}
|
|
259
315
|
}
|
|
260
|
-
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createCriticalCssProxy,
|
|
6
|
+
} from "./critical-css-proxy.js";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createStandaloneProductionServer as createBaseStandaloneProductionServer,
|
|
10
|
+
type StandaloneApiRoute,
|
|
11
|
+
type StandalonePageRoute,
|
|
12
|
+
type StandaloneProductionServerOptions as BaseStandaloneProductionServerOptions,
|
|
13
|
+
} from "./standalone-production-runtime-v5.js";
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
StandaloneApiRoute,
|
|
17
|
+
StandalonePageRoute,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface StandaloneProductionServerOptions
|
|
21
|
+
extends BaseStandaloneProductionServerOptions {}
|
|
22
|
+
|
|
23
|
+
export function createStandaloneProductionServer(
|
|
24
|
+
options: StandaloneProductionServerOptions
|
|
25
|
+
) {
|
|
26
|
+
let baseServer:
|
|
27
|
+
ReturnType<
|
|
28
|
+
typeof createBaseStandaloneProductionServer
|
|
29
|
+
> | null =
|
|
30
|
+
null;
|
|
31
|
+
|
|
32
|
+
let criticalCssProxy:
|
|
33
|
+
ReturnType<
|
|
34
|
+
typeof createCriticalCssProxy
|
|
35
|
+
> | null =
|
|
36
|
+
null;
|
|
37
|
+
|
|
38
|
+
let started =
|
|
39
|
+
false;
|
|
40
|
+
|
|
41
|
+
async function start(): Promise<void> {
|
|
42
|
+
if (started) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const internalPort =
|
|
47
|
+
await findFreePort();
|
|
48
|
+
|
|
49
|
+
baseServer =
|
|
50
|
+
createBaseStandaloneProductionServer({
|
|
51
|
+
...options,
|
|
52
|
+
port:
|
|
53
|
+
internalPort,
|
|
54
|
+
hostname:
|
|
55
|
+
"127.0.0.1",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await baseServer.start();
|
|
59
|
+
|
|
60
|
+
criticalCssProxy =
|
|
61
|
+
createCriticalCssProxy({
|
|
62
|
+
port:
|
|
63
|
+
options.port,
|
|
64
|
+
hostname:
|
|
65
|
+
options.hostname,
|
|
66
|
+
upstreamPort:
|
|
67
|
+
internalPort,
|
|
68
|
+
upstreamHostname:
|
|
69
|
+
"127.0.0.1",
|
|
70
|
+
cssFile:
|
|
71
|
+
path.join(
|
|
72
|
+
options.buildDirectory,
|
|
73
|
+
"public",
|
|
74
|
+
"bcp.css"
|
|
75
|
+
),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await criticalCssProxy.start();
|
|
80
|
+
} catch (error) {
|
|
81
|
+
await baseServer.stop();
|
|
82
|
+
baseServer =
|
|
83
|
+
null;
|
|
84
|
+
criticalCssProxy =
|
|
85
|
+
null;
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
started =
|
|
90
|
+
true;
|
|
91
|
+
|
|
92
|
+
console.log(
|
|
93
|
+
`[BCP CSS] Inline /bcp.css when <= 8 KiB.`
|
|
94
|
+
);
|
|
95
|
+
console.log("");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function stop(): Promise<void> {
|
|
99
|
+
if (criticalCssProxy) {
|
|
100
|
+
await criticalCssProxy.stop();
|
|
101
|
+
criticalCssProxy =
|
|
102
|
+
null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (baseServer) {
|
|
106
|
+
await baseServer.stop();
|
|
107
|
+
baseServer =
|
|
108
|
+
null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
started =
|
|
112
|
+
false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
start,
|
|
117
|
+
stop,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function findFreePort(): Promise<number> {
|
|
122
|
+
const server =
|
|
123
|
+
net.createServer();
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await new Promise<void>(
|
|
127
|
+
(
|
|
128
|
+
resolve,
|
|
129
|
+
reject
|
|
130
|
+
) => {
|
|
131
|
+
const onError =
|
|
132
|
+
(error: Error) => {
|
|
133
|
+
server.off(
|
|
134
|
+
"listening",
|
|
135
|
+
onListening
|
|
136
|
+
);
|
|
137
|
+
reject(
|
|
138
|
+
error
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const onListening =
|
|
143
|
+
() => {
|
|
144
|
+
server.off(
|
|
145
|
+
"error",
|
|
146
|
+
onError
|
|
147
|
+
);
|
|
148
|
+
resolve();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
server.once(
|
|
152
|
+
"error",
|
|
153
|
+
onError
|
|
154
|
+
);
|
|
155
|
+
server.once(
|
|
156
|
+
"listening",
|
|
157
|
+
onListening
|
|
158
|
+
);
|
|
159
|
+
server.listen(
|
|
160
|
+
0,
|
|
161
|
+
"127.0.0.1"
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const address =
|
|
167
|
+
server.address();
|
|
168
|
+
|
|
169
|
+
if (
|
|
170
|
+
!address ||
|
|
171
|
+
typeof address ===
|
|
172
|
+
"string"
|
|
173
|
+
) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
"BCP Framework: could not allocate critical CSS production port."
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return address.port;
|
|
180
|
+
} finally {
|
|
181
|
+
if (
|
|
182
|
+
server.listening
|
|
183
|
+
) {
|
|
184
|
+
await new Promise<void>(
|
|
185
|
+
(
|
|
186
|
+
resolve,
|
|
187
|
+
reject
|
|
188
|
+
) => {
|
|
189
|
+
server.close(
|
|
190
|
+
(error) => {
|
|
191
|
+
if (error) {
|
|
192
|
+
reject(
|
|
193
|
+
error
|
|
194
|
+
);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
resolve();
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|