修复在线游玩加载体验:加载期黑屏/空白与发行资源传输开销
- packages/shared 新增共享游戏加载面 PlatformGameLoadingSurface:封面/标题 + 动效进度 + 按时长推进的阶段文案(准备/资源/引擎)+ 慢加载提示,附用例 - 网页游玩页用共享加载面替换近黑壳上的一行小字,加载上限改为共享常量 PLATFORM_GAME_LOADING_TIMEOUT_MS - 后台审核试玩补加载面、20 秒超时与「重新创建试玩会话」入口,新会话重置加载态;补两条用例 - 发行网关(api-server)对文本类发行资源按 Accept-Encoding 下发 gzip,并下发强 ETag、命中 If-None-Match 返回 304;补 5 条 Rust 用例 - E2E 脚本 check-game-distribution-ops-rollback-e2e 补压缩/Vary/ETag/304/不接受 gzip 四条断言 - 同步 pitfalls、开发运维文档、nginx README 与两份里程碑记录
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-ops-rollback-e2e
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
@@ -99,6 +100,37 @@ async function release(pathname) {
|
||||
return { status: response.status, body, headers: response.headers };
|
||||
}
|
||||
|
||||
/// 原样请求(不自动解压):压缩与条件请求必须看真实线字节,不能让 fetch 帮忙解码。
|
||||
function releaseRaw(pathname, { acceptEncoding, ifNoneMatch } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const target = new URL(`${API}${pathname}`);
|
||||
const headers = {};
|
||||
if (acceptEncoding) headers['accept-encoding'] = acceptEncoding;
|
||||
if (ifNoneMatch) headers['if-none-match'] = ifNoneMatch;
|
||||
const request = http.request(
|
||||
{
|
||||
hostname: target.hostname,
|
||||
port: target.port,
|
||||
path: `${target.pathname}${target.search}`,
|
||||
headers,
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () =>
|
||||
resolve({
|
||||
status: response.statusCode,
|
||||
headers: response.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function adminToken() {
|
||||
const login = await api('/admin/api/login', {
|
||||
method: 'POST',
|
||||
@@ -397,6 +429,47 @@ async function main() {
|
||||
);
|
||||
assertReleaseHeaders('v1 CSS', baselineCss, { html: false });
|
||||
|
||||
// 传输与条件请求契约:引擎包动辄 1 MiB 以上,文本资源必须按 Accept-Encoding 压缩,
|
||||
// 并在 60 秒缓存窗口之后靠 ETag 重验证(304)而不是重下整包。
|
||||
const releasePath = `/api/game-distribution/releases/${gameId}/index.html`;
|
||||
const compressedHtml = await releaseRaw(releasePath, {
|
||||
acceptEncoding: 'gzip',
|
||||
});
|
||||
check(
|
||||
'发行文本资源按 Accept-Encoding 压缩并声明 Vary',
|
||||
compressedHtml.status === 200 &&
|
||||
compressedHtml.headers['content-encoding'] === 'gzip' &&
|
||||
String(compressedHtml.headers.vary ?? '').includes('accept-encoding') &&
|
||||
compressedHtml.body.length < Buffer.byteLength(baselineRelease.body),
|
||||
`encoding=${compressedHtml.headers['content-encoding'] ?? ''} vary=${compressedHtml.headers.vary ?? ''} bytes=${compressedHtml.body.length}/${Buffer.byteLength(baselineRelease.body)}`,
|
||||
);
|
||||
const releaseEtag = baselineRelease.headers.get('etag') ?? '';
|
||||
check(
|
||||
'发行资源下发强 ETag',
|
||||
/^"[0-9a-f]{32}"$/u.test(releaseEtag),
|
||||
`etag=${releaseEtag}`,
|
||||
);
|
||||
const revalidated = await releaseRaw(releasePath, {
|
||||
ifNoneMatch: releaseEtag,
|
||||
});
|
||||
check(
|
||||
'If-None-Match 命中时返回 304 且不带正文',
|
||||
revalidated.status === 304 &&
|
||||
revalidated.body.length === 0 &&
|
||||
revalidated.headers['cache-control'] === RELEASE_CACHE_CONTROL &&
|
||||
revalidated.headers.etag === releaseEtag,
|
||||
`status=${revalidated.status} body=${revalidated.body.length} cache-control=${revalidated.headers['cache-control'] ?? ''}`,
|
||||
);
|
||||
const identityHtml = await releaseRaw(releasePath, {
|
||||
acceptEncoding: 'identity',
|
||||
});
|
||||
check(
|
||||
'客户端不接受 gzip 时不下发压缩体',
|
||||
identityHtml.status === 200 &&
|
||||
identityHtml.headers['content-encoding'] === undefined,
|
||||
`encoding=${identityHtml.headers['content-encoding'] ?? ''}`,
|
||||
);
|
||||
|
||||
const pendingIds = {};
|
||||
for (const marker of ['V2-OK', 'V3-OK']) {
|
||||
const built = await buildPackage(marker);
|
||||
|
||||
Reference in New Issue
Block a user