1- import http from 'node:http ' ;
1+ import { execFileSync } from 'node:child_process ' ;
22import fs from 'node:fs' ;
3- import zlib from 'node:zlib ' ;
3+ import http from 'node:http ' ;
44import path from 'node:path' ;
5- import { execFileSync } from 'node:child_process ' ;
6- import type { RequestRecord } from './sourcemap-upload-assertions' ;
5+ import zlib from 'node:zlib ' ;
6+ import type { ChunkFileRecord , RequestRecord } from './sourcemap-upload-assertions' ;
77
88export interface MockSentryServerOptions {
99 port ?: number ;
@@ -32,16 +32,16 @@ function parseMultipartParts(body: Buffer, boundary: string): { headers: string;
3232 if ( idx === - 1 ) break ;
3333
3434 const afterBoundary = idx + boundaryBuf . length ;
35- if ( body . slice ( afterBoundary , afterBoundary + 2 ) . toString ( ) === '--' ) break ;
35+ if ( body . subarray ( afterBoundary , afterBoundary + 2 ) . toString ( ) === '--' ) break ;
3636
3737 const headerEnd = body . indexOf ( '\r\n\r\n' , afterBoundary ) ;
3838 if ( headerEnd === - 1 ) break ;
3939
40- const headerStr = body . slice ( afterBoundary , headerEnd ) . toString ( ) ;
40+ const headerStr = body . subarray ( afterBoundary , headerEnd ) . toString ( ) ;
4141
4242 const nextBoundary = body . indexOf ( boundaryBuf , headerEnd + 4 ) ;
4343 const contentEnd = nextBoundary !== - 1 ? nextBoundary - 2 : body . length ;
44- const content = body . slice ( headerEnd + 4 , contentEnd ) ;
44+ const content = body . subarray ( headerEnd + 4 , contentEnd ) ;
4545
4646 parts . push ( { headers : headerStr , content } ) ;
4747 start = nextBoundary !== - 1 ? nextBoundary : body . length ;
@@ -50,6 +50,130 @@ function parseMultipartParts(body: Buffer, boundary: string): { headers: string;
5050 return parts ;
5151}
5252
53+ /**
54+ * Extract and inspect a single multipart chunk: decompress, unzip, read manifest.
55+ */
56+ function extractChunkPart ( partContent : Buffer , outputDir : string , chunkIndex : number , partIndex : number ) : ChunkFileRecord {
57+ const bundleDir = path . join ( outputDir , `bundle_${ chunkIndex } _${ partIndex } ` ) ;
58+
59+ // Try to decompress (sentry-cli gzips chunks)
60+ let zipBuffer : Buffer ;
61+ try {
62+ zipBuffer = zlib . gunzipSync ( partContent ) ;
63+ } catch {
64+ zipBuffer = partContent ;
65+ }
66+
67+ const zipFile = `${ bundleDir } .zip` ;
68+ fs . writeFileSync ( zipFile , zipBuffer ) ;
69+
70+ // Extract the zip to inspect contents
71+ try {
72+ fs . mkdirSync ( bundleDir , { recursive : true } ) ;
73+ execFileSync ( 'unzip' , [ '-q' , '-o' , zipFile , '-d' , bundleDir ] , { stdio : 'ignore' } ) ;
74+
75+ // Read manifest.json if present
76+ const manifestPath = path . join ( bundleDir , 'manifest.json' ) ;
77+ if ( fs . existsSync ( manifestPath ) ) {
78+ const manifest = JSON . parse ( fs . readFileSync ( manifestPath , 'utf-8' ) ) as { files ?: Record < string , unknown > } ;
79+ return {
80+ bundleDir,
81+ manifest : manifest as ChunkFileRecord [ 'manifest' ] ,
82+ fileCount : Object . keys ( manifest . files || { } ) . length ,
83+ } ;
84+ }
85+ return { bundleDir, note : 'no manifest.json found' } ;
86+ } catch ( err : unknown ) {
87+ return {
88+ zipFile,
89+ note : `extraction failed: ${ err instanceof Error ? err . message : String ( err ) } ` ,
90+ } ;
91+ }
92+ }
93+
94+ /**
95+ * Process a chunk upload POST request: parse multipart body, extract each part.
96+ */
97+ function processChunkUpload (
98+ record : RequestRecord ,
99+ body : Buffer ,
100+ contentType : string ,
101+ outputDir : string ,
102+ chunkIndex : number ,
103+ ) : number {
104+ record . hasBody = true ;
105+ record . chunkFiles = [ ] ;
106+
107+ const boundaryMatch = contentType . match ( / b o u n d a r y = ( .+ ) / ) ;
108+ if ( ! boundaryMatch ) {
109+ return chunkIndex ;
110+ }
111+
112+ // boundaryMatch[1] is guaranteed to exist since the regex matched
113+ const parts = parseMultipartParts ( body , boundaryMatch [ 1 ] as string ) ;
114+ let nextChunkIndex = chunkIndex ;
115+ for ( let i = 0 ; i < parts . length ; i ++ ) {
116+ // parts[i] is guaranteed to exist within the loop bounds
117+ const part = parts [ i ] as { headers : string ; content : Buffer } ;
118+ record . chunkFiles . push ( extractChunkPart ( part . content , outputDir , nextChunkIndex , i ) ) ;
119+ nextChunkIndex ++ ;
120+ }
121+
122+ return nextChunkIndex ;
123+ }
124+
125+ /**
126+ * Send the appropriate mock response based on the request URL.
127+ */
128+ function sendResponse (
129+ req : http . IncomingMessage ,
130+ res : http . ServerResponse ,
131+ port : number ,
132+ org : string ,
133+ ) : void {
134+ const url = req . url || '' ;
135+
136+ if ( url . includes ( '/artifactbundle/assemble/' ) ) {
137+ res . writeHead ( 200 , { 'Content-Type' : 'application/json' } ) ;
138+ res . end ( JSON . stringify ( { state : 'created' , missingChunks : [ ] } ) ) ;
139+ } else if ( url . includes ( '/chunk-upload/' ) ) {
140+ if ( req . method === 'GET' ) {
141+ res . writeHead ( 200 , { 'Content-Type' : 'application/json' } ) ;
142+ res . end (
143+ JSON . stringify ( {
144+ url : `http://localhost:${ port } /api/0/organizations/${ org } /chunk-upload/` ,
145+ chunkSize : 8388608 ,
146+ chunksPerRequest : 64 ,
147+ maxFileSize : 2147483648 ,
148+ maxRequestSize : 33554432 ,
149+ concurrency : 1 ,
150+ hashAlgorithm : 'sha1' ,
151+ compression : [ 'gzip' ] ,
152+ accept : [
153+ 'debug_files' ,
154+ 'release_files' ,
155+ 'pdbs' ,
156+ 'sources' ,
157+ 'bcsymbolmaps' ,
158+ 'il2cpp' ,
159+ 'portablepdbs' ,
160+ 'artifact_bundles' ,
161+ ] ,
162+ } ) ,
163+ ) ;
164+ } else {
165+ res . writeHead ( 200 , { 'Content-Type' : 'application/json' } ) ;
166+ res . end ( JSON . stringify ( { } ) ) ;
167+ }
168+ } else if ( url . includes ( '/releases/' ) ) {
169+ res . writeHead ( 201 , { 'Content-Type' : 'application/json' } ) ;
170+ res . end ( JSON . stringify ( { version : 'test-release' , dateCreated : new Date ( ) . toISOString ( ) } ) ) ;
171+ } else {
172+ res . writeHead ( 200 , { 'Content-Type' : 'application/json' } ) ;
173+ res . end ( JSON . stringify ( { ok : true } ) ) ;
174+ }
175+ }
176+
53177/**
54178 * Starts a mock Sentry server that captures sourcemap upload requests.
55179 *
@@ -91,54 +215,7 @@ export function startMockSentryServer(options: MockSentryServerOptions = {}): Mo
91215
92216 // For chunk upload POSTs, save and extract artifact bundles
93217 if ( req . url ?. includes ( 'chunk-upload' ) && req . method === 'POST' && body . length > 0 ) {
94- record . hasBody = true ;
95- record . chunkFiles = [ ] ;
96-
97- const boundaryMatch = contentType . match ( / b o u n d a r y = ( .+ ) / ) ;
98- if ( boundaryMatch ) {
99- const parts = parseMultipartParts ( body , boundaryMatch [ 1 ] ! ) ;
100- for ( let i = 0 ; i < parts . length ; i ++ ) {
101- const part = parts [ i ] ! ;
102- const bundleDir = path . join ( outputDir , `bundle_${ chunkIndex } _${ i } ` ) ;
103-
104- // Try to decompress (sentry-cli gzips chunks)
105- let zipBuffer : Buffer ;
106- try {
107- zipBuffer = zlib . gunzipSync ( part . content ) ;
108- } catch {
109- zipBuffer = part . content ;
110- }
111-
112- const zipFile = `${ bundleDir } .zip` ;
113- fs . writeFileSync ( zipFile , zipBuffer ) ;
114-
115- // Extract the zip to inspect contents
116- try {
117- fs . mkdirSync ( bundleDir , { recursive : true } ) ;
118- execFileSync ( 'unzip' , [ '-q' , '-o' , zipFile , '-d' , bundleDir ] , { stdio : 'ignore' } ) ;
119-
120- // Read manifest.json if present
121- const manifestPath = path . join ( bundleDir , 'manifest.json' ) ;
122- if ( fs . existsSync ( manifestPath ) ) {
123- const manifest = JSON . parse ( fs . readFileSync ( manifestPath , 'utf-8' ) ) ;
124- record . chunkFiles . push ( {
125- bundleDir,
126- manifest,
127- fileCount : Object . keys ( manifest . files || { } ) . length ,
128- } ) ;
129- } else {
130- record . chunkFiles . push ( { bundleDir, note : 'no manifest.json found' } ) ;
131- }
132- } catch ( err : unknown ) {
133- record . chunkFiles . push ( {
134- zipFile,
135- note : `extraction failed: ${ err instanceof Error ? err . message : String ( err ) } ` ,
136- } ) ;
137- }
138-
139- chunkIndex ++ ;
140- }
141- }
218+ chunkIndex = processChunkUpload ( record , body , contentType , outputDir , chunkIndex ) ;
142219 }
143220
144221 // For artifact bundle assemble, capture the request body
@@ -155,54 +232,12 @@ export function startMockSentryServer(options: MockSentryServerOptions = {}): Mo
155232 // Write all collected requests to the output file after each request
156233 fs . writeFileSync ( outputFile , JSON . stringify ( requests , null , 2 ) ) ;
157234
158- // Route responses — order matters: most specific first
159- const url = req . url || '' ;
160-
161- if ( url . includes ( '/artifactbundle/assemble/' ) ) {
162- res . writeHead ( 200 , { 'Content-Type' : 'application/json' } ) ;
163- res . end ( JSON . stringify ( { state : 'created' , missingChunks : [ ] } ) ) ;
164- } else if ( url . includes ( '/chunk-upload/' ) ) {
165- if ( req . method === 'GET' ) {
166- res . writeHead ( 200 , { 'Content-Type' : 'application/json' } ) ;
167- res . end (
168- JSON . stringify ( {
169- url : `http://localhost:${ port } /api/0/organizations/${ org } /chunk-upload/` ,
170- chunkSize : 8388608 ,
171- chunksPerRequest : 64 ,
172- maxFileSize : 2147483648 ,
173- maxRequestSize : 33554432 ,
174- concurrency : 1 ,
175- hashAlgorithm : 'sha1' ,
176- compression : [ 'gzip' ] ,
177- accept : [
178- 'debug_files' ,
179- 'release_files' ,
180- 'pdbs' ,
181- 'sources' ,
182- 'bcsymbolmaps' ,
183- 'il2cpp' ,
184- 'portablepdbs' ,
185- 'artifact_bundles' ,
186- ] ,
187- } ) ,
188- ) ;
189- } else {
190- res . writeHead ( 200 , { 'Content-Type' : 'application/json' } ) ;
191- res . end ( JSON . stringify ( { } ) ) ;
192- }
193- } else if ( url . includes ( '/releases/' ) ) {
194- res . writeHead ( 201 , { 'Content-Type' : 'application/json' } ) ;
195- res . end ( JSON . stringify ( { version : 'test-release' , dateCreated : new Date ( ) . toISOString ( ) } ) ) ;
196- } else {
197- res . writeHead ( 200 , { 'Content-Type' : 'application/json' } ) ;
198- res . end ( JSON . stringify ( { ok : true } ) ) ;
199- }
235+ sendResponse ( req , res , port , org ) ;
200236 } ) ;
201237 } ) ;
202238
203- server . listen ( port , ( ) => {
204- console . log ( `Mock Sentry server listening on port ${ port } ` ) ;
205- } ) ;
239+ // eslint-disable-next-line no-console
240+ server . listen ( port , ( ) => console . log ( `Mock Sentry server listening on port ${ port } ` ) ) ;
206241
207242 return {
208243 port,
0 commit comments