Skip to content

Commit 31201ef

Browse files
sunnylqmclaude
andcommitted
feat(sourcemap): 归档前 gzip 压缩
sourcemap 是 JSON,压缩比约 5:1:瘦身后 3.1MB 的 map gzip 后仅 0.94MB(相对原始 9.2MB 缩掉 90%),每次 publish 少传一个数量级。 临时文件仍以 .map 结尾(/upload 按扩展名路由额度),读取端一律按 gzip magic bytes 判断,因此明文归档(旧 CLI 发的版本)继续可读。 `pushy symbolicate` 同步支持两种归档。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkBW5Sa4doZiwQtBfzr3GE
1 parent 7be4c90 commit 31201ef

4 files changed

Lines changed: 88 additions & 21 deletions

File tree

src/symbolicate.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { get } from './api';
55
import { getPlatform, getSelectedApp } from './app';
66
import type { Platform, Version } from './types';
77
import { t } from './utils/i18n';
8+
import { unpackSourceMap } from './utils/slim-sourcemap';
89
import { fetchVersions } from './versions';
910

1011
// A frame location inside a shipped bundle: `<bundle>:<line>:<column>`. Covers
@@ -137,7 +138,11 @@ export const symbolicateCommands = {
137138
if (!response.ok) {
138139
throw new Error(`Failed to download source map: HTTP ${response.status}`);
139140
}
140-
const rawMap = JSON.parse(await response.text());
141+
// Maps archived by recent CLI versions are gzipped; older ones are plain
142+
// JSON, and unpackSourceMap accepts both.
143+
const rawMap = JSON.parse(
144+
unpackSourceMap(Buffer.from(await response.arrayBuffer())),
145+
);
141146
const consumer = new SourceMapConsumer(rawMap);
142147

143148
const stack = await readStack(args[0]);

src/utils/slim-sourcemap.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from 'path';
2+
import zlib from 'zlib';
23

34
interface SourceMapV3 {
45
sources?: unknown;
@@ -81,3 +82,24 @@ function isDependencySource(source: string): boolean {
8182
source.startsWith('node_modules/') || source.includes('/node_modules/')
8283
);
8384
}
85+
86+
/**
87+
* Build the bytes actually archived with a version: the slimmed map (when it
88+
* is a plain map we understand), gzipped. Source maps are JSON and compress
89+
* about 5x, which is the difference between a multi-megabyte upload on every
90+
* publish and a small one. Readers detect the gzip magic bytes rather than
91+
* trusting a file name, so plain maps archived by older CLI versions keep
92+
* working unchanged.
93+
*/
94+
export function packSourceMap(content: string, projectRoot: string): Buffer {
95+
const slimmed = slimSourceMap(content, projectRoot);
96+
return zlib.gzipSync(Buffer.from(slimmed ?? content, 'utf8'));
97+
}
98+
99+
/** Decode an archived source map: gzip when the magic bytes say so. */
100+
export function unpackSourceMap(data: Buffer): string {
101+
if (data.length >= 2 && data[0] === 0x1f && data[1] === 0x8b) {
102+
return zlib.gunzipSync(data).toString('utf8');
103+
}
104+
return data.toString('utf8');
105+
}

src/versions.ts

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
} from './utils/hermes-base';
2121
import { t } from './utils/i18n';
2222
import { getBooleanOption, getStringListOption } from './utils/options';
23-
import { slimSourceMap } from './utils/slim-sourcemap';
23+
import { packSourceMap } from './utils/slim-sourcemap';
2424
import {
2525
bundleLocationFields,
2626
readZipEntryWithLocation,
@@ -546,25 +546,23 @@ export const versionCommands = {
546546
if (!sourcemapPath) {
547547
console.log(chalk.yellow(t('sourceMapMissingWarning')));
548548
}
549-
// Archive a slimmed copy: relative paths, no dependency sourcesContent
550-
// (see slimSourceMap). Frames keep symbolicating everywhere; only inline
551-
// snippets of node_modules code are dropped, and the map shrinks by the
552-
// bulk of its bytes. If the file is not a plain map, upload it untouched.
549+
// Archive a slimmed, gzipped copy (see packSourceMap): relative paths, no
550+
// dependency sourcesContent, ~5x smaller on the wire. Frames keep
551+
// symbolicating everywhere; only inline snippets of node_modules code are
552+
// dropped. The temp file keeps the .map extension because /upload routes
553+
// by extension; readers detect gzip by magic bytes.
553554
let uploadSourcemapPath = sourcemapPath;
554-
let slimTempDir: string | undefined;
555+
let packTempDir: string | undefined;
555556
if (sourcemapPath) {
556-
const slimmed = slimSourceMap(
557-
fs.readFileSync(sourcemapPath, 'utf8'),
558-
process.cwd(),
557+
packTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-sourcemap-'));
558+
uploadSourcemapPath = path.join(
559+
packTempDir,
560+
path.basename(sourcemapPath),
561+
);
562+
fs.writeFileSync(
563+
uploadSourcemapPath,
564+
packSourceMap(fs.readFileSync(sourcemapPath, 'utf8'), process.cwd()),
559565
);
560-
if (slimmed !== null) {
561-
slimTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnu-sourcemap-'));
562-
uploadSourcemapPath = path.join(
563-
slimTempDir,
564-
path.basename(sourcemapPath),
565-
);
566-
fs.writeFileSync(uploadSourcemapPath, slimmed);
567-
}
568566
}
569567

570568
// Hashing/caching the bundle and asking git for the commit are independent
@@ -588,8 +586,8 @@ export const versionCommands = {
588586
)
589587
: Promise.resolve(undefined),
590588
]);
591-
if (slimTempDir) {
592-
fs.rmSync(slimTempDir, { recursive: true, force: true });
589+
if (packTempDir) {
590+
fs.rmSync(packTempDir, { recursive: true, force: true });
593591
}
594592
const sourceMapKey = sourceMapUpload?.hash;
595593
if (sourcemapPath && !sourceMapKey) {

tests/slim-sourcemap.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, test } from 'bun:test';
2-
import { slimSourceMap } from '../src/utils/slim-sourcemap';
2+
import zlib from 'zlib';
3+
import {
4+
packSourceMap,
5+
slimSourceMap,
6+
unpackSourceMap,
7+
} from '../src/utils/slim-sourcemap';
38

49
const root = '/work/app';
510

@@ -72,3 +77,40 @@ describe('slimSourceMap', () => {
7277
expect(slimSourceMap(JSON.stringify([1, 2]), root)).toBeNull();
7378
});
7479
});
80+
81+
describe('packSourceMap / unpackSourceMap', () => {
82+
const map = JSON.stringify({
83+
version: 3,
84+
sources: ['/work/app/src/a.ts', '/work/app/node_modules/lib/b.js'],
85+
sourcesContent: ['app code', 'dependency code'],
86+
mappings: 'AAAA',
87+
});
88+
89+
test('gzips the slimmed map and round-trips through unpack', () => {
90+
const packed = packSourceMap(map, root);
91+
expect(packed[0]).toBe(0x1f);
92+
expect(packed[1]).toBe(0x8b);
93+
expect(packed.length).toBeLessThan(Buffer.byteLength(map));
94+
const restored = JSON.parse(unpackSourceMap(packed));
95+
expect(restored.sources).toEqual(['src/a.ts', 'node_modules/lib/b.js']);
96+
expect(restored.sourcesContent).toEqual(['app code', null]);
97+
});
98+
99+
test('gzips unslimmable input unchanged rather than dropping it', () => {
100+
const indexed = JSON.stringify({ version: 3, sections: [] });
101+
const packed = packSourceMap(indexed, root);
102+
expect(unpackSourceMap(packed)).toBe(indexed);
103+
});
104+
105+
test('unpack accepts plain maps archived by older CLI versions', () => {
106+
expect(unpackSourceMap(Buffer.from(map, 'utf8'))).toBe(map);
107+
});
108+
109+
test('unpack rejects corrupt gzip instead of returning garbage', () => {
110+
const broken = Buffer.concat([
111+
zlib.gzipSync(Buffer.from(map)).subarray(0, 20),
112+
Buffer.from([0, 0, 0, 0]),
113+
]);
114+
expect(() => unpackSourceMap(broken)).toThrow();
115+
});
116+
});

0 commit comments

Comments
 (0)