Skip to content

Commit bd0e180

Browse files
committed
feat: enhance sourcemap uploader with batching and error handling options
1 parent 882f883 commit bd0e180

7 files changed

Lines changed: 262 additions & 19 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@sourcemaps/backend": patch
3+
---
4+
5+
Increase the public sourcemap ingest request body limit to 50MB to avoid 413 errors when uploading larger sourcemap payloads.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@faststats/sourcemap-uploader-plugin": patch
3+
---
4+
5+
Add upload payload batching with a configurable `maxUploadBodyBytes` limit and introduce a `failOnError` option to control whether upload failures should fail the build.

apps/backend/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ S3_ACCESS_KEY_ID=
99
S3_SECRET_ACCESS_KEY=
1010
HOST=0.0.0.0
1111
PORT=3000
12-
INTERNAL_HOST=127.0.0.1
12+
INTERNAL_HOST=[::]
1313
INTERNAL_PORT=9485
1414
ADMIN_TOKEN=replace-with-long-random-token

apps/backend/src/config.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ impl Config {
2323
.unwrap_or_else(|_| "3000".into())
2424
.parse()
2525
.expect("PORT must be a valid u16");
26-
let internal_host = std::env::var("INTERNAL_HOST").unwrap_or_else(|_| "127.0.0.1".into());
26+
let internal_host = std::env::var("INTERNAL_HOST").unwrap_or_else(|_| "[::]".into());
2727
let internal_port: u16 = std::env::var("INTERNAL_PORT")
2828
.unwrap_or_else(|_| "3001".into())
2929
.parse()
@@ -46,7 +46,7 @@ impl Config {
4646
s3_secret_access_key: std::env::var("S3_SECRET_ACCESS_KEY")?,
4747
listen_addr: SocketAddr::new(host.parse().expect("HOST must be a valid IP"), port),
4848
internal_listen_addr: SocketAddr::new(
49-
internal_host
49+
normalize_ip_literal(&internal_host)
5050
.parse()
5151
.expect("INTERNAL_HOST must be a valid IP"),
5252
internal_port,
@@ -55,3 +55,9 @@ impl Config {
5555
})
5656
}
5757
}
58+
59+
fn normalize_ip_literal(host: &str) -> &str {
60+
host.strip_prefix('[')
61+
.and_then(|h| h.strip_suffix(']'))
62+
.unwrap_or(host)
63+
}

apps/backend/src/routes.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use crate::SharedState;
1313
use crate::auth::{AdminAuthenticatedProject, AuthenticatedProject};
1414
use crate::error::AppError;
1515

16-
const INGEST_MAX_BODY_BYTES: usize = 50 * 1024 * 1024; // 50MB
16+
const INGEST_MAX_BODY_BYTES: usize = 50 * 1024 * 1024;
1717

1818
#[derive(Debug, Deserialize)]
1919
#[serde(rename_all = "camelCase")]

packages/bundler-plugin/src/index.ts

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
import { createUnplugin } from "unplugin";
1111

1212
const DEFAULT_ENDPOINT = "https://sourcemaps.faststats.dev/api/sourcemaps";
13+
const DEFAULT_MAX_UPLOAD_BODY_BYTES = 50 * 1024 * 1024;
1314

1415
type BundlerName =
1516
| "vite"
@@ -87,6 +88,8 @@ export type BundlerPluginOptions = {
8788
endpoint?: string;
8889
authToken?: string;
8990
buildId?: string;
91+
maxUploadBodyBytes?: number;
92+
failOnError?: boolean;
9093
deleteAfterUpload?: boolean;
9194
globalKey?: string;
9295
fetchImpl?: typeof fetch;
@@ -180,6 +183,72 @@ const postSourcemaps = async (
180183
}
181184
};
182185

186+
const payloadSizeBytes = (payload: SourcemapUploadPayload): number =>
187+
Buffer.byteLength(JSON.stringify(payload), "utf8");
188+
189+
const createUploadBatches = (
190+
buildId: string,
191+
bundler: BundlerName,
192+
sourcemaps: SourcemapUpload[],
193+
maxUploadBodyBytes: number,
194+
): SourcemapUploadPayload[] => {
195+
if (!Number.isFinite(maxUploadBodyBytes) || maxUploadBodyBytes <= 0) {
196+
throw new Error("maxUploadBodyBytes must be a positive number");
197+
}
198+
199+
const uploadedAt = new Date().toISOString();
200+
const batches: SourcemapUploadPayload[] = [];
201+
let currentBatch: SourcemapUpload[] = [];
202+
203+
const toPayload = (batch: SourcemapUpload[]): SourcemapUploadPayload => ({
204+
buildId,
205+
bundler,
206+
uploadedAt,
207+
sourcemaps: batch,
208+
});
209+
210+
for (const sourcemap of sourcemaps) {
211+
const nextBatch = [...currentBatch, sourcemap];
212+
const nextPayload = toPayload(nextBatch);
213+
214+
if (payloadSizeBytes(nextPayload) <= maxUploadBodyBytes) {
215+
currentBatch = nextBatch;
216+
continue;
217+
}
218+
219+
if (currentBatch.length === 0) {
220+
throw new Error(
221+
`Sourcemap "${sourcemap.fileName}" exceeds maxUploadBodyBytes limit`,
222+
);
223+
}
224+
225+
batches.push(toPayload(currentBatch));
226+
currentBatch = [sourcemap];
227+
228+
if (payloadSizeBytes(toPayload(currentBatch)) > maxUploadBodyBytes) {
229+
throw new Error(
230+
`Sourcemap "${sourcemap.fileName}" exceeds maxUploadBodyBytes limit`,
231+
);
232+
}
233+
}
234+
235+
if (currentBatch.length > 0) {
236+
batches.push(toPayload(currentBatch));
237+
}
238+
239+
return batches;
240+
};
241+
242+
const handleUploadError = async (
243+
options: BundlerPluginOptions,
244+
error: unknown,
245+
): Promise<void> => {
246+
await options.onUploadError?.(error);
247+
if (options.failOnError ?? true) {
248+
throw error;
249+
}
250+
};
251+
183252
const deleteFiles = async (
184253
baseDir: string,
185254
fileNames: string[],
@@ -293,15 +362,16 @@ const uploadAndMaybeDelete = async (
293362
return;
294363
}
295364

296-
const payload: SourcemapUploadPayload = {
365+
const batches = createUploadBatches(
297366
buildId,
298367
bundler,
299-
uploadedAt: new Date().toISOString(),
300368
sourcemaps,
301-
};
302-
303-
await postSourcemaps(options, payload);
304-
await options.onUploadSuccess?.(payload);
369+
options.maxUploadBodyBytes ?? DEFAULT_MAX_UPLOAD_BODY_BYTES,
370+
);
371+
for (const payload of batches) {
372+
await postSourcemaps(options, payload);
373+
await options.onUploadSuccess?.(payload);
374+
}
305375

306376
if (options.deleteAfterUpload && baseDirForDeletion) {
307377
await deleteFiles(
@@ -347,8 +417,7 @@ const rollupWriteBundle = (
347417
outputDir,
348418
);
349419
} catch (error) {
350-
await options.onUploadError?.(error);
351-
throw error;
420+
await handleUploadError(options, error);
352421
}
353422
};
354423

@@ -419,8 +488,7 @@ const applyWebpackLikeHooks = (
419488
}
420489
}
421490
} catch (error) {
422-
await options.onUploadError?.(error);
423-
throw error;
491+
await handleUploadError(options, error);
424492
}
425493
},
426494
);
@@ -522,8 +590,7 @@ const unpluginInstance = createUnplugin<BundlerPluginOptions>(
522590
outdir,
523591
);
524592
} catch (error) {
525-
await options.onUploadError?.(error);
526-
throw error;
593+
await handleUploadError(options, error);
527594
}
528595
});
529596
},
@@ -553,8 +620,7 @@ const unpluginInstance = createUnplugin<BundlerPluginOptions>(
553620
outputDir,
554621
);
555622
} catch (error) {
556-
await options.onUploadError?.(error);
557-
throw error;
623+
await handleUploadError(options, error);
558624
}
559625
},
560626
};

packages/bundler-plugin/tests/plugin.test.ts

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { tmpdir } from "node:os";
44
import { join, resolve } from "node:path";
55
import { rspack } from "@rspack/core";
66
import { build as esbuildBuild } from "esbuild";
7+
import type { NormalizedOutputOptions, OutputBundle } from "rollup";
78
import { rollup as createRollupBundle } from "rollup";
89
import { build as viteBuild } from "vite";
9-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
10+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1011
import webpack from "webpack";
1112
import bunPlugin from "../src/bun";
1213
import esbuildPlugin from "../src/esbuild";
@@ -273,6 +274,166 @@ describe("sourcemaps bundler plugin", () => {
273274
await rm(cwd, { recursive: true, force: true });
274275
});
275276

277+
it("batches uploads when payload exceeds maxUploadBodyBytes", async () => {
278+
const postedPayloads: UploadPayload[] = [];
279+
const fetchImpl = (async (
280+
_input: Parameters<typeof fetch>[0],
281+
init?: Parameters<typeof fetch>[1],
282+
) => {
283+
const body = init?.body;
284+
if (!body || typeof body !== "string") {
285+
throw new Error("Expected JSON string body");
286+
}
287+
postedPayloads.push(JSON.parse(body) as UploadPayload);
288+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
289+
}) as typeof fetch;
290+
const plugin = firstPlugin(
291+
rollupPlugin({
292+
endpoint,
293+
buildId: "batch-build-id",
294+
maxUploadBodyBytes: 600,
295+
fetchImpl,
296+
}),
297+
) as {
298+
writeBundle?: (
299+
outputOptions: NormalizedOutputOptions,
300+
bundle: OutputBundle,
301+
) => Promise<void>;
302+
};
303+
304+
if (!plugin.writeBundle) {
305+
throw new Error("Expected rollup writeBundle hook");
306+
}
307+
308+
const bundle = {
309+
"first.js.map": {
310+
type: "asset",
311+
fileName: "first.js.map",
312+
source: JSON.stringify({ version: 3, mappings: "AAAA".repeat(35) }),
313+
},
314+
"second.js.map": {
315+
type: "asset",
316+
fileName: "second.js.map",
317+
source: JSON.stringify({ version: 3, mappings: "BBBB".repeat(35) }),
318+
},
319+
"third.js.map": {
320+
type: "asset",
321+
fileName: "third.js.map",
322+
source: JSON.stringify({ version: 3, mappings: "CCCC".repeat(35) }),
323+
},
324+
} as unknown as OutputBundle;
325+
326+
await plugin.writeBundle(
327+
{ dir: process.cwd() } as NormalizedOutputOptions,
328+
bundle,
329+
);
330+
331+
expect(postedPayloads.length).toBeGreaterThan(1);
332+
expect(
333+
postedPayloads.every((payload) => payload.buildId === "batch-build-id"),
334+
).toBe(true);
335+
expect(
336+
postedPayloads.every((payload) => payload.bundler === "rollup"),
337+
).toBe(true);
338+
expect(
339+
postedPayloads.every((payload) => payload.sourcemaps.length > 0),
340+
).toBe(true);
341+
expect(
342+
postedPayloads.flatMap((payload) =>
343+
payload.sourcemaps.map((sourcemap) => sourcemap.fileName),
344+
),
345+
).toEqual(["first.js.map", "second.js.map", "third.js.map"]);
346+
});
347+
348+
it("throws on upload error by default", async () => {
349+
const onUploadError = vi.fn();
350+
const fetchImpl = (async (
351+
_input: Parameters<typeof fetch>[0],
352+
_init?: Parameters<typeof fetch>[1],
353+
) =>
354+
new Response(JSON.stringify({ ok: false }), {
355+
status: 500,
356+
})) as unknown as typeof fetch;
357+
const plugin = firstPlugin(
358+
rollupPlugin({
359+
endpoint,
360+
buildId: "fail-default-build-id",
361+
fetchImpl,
362+
onUploadError,
363+
}),
364+
) as {
365+
writeBundle?: (
366+
outputOptions: NormalizedOutputOptions,
367+
bundle: OutputBundle,
368+
) => Promise<void>;
369+
};
370+
371+
if (!plugin.writeBundle) {
372+
throw new Error("Expected rollup writeBundle hook");
373+
}
374+
375+
const bundle = {
376+
"bundle.js.map": {
377+
type: "asset",
378+
fileName: "bundle.js.map",
379+
source: JSON.stringify({ version: 3, mappings: "AAAA" }),
380+
},
381+
} as unknown as OutputBundle;
382+
383+
await expect(
384+
plugin.writeBundle(
385+
{ dir: process.cwd() } as NormalizedOutputOptions,
386+
bundle,
387+
),
388+
).rejects.toThrow("Sourcemap upload failed with status 500");
389+
expect(onUploadError).toHaveBeenCalledTimes(1);
390+
});
391+
392+
it("does not throw on upload error when failOnError is false", async () => {
393+
const onUploadError = vi.fn();
394+
const fetchImpl = (async (
395+
_input: Parameters<typeof fetch>[0],
396+
_init?: Parameters<typeof fetch>[1],
397+
) =>
398+
new Response(JSON.stringify({ ok: false }), {
399+
status: 500,
400+
})) as unknown as typeof fetch;
401+
const plugin = firstPlugin(
402+
rollupPlugin({
403+
endpoint,
404+
buildId: "fail-soft-build-id",
405+
fetchImpl,
406+
failOnError: false,
407+
onUploadError,
408+
}),
409+
) as {
410+
writeBundle?: (
411+
outputOptions: NormalizedOutputOptions,
412+
bundle: OutputBundle,
413+
) => Promise<void>;
414+
};
415+
416+
if (!plugin.writeBundle) {
417+
throw new Error("Expected rollup writeBundle hook");
418+
}
419+
420+
const bundle = {
421+
"bundle.js.map": {
422+
type: "asset",
423+
fileName: "bundle.js.map",
424+
source: JSON.stringify({ version: 3, mappings: "AAAA" }),
425+
},
426+
} as unknown as OutputBundle;
427+
428+
await expect(
429+
plugin.writeBundle(
430+
{ dir: process.cwd() } as NormalizedOutputOptions,
431+
bundle,
432+
),
433+
).resolves.toBeUndefined();
434+
expect(onUploadError).toHaveBeenCalledTimes(1);
435+
});
436+
276437
it("prefers webpack native build hash when buildId is not provided", async () => {
277438
const cwd = await mkdtemp(join(tmpdir(), "sourcemaps-webpack-native-id-"));
278439
await cp(join(fixturesDir, "webpack"), cwd, { recursive: true });

0 commit comments

Comments
 (0)