Skip to content

Commit 4daac9e

Browse files
committed
perf: 支持预压缩前端静态资源
直接托管前端时复用构建产出的 gzip 文件,减少传输体积且不引入请求时压缩。按 hash 文件名设置 immutable cache,并保留现有 SPA 与后端路由顺序。
1 parent 2b28e1b commit 4daac9e

5 files changed

Lines changed: 382 additions & 88 deletions

File tree

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sub-store",
3-
"version": "2.36.11",
3+
"version": "2.36.12",
44
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
55
"main": "src/main.js",
66
"packageManager": "pnpm@11.0.9",

backend/src/restful/index.js

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { gistBackupAction } from '@/restful/miscs';
1313
import { SETTINGS_KEY } from '@/constants';
1414
import { startArtifactCronJobs } from '@/utils/artifact-cron';
15+
import { createFrontendStaticMiddleware } from '@/utils/frontend-static';
1516

1617
import registerSubscriptionRoutes from './subscriptions';
1718
import registerCollectionRoutes from './collections';
@@ -46,6 +47,10 @@ export default function serve() {
4647
const be_prefix = eval('process.env.SUB_STORE_BACKEND_PREFIX');
4748
const fe_be_path = eval('process.env.SUB_STORE_FRONTEND_BACKEND_PATH');
4849
const fe_path = eval('process.env.SUB_STORE_FRONTEND_PATH');
50+
const mergedFrontend =
51+
be_merge && fe_path
52+
? createFrontendStaticMiddleware(fe_path, '/index.html')
53+
: null;
4954
if (be_prefix || be_merge) {
5055
if (!fe_be_path.startsWith('/')) {
5156
throw new Error(
@@ -101,25 +106,8 @@ export default function serve() {
101106
const isBackendRoute = /^\/(api|download|share)(\/|$)/.test(
102107
req.path,
103108
);
104-
if (be_merge && fe_path && !isBackendRoute) {
105-
const express_ = eval(`require("express")`);
106-
const mime_ = eval(`require("mime-types")`);
107-
const path_ = eval(`require("path")`);
108-
const fs_ = eval(`require("fs")`);
109-
// 检查请求的文件是否真实存在,不存在则返回 index.html(SPA 路由)
110-
const filePath = path_.join(fe_path, req.path);
111-
if (!fs_.existsSync(filePath)) {
112-
req.url = '/index.html';
113-
}
114-
const staticFileMiddleware = express_.static(fe_path, {
115-
setHeaders: (res, path) => {
116-
const type = mime_.contentType(path_.extname(path));
117-
if (type) {
118-
res.set('Content-Type', type);
119-
}
120-
},
121-
});
122-
staticFileMiddleware(req, res, next);
109+
if (mergedFrontend && !isBackendRoute) {
110+
mergedFrontend(req, res, next);
123111
return;
124112
}
125113
res.status(404).end();
@@ -368,7 +356,8 @@ export default function serve() {
368356

369357
const app = express_();
370358

371-
const staticFileMiddleware = express_.static(fe_path);
359+
const staticFileMiddleware =
360+
createFrontendStaticMiddleware(fe_path);
372361

373362
let be_api = '/api/';
374363
let be_download = '/download/';
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import { expect } from 'chai';
2+
import { afterEach, beforeEach, describe, it } from 'mocha';
3+
import fs from 'fs';
4+
import http from 'http';
5+
import os from 'os';
6+
import path from 'path';
7+
import zlib from 'zlib';
8+
9+
import history from 'connect-history-api-fallback';
10+
import express from 'express';
11+
12+
import {
13+
createFrontendStaticMiddleware,
14+
isHashedFrontendAsset,
15+
} from '@/utils/frontend-static';
16+
17+
const HOST = '127.0.0.1';
18+
19+
describe('frontend static middleware', function () {
20+
let tempDir;
21+
let frontendDir;
22+
let originalChunk;
23+
let compressedChunk;
24+
25+
beforeEach(function () {
26+
tempDir = fs.mkdtempSync(
27+
path.join(os.tmpdir(), 'sub-store-frontend-static-'),
28+
);
29+
frontendDir = path.join(tempDir, 'frontend');
30+
fs.mkdirSync(path.join(frontendDir, 'chunks'), { recursive: true });
31+
fs.mkdirSync(path.join(frontendDir, 'css'));
32+
33+
originalChunk = Buffer.from('console.log("original chunk")');
34+
compressedChunk = zlib.gzipSync(originalChunk);
35+
fs.writeFileSync(
36+
path.join(frontendDir, 'chunks/main-84da2e86.js'),
37+
originalChunk,
38+
);
39+
fs.writeFileSync(
40+
path.join(frontendDir, 'chunks/main-84da2e86.js.gz'),
41+
compressedChunk,
42+
);
43+
fs.writeFileSync(
44+
path.join(frontendDir, 'index.html'),
45+
'<main>SPA</main>',
46+
);
47+
fs.writeFileSync(path.join(frontendDir, 'index.js'), 'entry');
48+
fs.writeFileSync(path.join(frontendDir, 'css/main.css'), 'body{}');
49+
fs.writeFileSync(path.join(frontendDir, 'sw.js'), 'worker');
50+
fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'secret');
51+
});
52+
53+
afterEach(function () {
54+
fs.rmSync(tempDir, { recursive: true, force: true });
55+
});
56+
57+
it('serves a hashed gzip representation with logical asset headers', async function () {
58+
await withServer(createStaticApp(frontendDir), async (baseUrl) => {
59+
const response = await request(
60+
baseUrl,
61+
'/chunks/main-84da2e86.js?cache=1',
62+
{
63+
headers: { 'Accept-Encoding': 'gzip' },
64+
},
65+
);
66+
67+
expect(response.statusCode).to.equal(200);
68+
expect(response.body).to.deep.equal(compressedChunk);
69+
expect(response.headers['content-encoding']).to.equal('gzip');
70+
expect(response.headers['content-type']).to.match(/javascript/);
71+
expect(response.headers.vary).to.include('Accept-Encoding');
72+
expect(response.headers['content-length']).to.equal(
73+
`${compressedChunk.length}`,
74+
);
75+
expect(response.headers['cache-control']).to.equal(
76+
'public, max-age=31536000, immutable',
77+
);
78+
expect(response.headers.etag).to.be.a('string');
79+
expect(response.headers['last-modified']).to.equal(
80+
fs
81+
.statSync(
82+
path.join(frontendDir, 'chunks/main-84da2e86.js.gz'),
83+
)
84+
.mtime.toUTCString(),
85+
);
86+
87+
const conditional = await request(
88+
baseUrl,
89+
'/chunks/main-84da2e86.js',
90+
{
91+
headers: {
92+
'Accept-Encoding': 'gzip',
93+
'If-None-Match': response.headers.etag,
94+
},
95+
},
96+
);
97+
98+
expect(conditional.statusCode).to.equal(304);
99+
expect(conditional.body).to.have.length(0);
100+
101+
const replacement = zlib.gzipSync(
102+
Buffer.from('console.log("replacement chunk")'),
103+
);
104+
fs.writeFileSync(
105+
path.join(frontendDir, 'chunks/main-84da2e86.js.gz'),
106+
replacement,
107+
);
108+
const replaced = await request(
109+
baseUrl,
110+
'/chunks/main-84da2e86.js',
111+
{ headers: { 'Accept-Encoding': 'gzip' } },
112+
);
113+
114+
expect(replaced.body).to.deep.equal(replacement);
115+
});
116+
});
117+
118+
it('honors identity preferences and falls back when gzip is absent', async function () {
119+
await withServer(createStaticApp(frontendDir), async (baseUrl) => {
120+
for (const acceptEncoding of [
121+
undefined,
122+
'gzip;q=0',
123+
'gzip;q=0.5, identity;q=1',
124+
]) {
125+
const headers = acceptEncoding
126+
? { 'Accept-Encoding': acceptEncoding }
127+
: {};
128+
const response = await request(
129+
baseUrl,
130+
'/chunks/main-84da2e86.js',
131+
{ headers },
132+
);
133+
134+
expect(response.body).to.deep.equal(originalChunk);
135+
expect(response.headers['content-encoding']).to.equal(
136+
undefined,
137+
);
138+
expect(response.headers['cache-control']).to.equal(
139+
'public, max-age=31536000, immutable',
140+
);
141+
}
142+
143+
const fallback = await request(baseUrl, '/index.js?cache=1', {
144+
headers: { 'Accept-Encoding': 'gzip' },
145+
});
146+
147+
expect(fallback.body.toString()).to.equal('entry');
148+
expect(fallback.headers['content-encoding']).to.equal(undefined);
149+
expect(fallback.headers['cache-control']).to.equal(
150+
'public, max-age=0',
151+
);
152+
expect(fallback.headers.etag).to.be.a('string');
153+
});
154+
});
155+
156+
it('returns gzip metadata without a body for HEAD', async function () {
157+
await withServer(createStaticApp(frontendDir), async (baseUrl) => {
158+
const response = await request(
159+
baseUrl,
160+
'/chunks/main-84da2e86.js',
161+
{
162+
method: 'HEAD',
163+
headers: { 'Accept-Encoding': 'gzip' },
164+
},
165+
);
166+
167+
expect(response.statusCode).to.equal(200);
168+
expect(response.body).to.have.length(0);
169+
expect(response.headers['content-encoding']).to.equal('gzip');
170+
expect(response.headers['content-length']).to.equal(
171+
`${compressedChunk.length}`,
172+
);
173+
});
174+
});
175+
176+
it('keeps stable names revalidatable and blocks traversal', async function () {
177+
expect(
178+
isHashedFrontendAsset('C:\\dist\\release-deadbeef.dir\\index.html'),
179+
).to.equal(false);
180+
181+
const compressedCss = zlib.gzipSync(Buffer.from('body{}'));
182+
fs.writeFileSync(
183+
path.join(frontendDir, 'css/main.css.gz'),
184+
compressedCss,
185+
);
186+
187+
await withServer(createStaticApp(frontendDir), async (baseUrl) => {
188+
for (const pathname of ['/index.html', '/sw.js', '/css/main.css']) {
189+
const response = await request(baseUrl, pathname, {
190+
headers: { 'Accept-Encoding': 'gzip' },
191+
});
192+
193+
expect(response.headers['cache-control']).to.equal(
194+
'public, max-age=0',
195+
);
196+
expect(response.headers.etag).to.be.a('string');
197+
}
198+
199+
const traversal = await request(baseUrl, '/%2e%2e%2fsecret.txt');
200+
201+
expect(traversal.statusCode).to.equal(404);
202+
expect(traversal.body.toString()).not.to.equal('secret');
203+
});
204+
});
205+
206+
it('preserves merged and standalone SPA routing', async function () {
207+
const merged = express();
208+
const mergedFrontend = createFrontendStaticMiddleware(
209+
frontendDir,
210+
'/index.html',
211+
);
212+
merged.use((req, res, next) => {
213+
if (/^\/(api|download|share)(\/|$)/.test(req.path)) {
214+
next();
215+
return;
216+
}
217+
mergedFrontend(req, res, next);
218+
});
219+
merged.use(['/api', '/download', '/share'], (req, res) =>
220+
res.status(204).end(),
221+
);
222+
223+
await withServer(merged, async (baseUrl) => {
224+
const spa = await request(baseUrl, '/settings', {
225+
headers: { Accept: 'text/html' },
226+
});
227+
228+
expect(spa.body.toString()).to.equal('<main>SPA</main>');
229+
for (const prefix of ['/api', '/download', '/share']) {
230+
const backend = await request(baseUrl, `${prefix}/settings`);
231+
expect(backend.statusCode).to.equal(204);
232+
}
233+
});
234+
235+
const standalone = express();
236+
const staticFiles = createFrontendStaticMiddleware(frontendDir);
237+
standalone.use(staticFiles);
238+
standalone.use(history({ disableDotRule: true, verbose: false }));
239+
standalone.use(staticFiles);
240+
241+
await withServer(standalone, async (baseUrl) => {
242+
const spa = await request(baseUrl, '/settings', {
243+
headers: { Accept: 'text/html' },
244+
});
245+
expect(spa.body.toString()).to.equal('<main>SPA</main>');
246+
});
247+
});
248+
});
249+
250+
function createStaticApp(root) {
251+
const app = express();
252+
app.use(createFrontendStaticMiddleware(root));
253+
return app;
254+
}
255+
256+
async function withServer(app, run) {
257+
const server = await listen(app);
258+
const { port } = server.address();
259+
260+
try {
261+
await run(`http://${HOST}:${port}`);
262+
} finally {
263+
await close(server);
264+
}
265+
}
266+
267+
function listen(app) {
268+
return new Promise((resolve) => {
269+
const server = app.listen(0, HOST, () => resolve(server));
270+
});
271+
}
272+
273+
function close(server) {
274+
return new Promise((resolve, reject) => {
275+
server.close((error) => {
276+
if (error) reject(error);
277+
else resolve();
278+
});
279+
});
280+
}
281+
282+
function request(baseUrl, pathname, { method = 'GET', headers = {} } = {}) {
283+
return new Promise((resolve, reject) => {
284+
const req = http.request(
285+
`${baseUrl}${pathname}`,
286+
{ method, headers },
287+
(res) => {
288+
const chunks = [];
289+
res.on('data', (chunk) => chunks.push(chunk));
290+
res.on('end', () => {
291+
resolve({
292+
statusCode: res.statusCode,
293+
headers: res.headers,
294+
body: Buffer.concat(chunks),
295+
});
296+
});
297+
},
298+
);
299+
req.on('error', reject);
300+
req.end();
301+
});
302+
}

0 commit comments

Comments
 (0)