@zealakit/css-doctor-remix 0.0.0-stage → 1.0.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/README.md +63 -2
- package/README_CN.md +64 -0
- package/README_VI.md +64 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +183 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zealamic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,64 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# @zealakit/css-doctor-remix
|
|
4
|
+
|
|
5
|
+
**Remix 3 asset-server middleware for runtime CSS optimization.**
|
|
6
|
+
|
|
7
|
+
[English](https://github.com/zealakit/css-doctor/blob/main/packages/css-doctor-remix/README.md) | [Tiếng Việt](https://github.com/zealakit/css-doctor/blob/main/packages/css-doctor-remix/README_VI.md) | [简体中文](https://github.com/zealakit/css-doctor/blob/main/packages/css-doctor-remix/README_CN.md)
|
|
8
|
+
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @zealakit/css-doctor-remix
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createAssetServer } from "remix/assets";
|
|
25
|
+
import { withCssDoctorAssets } from "@zealakit/css-doctor-remix";
|
|
26
|
+
|
|
27
|
+
const baseAssets = createAssetServer({
|
|
28
|
+
basePath: "/assets",
|
|
29
|
+
rootDir: process.cwd(),
|
|
30
|
+
allowFiles: ["app/**/public/**", "app/routes.ts"],
|
|
31
|
+
allowPackages: ["remix"],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const assets = withCssDoctorAssets(baseAssets, {
|
|
35
|
+
sizeBudget: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
maxChunkSize: 30 * 1024,
|
|
38
|
+
isolateVariables: true,
|
|
39
|
+
},
|
|
40
|
+
minify: process.env.NODE_ENV === "production",
|
|
41
|
+
cache: { enabled: true, maxEntries: 100 },
|
|
42
|
+
exclude: [/\.module\.(css|scss)$/],
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
| Option | Default | Description |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| `sizeBudget.enabled` | `true` | Enable byte-budget splitting. |
|
|
51
|
+
| `sizeBudget.maxChunkSize` | `60 * 1024` | Maximum bytes per emitted chunk. |
|
|
52
|
+
| `sizeBudget.isolateVariables` | `true` | Extract `:root` and `@theme` variables first. |
|
|
53
|
+
| `minify` | production | Minify emitted CSS with LightningCSS. |
|
|
54
|
+
|
|
55
|
+
## Safety and limitations
|
|
56
|
+
|
|
57
|
+
- No pageCoverage: Remix 3 has no build-time route-to-CSS graph.
|
|
58
|
+
- Optimizes successful text/css GET responses and falls back to the original response on failure.
|
|
59
|
+
- Uses an LRU cache based on URL, source ETag, and content hash.
|
|
60
|
+
- Load page-specific module stylesheets from controller actions, not a global document head.
|
|
61
|
+
|
|
62
|
+
## Full documentation
|
|
63
|
+
|
|
64
|
+
Project README: [English](https://github.com/zealakit/css-doctor/blob/main/README.md) | [Tiếng Việt](https://github.com/zealakit/css-doctor/blob/main/README_VI.md) | [简体中文](https://github.com/zealakit/css-doctor/blob/main/README_CN.md)
|
package/README_CN.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# @zealakit/css-doctor-remix
|
|
4
|
+
|
|
5
|
+
**用于运行时 CSS 优化的 Remix 3 Asset Server 中间件。**
|
|
6
|
+
|
|
7
|
+
[English](https://github.com/zealakit/css-doctor/blob/main/packages/css-doctor-remix/README.md) | [Tiếng Việt](https://github.com/zealakit/css-doctor/blob/main/packages/css-doctor-remix/README_VI.md) | [简体中文](https://github.com/zealakit/css-doctor/blob/main/packages/css-doctor-remix/README_CN.md)
|
|
8
|
+
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @zealakit/css-doctor-remix
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 使用
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createAssetServer } from "remix/assets";
|
|
25
|
+
import { withCssDoctorAssets } from "@zealakit/css-doctor-remix";
|
|
26
|
+
|
|
27
|
+
const baseAssets = createAssetServer({
|
|
28
|
+
basePath: "/assets",
|
|
29
|
+
rootDir: process.cwd(),
|
|
30
|
+
allowFiles: ["app/**/public/**", "app/routes.ts"],
|
|
31
|
+
allowPackages: ["remix"],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const assets = withCssDoctorAssets(baseAssets, {
|
|
35
|
+
sizeBudget: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
maxChunkSize: 30 * 1024,
|
|
38
|
+
isolateVariables: true,
|
|
39
|
+
},
|
|
40
|
+
minify: process.env.NODE_ENV === "production",
|
|
41
|
+
cache: { enabled: true, maxEntries: 100 },
|
|
42
|
+
exclude: [/\.module\.(css|scss)$/],
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 配置
|
|
47
|
+
|
|
48
|
+
| 参数 | 默认值 | 说明 |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| `sizeBudget.enabled` | `true` | 启用按字节预算分割。 |
|
|
51
|
+
| `sizeBudget.maxChunkSize` | `60 * 1024` | 每个 chunk 的最大字节数。 |
|
|
52
|
+
| `sizeBudget.isolateVariables` | `true` | 先提取 `:root` 与 `@theme` 变量。 |
|
|
53
|
+
| `minify` | production | 使用 LightningCSS 压缩输出 CSS。 |
|
|
54
|
+
|
|
55
|
+
## 安全与限制
|
|
56
|
+
|
|
57
|
+
- 不支持 pageCoverage:Remix 3 没有构建期 route-to-CSS 图。
|
|
58
|
+
- 优化成功的 text/css GET 响应,失败时回退原始响应。
|
|
59
|
+
- 使用基于 URL、源 ETag 和内容哈希的 LRU 缓存。
|
|
60
|
+
- 按 controller action 加载页面 module 样式,不要硬编码到全局 document head。
|
|
61
|
+
|
|
62
|
+
## 完整文档
|
|
63
|
+
|
|
64
|
+
项目 README:[English](https://github.com/zealakit/css-doctor/blob/main/README.md) | [Tiếng Việt](https://github.com/zealakit/css-doctor/blob/main/README_VI.md) | [简体中文](https://github.com/zealakit/css-doctor/blob/main/README_CN.md)
|
package/README_VI.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# @zealakit/css-doctor-remix
|
|
4
|
+
|
|
5
|
+
**Middleware asset server Remix 3 tối ưu CSS runtime.**
|
|
6
|
+
|
|
7
|
+
[English](https://github.com/zealakit/css-doctor/blob/main/packages/css-doctor-remix/README.md) | [Tiếng Việt](https://github.com/zealakit/css-doctor/blob/main/packages/css-doctor-remix/README_VI.md) | [简体中文](https://github.com/zealakit/css-doctor/blob/main/packages/css-doctor-remix/README_CN.md)
|
|
8
|
+
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## Cài đặt
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @zealakit/css-doctor-remix
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Sử dụng
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createAssetServer } from "remix/assets";
|
|
25
|
+
import { withCssDoctorAssets } from "@zealakit/css-doctor-remix";
|
|
26
|
+
|
|
27
|
+
const baseAssets = createAssetServer({
|
|
28
|
+
basePath: "/assets",
|
|
29
|
+
rootDir: process.cwd(),
|
|
30
|
+
allowFiles: ["app/**/public/**", "app/routes.ts"],
|
|
31
|
+
allowPackages: ["remix"],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const assets = withCssDoctorAssets(baseAssets, {
|
|
35
|
+
sizeBudget: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
maxChunkSize: 30 * 1024,
|
|
38
|
+
isolateVariables: true,
|
|
39
|
+
},
|
|
40
|
+
minify: process.env.NODE_ENV === "production",
|
|
41
|
+
cache: { enabled: true, maxEntries: 100 },
|
|
42
|
+
exclude: [/\.module\.(css|scss)$/],
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Tùy chọn
|
|
47
|
+
|
|
48
|
+
| Tùy chọn | Mặc định | Mô tả |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| `sizeBudget.enabled` | `true` | Bật chia nhỏ theo byte budget. |
|
|
51
|
+
| `sizeBudget.maxChunkSize` | `60 * 1024` | Số byte tối đa mỗi chunk. |
|
|
52
|
+
| `sizeBudget.isolateVariables` | `true` | Tách biến `:root` và `@theme` trước. |
|
|
53
|
+
| `minify` | production | Minify CSS bằng LightningCSS. |
|
|
54
|
+
|
|
55
|
+
## An toàn và giới hạn
|
|
56
|
+
|
|
57
|
+
- Không có pageCoverage: Remix 3 không có route-to-CSS graph lúc build.
|
|
58
|
+
- Tối ưu response text/css thành công và fallback response gốc khi lỗi.
|
|
59
|
+
- Dùng LRU cache theo URL, ETag gốc và content hash.
|
|
60
|
+
- Nạp stylesheet module theo action controller, không hardcode toàn bộ vào document head.
|
|
61
|
+
|
|
62
|
+
## Tài liệu đầy đủ
|
|
63
|
+
|
|
64
|
+
README dự án: [English](https://github.com/zealakit/css-doctor/blob/main/README.md) | [Tiếng Việt](https://github.com/zealakit/css-doctor/blob/main/README_VI.md) | [简体中文](https://github.com/zealakit/css-doctor/blob/main/README_CN.md)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Tier 2: physical size-budget splitting. */
|
|
2
|
+
type SizeBudgetOptions = {
|
|
3
|
+
/** Enable size-budget splitting. @default true */
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
/** Maximum serialized bytes per body chunk. @default 60 * 1024 */
|
|
6
|
+
maxChunkSize?: number;
|
|
7
|
+
/** Isolate CSS variables before splitting. @default true */
|
|
8
|
+
isolateVariables?: boolean;
|
|
9
|
+
};
|
|
10
|
+
/** Memory cache configuration. */
|
|
11
|
+
type CssDoctorRemixCacheOptions = {
|
|
12
|
+
/** Enable the optimized-response cache. @default true */
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
/** Maximum number of cached CSS responses. @default 100 */
|
|
15
|
+
maxEntries?: number;
|
|
16
|
+
};
|
|
17
|
+
/** Configuration for the Remix 3 asset middleware. */
|
|
18
|
+
type CssDoctorRemixOptions = {
|
|
19
|
+
/** Tier 2 configuration. */
|
|
20
|
+
sizeBudget?: SizeBudgetOptions;
|
|
21
|
+
/** Minify CSS. Defaults to true only when NODE_ENV is production. */
|
|
22
|
+
minify?: boolean;
|
|
23
|
+
/** Memory cache configuration. */
|
|
24
|
+
cache?: CssDoctorRemixCacheOptions;
|
|
25
|
+
/** URL patterns for assets that must not be optimized. */
|
|
26
|
+
exclude?: Array<string | RegExp>;
|
|
27
|
+
};
|
|
28
|
+
/** The subset of Remix's AssetServer used by this adapter. */
|
|
29
|
+
type RemixAssetServerLike = {
|
|
30
|
+
fetch: (request: Request) => Promise<Response | null>;
|
|
31
|
+
};
|
|
32
|
+
/** A cached, already-optimized CSS response. */
|
|
33
|
+
type CssCacheEntry = {
|
|
34
|
+
body: string;
|
|
35
|
+
etag: string;
|
|
36
|
+
headers: Array<[string, string]>;
|
|
37
|
+
sizeBytes: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wraps a Remix 3 AssetServer and optimizes CSS responses lazily.
|
|
42
|
+
*
|
|
43
|
+
* The original server remains the source of truth for every asset. The wrapper
|
|
44
|
+
* only intercepts successful `text/css` GET responses, and falls back to the
|
|
45
|
+
* original Response for every error or unsupported request.
|
|
46
|
+
*/
|
|
47
|
+
declare const withCssDoctorAssets: <T extends RemixAssetServerLike>(assetServer: T, options?: CssDoctorRemixOptions) => T;
|
|
48
|
+
|
|
49
|
+
export { type CssCacheEntry, type CssDoctorRemixCacheOptions, type CssDoctorRemixOptions, type RemixAssetServerLike, type SizeBudgetOptions, withCssDoctorAssets };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// src/asset-middleware.ts
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import { optimizeCss } from "@zealakit/css-doctor-binding";
|
|
4
|
+
|
|
5
|
+
// src/cache-manager.ts
|
|
6
|
+
var CssCacheManager = class {
|
|
7
|
+
entries = /* @__PURE__ */ new Map();
|
|
8
|
+
maxEntries;
|
|
9
|
+
constructor(maxEntries = 100) {
|
|
10
|
+
this.maxEntries = Math.max(1, Math.floor(maxEntries));
|
|
11
|
+
}
|
|
12
|
+
get(key) {
|
|
13
|
+
const entry = this.entries.get(key);
|
|
14
|
+
if (!entry) {
|
|
15
|
+
return void 0;
|
|
16
|
+
}
|
|
17
|
+
this.entries.delete(key);
|
|
18
|
+
this.entries.set(key, entry);
|
|
19
|
+
return entry;
|
|
20
|
+
}
|
|
21
|
+
set(key, entry) {
|
|
22
|
+
if (this.entries.has(key)) {
|
|
23
|
+
this.entries.delete(key);
|
|
24
|
+
}
|
|
25
|
+
this.entries.set(key, entry);
|
|
26
|
+
while (this.entries.size > this.maxEntries) {
|
|
27
|
+
const oldest = this.entries.keys().next().value;
|
|
28
|
+
if (oldest === void 0) {
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
this.entries.delete(oldest);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
clear() {
|
|
35
|
+
this.entries.clear();
|
|
36
|
+
}
|
|
37
|
+
get size() {
|
|
38
|
+
return this.entries.size;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// src/asset-middleware.ts
|
|
43
|
+
var DEFAULT_MAX_CHUNK_SIZE = 60 * 1024;
|
|
44
|
+
var DEFAULT_CACHE_ENTRIES = 100;
|
|
45
|
+
var CSS_MODULE_PATTERN = /\.module\.css$/i;
|
|
46
|
+
var withCssDoctorAssets = (assetServer, options = {}) => {
|
|
47
|
+
const resolved = resolveOptions(options);
|
|
48
|
+
const wrappedFetch = async (request) => {
|
|
49
|
+
let pathname;
|
|
50
|
+
try {
|
|
51
|
+
pathname = new URL(request.url).pathname;
|
|
52
|
+
} catch {
|
|
53
|
+
return assetServer.fetch(request);
|
|
54
|
+
}
|
|
55
|
+
if (request.method !== "GET" || !pathname.toLowerCase().endsWith(".css") || shouldSkipPath(pathname, resolved.exclude)) {
|
|
56
|
+
return assetServer.fetch(request);
|
|
57
|
+
}
|
|
58
|
+
const response = await assetServer.fetch(request);
|
|
59
|
+
if (!response || response.status !== 200) {
|
|
60
|
+
return response;
|
|
61
|
+
}
|
|
62
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
63
|
+
if (!contentType.toLowerCase().startsWith("text/css")) {
|
|
64
|
+
return response;
|
|
65
|
+
}
|
|
66
|
+
let originalCss;
|
|
67
|
+
try {
|
|
68
|
+
originalCss = await response.clone().text();
|
|
69
|
+
} catch {
|
|
70
|
+
return response;
|
|
71
|
+
}
|
|
72
|
+
const contentHash = hash(originalCss);
|
|
73
|
+
const etag = response.headers.get("etag") ?? "";
|
|
74
|
+
const cacheKey = `${request.url}|${etag}|${contentHash}`;
|
|
75
|
+
if (resolved.cacheEnabled) {
|
|
76
|
+
const cached = resolved.cache.get(cacheKey);
|
|
77
|
+
if (cached) {
|
|
78
|
+
return createResponse(cached, response);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const pipeline = optimizeCss({
|
|
83
|
+
cssContent: originalCss,
|
|
84
|
+
pageUsage: {},
|
|
85
|
+
pageCoverage: {
|
|
86
|
+
enabled: false,
|
|
87
|
+
minPageRatio: 0.5,
|
|
88
|
+
forceCommon: []
|
|
89
|
+
},
|
|
90
|
+
sizeBudget: {
|
|
91
|
+
enabled: resolved.sizeBudget.enabled,
|
|
92
|
+
maxChunkSize: resolved.sizeBudget.maxChunkSize,
|
|
93
|
+
isolateVariables: resolved.sizeBudget.isolateVariables
|
|
94
|
+
},
|
|
95
|
+
minify: resolved.minify
|
|
96
|
+
});
|
|
97
|
+
const optimizedCss = pipeline.chunks.filter((chunk) => chunk.page === void 0).map((chunk) => chunk.css).join("\n");
|
|
98
|
+
if (optimizedCss.length === 0) {
|
|
99
|
+
return response;
|
|
100
|
+
}
|
|
101
|
+
const optimizedHash = hash(optimizedCss);
|
|
102
|
+
const headers = new Headers(response.headers);
|
|
103
|
+
headers.delete("content-encoding");
|
|
104
|
+
headers.delete("transfer-encoding");
|
|
105
|
+
headers.set("content-type", "text/css; charset=utf-8");
|
|
106
|
+
headers.set(
|
|
107
|
+
"content-length",
|
|
108
|
+
String(Buffer.byteLength(optimizedCss, "utf8"))
|
|
109
|
+
);
|
|
110
|
+
headers.set("etag", `W/"cssd-${optimizedHash.slice(0, 16)}"`);
|
|
111
|
+
const headerEntries = [];
|
|
112
|
+
headers.forEach((value, key) => {
|
|
113
|
+
headerEntries.push([key, value]);
|
|
114
|
+
});
|
|
115
|
+
const entry = {
|
|
116
|
+
body: optimizedCss,
|
|
117
|
+
etag: headers.get("etag") ?? "",
|
|
118
|
+
headers: headerEntries,
|
|
119
|
+
sizeBytes: Buffer.byteLength(optimizedCss, "utf8")
|
|
120
|
+
};
|
|
121
|
+
if (resolved.cacheEnabled) {
|
|
122
|
+
resolved.cache.set(cacheKey, entry);
|
|
123
|
+
}
|
|
124
|
+
return createResponse(entry, response);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.warn(
|
|
127
|
+
`[css-doctor:remix] failed to optimize ${pathname}; serving the original asset: ${describeError(error)}`
|
|
128
|
+
);
|
|
129
|
+
return response;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
return new Proxy(assetServer, {
|
|
133
|
+
get(target, property) {
|
|
134
|
+
if (property === "fetch") {
|
|
135
|
+
return wrappedFetch;
|
|
136
|
+
}
|
|
137
|
+
const value = Reflect.get(target, property, target);
|
|
138
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
function resolveOptions(options) {
|
|
143
|
+
const maxChunkSize = options.sizeBudget?.maxChunkSize ?? DEFAULT_MAX_CHUNK_SIZE;
|
|
144
|
+
const maxEntries = options.cache?.maxEntries ?? DEFAULT_CACHE_ENTRIES;
|
|
145
|
+
return {
|
|
146
|
+
sizeBudget: {
|
|
147
|
+
enabled: options.sizeBudget?.enabled ?? true,
|
|
148
|
+
maxChunkSize: Number.isInteger(maxChunkSize) && maxChunkSize > 0 ? maxChunkSize : DEFAULT_MAX_CHUNK_SIZE,
|
|
149
|
+
isolateVariables: options.sizeBudget?.isolateVariables ?? true
|
|
150
|
+
},
|
|
151
|
+
minify: options.minify ?? process.env.NODE_ENV === "production",
|
|
152
|
+
cacheEnabled: options.cache?.enabled ?? true,
|
|
153
|
+
cache: new CssCacheManager(
|
|
154
|
+
Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_CACHE_ENTRIES
|
|
155
|
+
),
|
|
156
|
+
exclude: options.exclude ?? []
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function shouldSkipPath(pathname, exclude) {
|
|
160
|
+
if (CSS_MODULE_PATTERN.test(pathname)) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
return exclude.some(
|
|
164
|
+
(pattern) => typeof pattern === "string" ? pathname.includes(pattern) : pattern.test(pathname)
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
function createResponse(entry, original) {
|
|
168
|
+
return new Response(entry.body, {
|
|
169
|
+
status: original.status,
|
|
170
|
+
statusText: original.statusText,
|
|
171
|
+
headers: new Headers(entry.headers)
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function hash(value) {
|
|
175
|
+
return createHash("sha256").update(value).digest("hex");
|
|
176
|
+
}
|
|
177
|
+
function describeError(error) {
|
|
178
|
+
return error instanceof Error ? error.message : String(error);
|
|
179
|
+
}
|
|
180
|
+
export {
|
|
181
|
+
withCssDoctorAssets
|
|
182
|
+
};
|
|
183
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/asset-middleware.ts","../src/cache-manager.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\n\nimport { optimizeCss } from \"@zealakit/css-doctor-binding\";\nimport type { JsChunkOutput } from \"@zealakit/css-doctor-binding\";\n\nimport { CssCacheManager } from \"./cache-manager\";\nimport type {\n CssCacheEntry,\n CssDoctorRemixOptions,\n RemixAssetServerLike,\n SizeBudgetOptions,\n} from \"./types\";\n\nconst DEFAULT_MAX_CHUNK_SIZE = 60 * 1_024;\nconst DEFAULT_CACHE_ENTRIES = 100;\nconst CSS_MODULE_PATTERN = /\\.module\\.css$/i;\n\ntype ResolvedOptions = {\n sizeBudget: Required<SizeBudgetOptions>;\n minify: boolean;\n cacheEnabled: boolean;\n cache: CssCacheManager;\n exclude: Array<string | RegExp>;\n};\n\n/**\n * Wraps a Remix 3 AssetServer and optimizes CSS responses lazily.\n *\n * The original server remains the source of truth for every asset. The wrapper\n * only intercepts successful `text/css` GET responses, and falls back to the\n * original Response for every error or unsupported request.\n */\nexport const withCssDoctorAssets = <T extends RemixAssetServerLike>(\n assetServer: T,\n options: CssDoctorRemixOptions = {},\n): T => {\n const resolved = resolveOptions(options);\n const wrappedFetch = async (request: Request): Promise<Response | null> => {\n let pathname: string;\n\n try {\n pathname = new URL(request.url).pathname;\n } catch {\n return assetServer.fetch(request);\n }\n\n if (\n request.method !== \"GET\" ||\n !pathname.toLowerCase().endsWith(\".css\") ||\n shouldSkipPath(pathname, resolved.exclude)\n ) {\n return assetServer.fetch(request);\n }\n\n const response = await assetServer.fetch(request);\n\n if (!response || response.status !== 200) {\n return response;\n }\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n\n if (!contentType.toLowerCase().startsWith(\"text/css\")) {\n return response;\n }\n\n let originalCss: string;\n\n try {\n originalCss = await response.clone().text();\n } catch {\n return response;\n }\n\n const contentHash = hash(originalCss);\n const etag = response.headers.get(\"etag\") ?? \"\";\n const cacheKey = `${request.url}|${etag}|${contentHash}`;\n\n if (resolved.cacheEnabled) {\n const cached = resolved.cache.get(cacheKey);\n\n if (cached) {\n return createResponse(cached, response);\n }\n }\n\n try {\n const pipeline = optimizeCss({\n cssContent: originalCss,\n pageUsage: {},\n pageCoverage: {\n enabled: false,\n minPageRatio: 0.5,\n forceCommon: [],\n },\n sizeBudget: {\n enabled: resolved.sizeBudget.enabled,\n maxChunkSize: resolved.sizeBudget.maxChunkSize,\n isolateVariables: resolved.sizeBudget.isolateVariables,\n },\n minify: resolved.minify,\n });\n const optimizedCss = pipeline.chunks\n .filter((chunk: JsChunkOutput) => chunk.page === undefined)\n .map((chunk: JsChunkOutput) => chunk.css)\n .join(\"\\n\");\n\n if (optimizedCss.length === 0) {\n return response;\n }\n\n const optimizedHash = hash(optimizedCss);\n const headers = new Headers(response.headers);\n\n headers.delete(\"content-encoding\");\n headers.delete(\"transfer-encoding\");\n headers.set(\"content-type\", \"text/css; charset=utf-8\");\n headers.set(\n \"content-length\",\n String(Buffer.byteLength(optimizedCss, \"utf8\")),\n );\n headers.set(\"etag\", `W/\"cssd-${optimizedHash.slice(0, 16)}\"`);\n\n const headerEntries: Array<[string, string]> = [];\n headers.forEach((value, key) => {\n headerEntries.push([key, value]);\n });\n\n const entry: CssCacheEntry = {\n body: optimizedCss,\n etag: headers.get(\"etag\") ?? \"\",\n headers: headerEntries,\n sizeBytes: Buffer.byteLength(optimizedCss, \"utf8\"),\n };\n\n if (resolved.cacheEnabled) {\n resolved.cache.set(cacheKey, entry);\n }\n\n return createResponse(entry, response);\n } catch (error) {\n console.warn(\n `[css-doctor:remix] failed to optimize ${pathname}; serving the original asset: ${describeError(error)}`,\n );\n return response;\n }\n };\n\n return new Proxy(assetServer, {\n get(target, property) {\n if (property === \"fetch\") {\n return wrappedFetch;\n }\n\n const value = Reflect.get(target, property, target);\n\n return typeof value === \"function\" ? value.bind(target) : value;\n },\n }) as T;\n};\n\nfunction resolveOptions(options: CssDoctorRemixOptions): ResolvedOptions {\n const maxChunkSize =\n options.sizeBudget?.maxChunkSize ?? DEFAULT_MAX_CHUNK_SIZE;\n const maxEntries = options.cache?.maxEntries ?? DEFAULT_CACHE_ENTRIES;\n\n return {\n sizeBudget: {\n enabled: options.sizeBudget?.enabled ?? true,\n maxChunkSize:\n Number.isInteger(maxChunkSize) && maxChunkSize > 0\n ? maxChunkSize\n : DEFAULT_MAX_CHUNK_SIZE,\n isolateVariables: options.sizeBudget?.isolateVariables ?? true,\n },\n minify: options.minify ?? process.env.NODE_ENV === \"production\",\n cacheEnabled: options.cache?.enabled ?? true,\n cache: new CssCacheManager(\n Number.isInteger(maxEntries) && maxEntries > 0\n ? maxEntries\n : DEFAULT_CACHE_ENTRIES,\n ),\n exclude: options.exclude ?? [],\n };\n}\n\nfunction shouldSkipPath(\n pathname: string,\n exclude: Array<string | RegExp>,\n): boolean {\n if (CSS_MODULE_PATTERN.test(pathname)) {\n return true;\n }\n\n return exclude.some((pattern) =>\n typeof pattern === \"string\"\n ? pathname.includes(pattern)\n : pattern.test(pathname),\n );\n}\n\nfunction createResponse(entry: CssCacheEntry, original: Response): Response {\n return new Response(entry.body, {\n status: original.status,\n statusText: original.statusText,\n headers: new Headers(entry.headers),\n });\n}\n\nfunction hash(value: string): string {\n return createHash(\"sha256\").update(value).digest(\"hex\");\n}\n\nfunction describeError(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n","import type { CssCacheEntry } from \"./types\";\n\n/**\n * Small insertion-ordered LRU cache for optimized CSS responses.\n *\n * The map keeps the least-recently-used entry first. Reading an entry moves it\n * to the end; writing above capacity evicts the first entry.\n */\nexport class CssCacheManager {\n private readonly entries = new Map<string, CssCacheEntry>();\n private readonly maxEntries: number;\n\n constructor(maxEntries: number = 100) {\n this.maxEntries = Math.max(1, Math.floor(maxEntries));\n }\n\n get(key: string): CssCacheEntry | undefined {\n const entry = this.entries.get(key);\n\n if (!entry) {\n return undefined;\n }\n\n this.entries.delete(key);\n this.entries.set(key, entry);\n return entry;\n }\n\n set(key: string, entry: CssCacheEntry): void {\n if (this.entries.has(key)) {\n this.entries.delete(key);\n }\n\n this.entries.set(key, entry);\n\n while (this.entries.size > this.maxEntries) {\n const oldest = this.entries.keys().next().value;\n\n if (oldest === undefined) {\n break;\n }\n\n this.entries.delete(oldest);\n }\n }\n\n clear(): void {\n this.entries.clear();\n }\n\n get size(): number {\n return this.entries.size;\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zealakit/css-doctor-remix",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Remix 3 asset-server middleware for css-doctor CSS optimization",
|
|
5
|
+
"author": "nqhed - Huy Edward Nguyen",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"README_VI.md",
|
|
21
|
+
"README_CN.md"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@zealakit/css-doctor-binding": "1.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.20.5",
|
|
28
|
+
"tsup": "^8.5.1",
|
|
29
|
+
"typescript": "^5.9.3"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/zealakit/css-doctor.git",
|
|
37
|
+
"directory": "packages/css-doctor-remix"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/zealakit/css-doctor/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/zealakit/css-doctor#readme",
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup",
|
|
45
|
+
"dev": "tsup --watch",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"lint": "biome lint src",
|
|
48
|
+
"format": "biome format --write src",
|
|
49
|
+
"format:check": "biome format src",
|
|
50
|
+
"check": "biome check src"
|
|
51
|
+
}
|
|
6
52
|
}
|