json-size-profiler.mjs (23390B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /** 6 * Parses a JSON string and creates a Firefox profiler profile describing 7 * which parts of the JSON use up how many bytes. 8 */ 9 10 // Categories for different JSON types 11 const JSON_CATEGORIES = { 12 OBJECT: { name: "Object", color: "grey" }, 13 ARRAY: { name: "Array", color: "grey" }, 14 NULL: { name: "Null", color: "yellow" }, 15 BOOL: { name: "Bool", color: "brown" }, 16 NUMBER: { name: "Number", color: "green" }, 17 STRING: { name: "String", color: "blue" }, 18 PROPERTY_KEY: { name: "Property Key", color: "lightblue" }, 19 }; 20 21 const MAX_SAMPLE_COUNT = 100000; 22 23 class JsonSizeProfiler { 24 /** 25 * @param {string} jsonString - The JSON string to profile. 26 * @param {string} [filename] - Optional filename for the profile metadata. 27 */ 28 constructor(jsonString, filename) { 29 this.jsonString = jsonString; 30 this.filename = filename; 31 this.pos = 0; 32 this.bytePos = 0; 33 this.lastAdvancedBytePos = 0; 34 this.scopeStack = []; 35 36 // Caching structures 37 this.stringTable = new Map(); 38 this.stringTableArray = []; 39 this.stackCache = new Map(); 40 this.frameCache = new Map(); 41 this.nodeCache = new Map(); 42 43 // Profile tables - stored in final array format 44 this.frameTable = { 45 func: [], 46 category: [], 47 }; 48 this.stackTable = { 49 frame: [], 50 prefix: [], 51 }; 52 53 // Aggregation state 54 this.topStackHandle = null; 55 const totalBytes = new TextEncoder().encode(jsonString).length; 56 this.bytesPerSample = Math.max( 57 1, 58 Math.min(1000000, Math.floor(totalBytes / MAX_SAMPLE_COUNT)) 59 ); 60 this.sampleCount = 0; 61 this.aggregationMap = new Map(); 62 this.aggregationStartPos = 0; 63 this.samples = { 64 stack: [], 65 time: [], 66 cpuDelta: [], 67 weight: [], 68 }; 69 70 // Categories initialization 71 this.categories = []; 72 this.categoryMap = new Map(); 73 for (const [key, value] of Object.entries(JSON_CATEGORIES)) { 74 const catIndex = this.categories.length; 75 this.categories.push(value); 76 this.categoryMap.set(key, catIndex); 77 } 78 } 79 80 /** 81 * Interns a string into the string table, returning its index. 82 * 83 * @param {string} str - The string to intern. 84 * @returns {number} The index of the string in the string table. 85 */ 86 internString(str) { 87 if (!this.stringTable.has(str)) { 88 const index = this.stringTableArray.length; 89 this.stringTableArray.push(str); 90 this.stringTable.set(str, index); 91 } 92 return this.stringTable.get(str); 93 } 94 95 /** 96 * Gets or creates a frame in the frame table. 97 * 98 * @param {string} funcName - The function name for the frame. 99 * @param {number} category - The category index for the frame. 100 * @returns {number} The frame index. 101 */ 102 getOrCreateFrame(funcName, category) { 103 const funcIndex = this.internString(funcName); 104 const cacheKey = `${funcIndex}:${category}`; 105 if (this.frameCache.has(cacheKey)) { 106 return this.frameCache.get(cacheKey); 107 } 108 109 const frameIndex = this.frameTable.func.length; 110 this.frameTable.func.push(funcIndex); 111 this.frameTable.category.push(category); 112 this.frameCache.set(cacheKey, frameIndex); 113 return frameIndex; 114 } 115 116 /** 117 * Gets or creates a stack in the stack table. 118 * 119 * @param {number} frameIndex - The frame index for this stack entry. 120 * @param {number|null} prefix - The parent stack index, or null for root. 121 * @returns {number} The stack index. 122 */ 123 getOrCreateStack(frameIndex, prefix) { 124 const cacheKey = `${frameIndex}:${prefix === null ? "null" : prefix}`; 125 if (this.stackCache.has(cacheKey)) { 126 return this.stackCache.get(cacheKey); 127 } 128 129 const stackIndex = this.stackTable.frame.length; 130 this.stackTable.frame.push(frameIndex); 131 this.stackTable.prefix.push(prefix); 132 this.stackCache.set(cacheKey, stackIndex); 133 return stackIndex; 134 } 135 136 /** 137 * Gets a stack handle for a given path and JSON type. 138 * 139 * @param {number|null} parentStackHandle - The parent stack handle. 140 * @param {string} path - The path in the JSON structure. 141 * @param {string} jsonType - The JSON type (OBJECT, ARRAY, STRING, etc.). 142 * @returns {number} The stack handle. 143 */ 144 getStack(parentStackHandle, path, jsonType) { 145 const cacheKey = `${parentStackHandle === null ? "null" : parentStackHandle}:${path}:${jsonType}`; 146 if (this.nodeCache.has(cacheKey)) { 147 return this.nodeCache.get(cacheKey); 148 } 149 150 const category = this.categoryMap.get(jsonType); 151 const frameIndex = this.getOrCreateFrame(path, category); 152 const stackHandle = this.getOrCreateStack(frameIndex, parentStackHandle); 153 this.nodeCache.set(cacheKey, stackHandle); 154 return stackHandle; 155 } 156 157 /** 158 * Moves position forward, updating both character and byte positions. 159 * 160 * This function tracks both character positions (in the UTF-16 string) and 161 * byte positions (in the original UTF-8 file) because: 162 * 1. The original JSON file contained bytes which formed a UTF-8 string. 163 * 2. When the JSON viewer loaded the JSON, these bytes were parsed into a 164 * UTF-16 string (or "potentially ill-formed UTF-16" aka WTF-16), which 165 * means all characters now take 2 bytes in memory even though most 166 * originally only took 1 byte in the UTF-8 file. 167 * 3. this.jsonString.charCodeAt() indexes into the UTF-16 string. 168 * 4. By examining the UTF-16 code unit value, we "recover" how many bytes 169 * this character originally occupied in the UTF-8 file. 170 * 171 * Note: this.pos will never stop in the middle of a surrogate pair. 172 * When this function returns, this.pos >= newCharPos. 173 * 174 * @param {number} newCharPos - The new character position to move to. 175 */ 176 advanceToPos(newCharPos) { 177 while (this.pos < newCharPos) { 178 const code = this.jsonString.charCodeAt(this.pos); 179 if (code >= 0xd800 && code <= 0xdbff) { 180 // Surrogate pair - always 4 bytes in UTF-8 181 this.bytePos += 4; 182 this.pos += 2; 183 } else { 184 // Single UTF-16 code unit - calculate UTF-8 byte length 185 if (code <= 0x7f) { 186 this.bytePos += 1; 187 } else if (code <= 0x7ff) { 188 this.bytePos += 2; 189 } else { 190 // 3-byte UTF-8 characters (U+0800 to U+FFFF) 191 // Examples: CJK characters like "中", symbols like "€", etc. 192 this.bytePos += 3; 193 } 194 this.pos++; 195 } 196 } 197 } 198 199 /** 200 * Moves position forward by a number of ASCII characters (1 byte each). 201 * 202 * @param {number} count - The number of ASCII characters to advance. 203 */ 204 advanceByAsciiChars(count) { 205 this.pos += count; 206 this.bytePos += count; 207 } 208 209 /** 210 * Parses a primitive value with stack tracking. 211 * 212 * @param {string} path - The path to the value in the JSON structure. 213 * @param {string} typeName - The type name (STRING, NUMBER, BOOL, NULL). 214 * @param {Function} parseFunc - The function to call to parse the value. 215 */ 216 parsePrimitive(path, typeName, parseFunc) { 217 this.recordBytesConsumed(); 218 219 const scope = this.getCurrentScope(); 220 const stackHandle = this.getStack( 221 scope.stackHandle, 222 `${path} (${typeName.toLowerCase()})`, 223 typeName 224 ); 225 this.topStackHandle = stackHandle; 226 227 parseFunc(); 228 229 this.recordBytesConsumed(); 230 this.topStackHandle = scope.stackHandle; 231 } 232 233 /** 234 * Exits the current scope (object or array). 235 */ 236 exitScope() { 237 this.recordBytesConsumed(); 238 this.scopeStack.pop(); 239 const prevScope = this.getCurrentScope(); 240 this.topStackHandle = prevScope.stackHandle; 241 } 242 243 /** 244 * Records bytes consumed since the last call. 245 * 246 * This method accumulates byte counts in aggregationMap instead of immediately 247 * creating profile samples. This aggregation limits the total sample count to 248 * approximately MAX_SAMPLE_COUNT (100,000), which keeps the Firefox Profiler 249 * UI responsive even for very large JSON files. 250 * 251 * For small files (< 100KB), bytesPerSample = 1, so samples are created 252 * frequently. For large files, bytesPerSample scales proportionally 253 * (e.g., 100 for a 10MB file), so samples are batched more aggressively. 254 * 255 * Samples are flushed when we have accumulated multiple stacks and have 256 * consumed enough bytes to justify creating new samples. 257 */ 258 recordBytesConsumed() { 259 if (this.bytePos === 0 && this.lastAdvancedBytePos === 0) { 260 return; 261 } 262 if (this.bytePos < this.lastAdvancedBytePos) { 263 throw new Error( 264 `Cannot advance backwards: ${this.lastAdvancedBytePos} -> ${this.bytePos}` 265 ); 266 } 267 if (this.bytePos === this.lastAdvancedBytePos) { 268 return; 269 } 270 271 const byteDelta = this.bytePos - this.lastAdvancedBytePos; 272 const stackHandle = this.topStackHandle; 273 if (stackHandle !== null) { 274 const current = this.aggregationMap.get(stackHandle) || 0; 275 this.aggregationMap.set(stackHandle, current + byteDelta); 276 } 277 278 this.lastAdvancedBytePos = this.bytePos; 279 280 // Flush accumulated samples when we have multiple stacks and enough bytes 281 const aggregatedStackCount = this.aggregationMap.size; 282 if (aggregatedStackCount > 1) { 283 const sampleCountIfWeFlush = this.sampleCount + aggregatedStackCount; 284 const allowedSampleCount = Math.floor( 285 this.lastAdvancedBytePos / this.bytesPerSample 286 ); 287 if (sampleCountIfWeFlush <= allowedSampleCount) { 288 this.recordSamples(); 289 } 290 } 291 } 292 293 /** 294 * Flushes accumulated byte counts to the samples table. 295 */ 296 recordSamples() { 297 let synthLastPos = this.aggregationStartPos; 298 299 for (const [stackHandle, accDelta] of this.aggregationMap.entries()) { 300 const synthPos = synthLastPos + accDelta; 301 302 // First sample at start position 303 this.samples.stack.push(stackHandle); 304 this.samples.time.push(synthLastPos); 305 this.samples.cpuDelta.push(0); 306 this.samples.weight.push(0); 307 308 // Second sample at end position with size 309 this.samples.stack.push(stackHandle); 310 this.samples.time.push(synthPos); 311 this.samples.cpuDelta.push(accDelta * 1000); 312 this.samples.weight.push(accDelta); 313 314 synthLastPos = synthPos; 315 this.sampleCount += 1; 316 } 317 318 this.aggregationStartPos = this.lastAdvancedBytePos; 319 this.aggregationMap.clear(); 320 } 321 322 /** 323 * Gets the current scope from the scope stack. 324 * 325 * @returns {object} An object with stackHandle, path, and arrayDepth. 326 */ 327 getCurrentScope() { 328 if (this.scopeStack.length === 0) { 329 return { 330 stackHandle: null, 331 path: "json", 332 arrayDepth: 0, 333 }; 334 } 335 336 const scope = this.scopeStack[this.scopeStack.length - 1]; 337 return { 338 stackHandle: scope.stackHandle, 339 path: scope.pathForValue || scope.pathForElems || scope.path, 340 arrayDepth: scope.arrayDepth, 341 }; 342 } 343 344 /** 345 * Skips whitespace characters in the JSON string. 346 */ 347 skipWhitespace() { 348 while (this.pos < this.jsonString.length) { 349 const ch = this.jsonString[this.pos]; 350 if (ch !== " " && ch !== "\t" && ch !== "\n" && ch !== "\r") { 351 break; 352 } 353 this.advanceByAsciiChars(1); // Whitespace is always ASCII (1 byte each) 354 } 355 } 356 357 /** 358 * Parses a JSON value at the current position. 359 * 360 * @param {string} path - The path to this value in the JSON structure. 361 */ 362 parseValue(path) { 363 this.skipWhitespace(); 364 365 if (this.pos >= this.jsonString.length) { 366 throw new Error("Unexpected end of JSON"); 367 } 368 369 const ch = this.jsonString[this.pos]; 370 371 if (ch === "{") { 372 this.parseObject(path); 373 } else if (ch === "[") { 374 this.parseArray(path); 375 } else if (ch === '"') { 376 this.parseString(path); 377 } else if (ch === "t" || ch === "f") { 378 this.parseBool(path); 379 } else if (ch === "n") { 380 this.parseNull(path); 381 } else { 382 this.parseNumber(path); 383 } 384 } 385 386 /** 387 * Parses a JSON object at the current position. 388 * 389 * @param {string} path - The path to this object in the JSON structure. 390 */ 391 parseObject(path) { 392 this.recordBytesConsumed(); 393 394 const parentScope = this.getCurrentScope(); 395 const stackHandle = this.getStack( 396 parentScope.stackHandle, 397 `${path} (object)`, 398 "OBJECT" 399 ); 400 401 this.scopeStack.push({ 402 type: "object", 403 stackHandle, 404 path, 405 pathForValue: null, 406 arrayDepth: parentScope.arrayDepth, 407 }); 408 this.topStackHandle = stackHandle; 409 410 this.advanceByAsciiChars(1); // skip '{' 411 412 let first = true; 413 while (this.pos < this.jsonString.length) { 414 this.skipWhitespace(); 415 416 if (this.jsonString[this.pos] === "}") { 417 this.advanceByAsciiChars(1); // skip '}' 418 break; 419 } 420 421 if (!first) { 422 if (this.jsonString[this.pos] !== ",") { 423 throw new Error(`Expected ',' at position ${this.pos}`); 424 } 425 this.advanceByAsciiChars(1); // skip ',' 426 this.skipWhitespace(); 427 } 428 first = false; 429 430 // Parse property key 431 if (this.jsonString[this.pos] !== '"') { 432 throw new Error(`Expected property key at position ${this.pos}`); 433 } 434 435 this.recordBytesConsumed(); 436 437 const key = this.parseStringValue(); 438 const propertyPath = `${path}.${key}`; 439 440 const propKeyStack = this.getStack( 441 stackHandle, 442 `${propertyPath} (property key)`, 443 "PROPERTY_KEY" 444 ); 445 this.topStackHandle = propKeyStack; 446 447 // Update scope with current property path 448 this.scopeStack[this.scopeStack.length - 1].pathForValue = propertyPath; 449 450 this.skipWhitespace(); 451 if (this.jsonString[this.pos] !== ":") { 452 throw new Error(`Expected ':' at position ${this.pos}`); 453 } 454 this.advanceByAsciiChars(1); // skip ':' 455 456 // Parse property value 457 this.parseValue(propertyPath); 458 } 459 460 this.exitScope(); 461 } 462 463 /** 464 * Parses a JSON array at the current position. 465 * 466 * @param {string} path - The path to this array in the JSON structure. 467 */ 468 parseArray(path) { 469 this.recordBytesConsumed(); 470 471 const parentScope = this.getCurrentScope(); 472 473 const INDEXER_CHARS = "ijklmnopqrstuvwxyz"; 474 const indexer = 475 INDEXER_CHARS[parentScope.arrayDepth % INDEXER_CHARS.length]; 476 const pathForElems = `${path}[${indexer}]`; 477 478 const stackHandle = this.getStack( 479 parentScope.stackHandle, 480 `${path} (array)`, 481 "ARRAY" 482 ); 483 484 this.topStackHandle = stackHandle; 485 this.scopeStack.push({ 486 type: "array", 487 stackHandle, 488 pathForElems, 489 arrayDepth: parentScope.arrayDepth + 1, 490 }); 491 492 this.advanceByAsciiChars(1); // skip '[' 493 494 let first = true; 495 while (this.pos < this.jsonString.length) { 496 this.skipWhitespace(); 497 498 if (this.jsonString[this.pos] === "]") { 499 this.advanceByAsciiChars(1); // skip ']' 500 break; 501 } 502 503 if (!first) { 504 if (this.jsonString[this.pos] !== ",") { 505 throw new Error(`Expected ',' at position ${this.pos}`); 506 } 507 this.advanceByAsciiChars(1); // skip ',' 508 this.skipWhitespace(); 509 } 510 first = false; 511 512 this.parseValue(pathForElems); 513 } 514 515 this.exitScope(); 516 } 517 518 /** 519 * Parses a JSON string at the current position. 520 * 521 * @param {string} path - The path to this string in the JSON structure. 522 */ 523 parseString(path) { 524 this.parsePrimitive(path, "STRING", () => this.parseStringValue()); 525 } 526 527 /** 528 * Parses a JSON string value and returns it. 529 * 530 * @returns {string} The parsed string value. 531 */ 532 parseStringValue() { 533 this.advanceByAsciiChars(1); // skip opening quote (ASCII) 534 let value = ""; 535 536 while (this.pos < this.jsonString.length) { 537 const ch = this.jsonString[this.pos]; 538 539 if (ch === '"') { 540 this.advanceByAsciiChars(1); // closing quote (ASCII) 541 break; 542 } else if (ch === "\\") { 543 this.advanceByAsciiChars(1); // backslash (ASCII) 544 if (this.pos >= this.jsonString.length) { 545 throw new Error("Unexpected end of JSON in string"); 546 } 547 const escaped = this.jsonString[this.pos]; 548 if (escaped === '"' || escaped === "\\" || escaped === "/") { 549 value += escaped; 550 } else if (escaped === "b") { 551 value += "\b"; 552 } else if (escaped === "f") { 553 value += "\f"; 554 } else if (escaped === "n") { 555 value += "\n"; 556 } else if (escaped === "r") { 557 value += "\r"; 558 } else if (escaped === "t") { 559 value += "\t"; 560 } else if (escaped === "u") { 561 // Unicode escape - \uXXXX (all ASCII) 562 this.advanceByAsciiChars(1); 563 const hex = this.jsonString.slice(this.pos, this.pos + 4); 564 value += String.fromCharCode(parseInt(hex, 16)); 565 this.advanceByAsciiChars(3); // skip the 4 hex digits (already moved 1) 566 } 567 this.advanceByAsciiChars(1); // escaped char (ASCII) 568 } else { 569 // Regular character - may be multi-byte UTF-8 570 value += ch; 571 this.advanceToPos(this.pos + 1); 572 } 573 } 574 575 return value; 576 } 577 578 /** 579 * Parses a JSON number at the current position. 580 * 581 * @param {string} path - The path to this number in the JSON structure. 582 */ 583 parseNumber(path) { 584 this.parsePrimitive(path, "NUMBER", () => { 585 // Skip all number characters: digits, decimal point, exponent, signs 586 while (this.pos < this.jsonString.length) { 587 const ch = this.jsonString[this.pos]; 588 if ( 589 (ch >= "0" && ch <= "9") || 590 ch === "." || 591 ch === "e" || 592 ch === "E" || 593 ch === "+" || 594 ch === "-" 595 ) { 596 this.advanceByAsciiChars(1); 597 } else { 598 break; 599 } 600 } 601 }); 602 } 603 604 /** 605 * Parses a JSON boolean at the current position. 606 * 607 * @param {string} path - The path to this boolean in the JSON structure. 608 */ 609 parseBool(path) { 610 this.parsePrimitive(path, "BOOL", () => { 611 if (this.jsonString.slice(this.pos, this.pos + 4) === "true") { 612 this.advanceByAsciiChars(4); 613 } else if (this.jsonString.slice(this.pos, this.pos + 5) === "false") { 614 this.advanceByAsciiChars(5); 615 } else { 616 throw new Error(`Expected boolean at position ${this.pos}`); 617 } 618 }); 619 } 620 621 /** 622 * Parses a JSON null at the current position. 623 * 624 * @param {string} path - The path to this null in the JSON structure. 625 */ 626 parseNull(path) { 627 this.parsePrimitive(path, "NULL", () => { 628 if (this.jsonString.slice(this.pos, this.pos + 4) === "null") { 629 this.advanceByAsciiChars(4); 630 } else { 631 throw new Error(`Expected null at position ${this.pos}`); 632 } 633 }); 634 } 635 636 /** 637 * Parses the JSON string and generates a Firefox profiler profile. 638 * 639 * @returns {object} A Firefox profiler profile object. 640 */ 641 parse() { 642 this.parseValue("json"); 643 644 // Move to end of string to account for any trailing content 645 const remaining = this.jsonString.length - this.pos; 646 if (remaining > 0) { 647 this.advanceByAsciiChars(remaining); 648 } 649 650 // Advance to final position 651 if (this.bytePos !== this.lastAdvancedBytePos) { 652 this.recordBytesConsumed(); 653 } 654 655 this.recordSamples(); 656 657 const frameCount = this.frameTable.func.length; 658 const funcCount = this.stringTableArray.length; 659 const sampleCount = this.samples.stack.length; 660 // Convert absolute times to deltas in place 661 for (let i = sampleCount - 1; i > 0; i--) { 662 this.samples.time[i] = this.samples.time[i] - this.samples.time[i - 1]; 663 } 664 // First element stays as-is (it's already a delta from 0) 665 666 const meta = { 667 version: 56, 668 preprocessedProfileVersion: 56, 669 startTime: 0, 670 fileSize: this.lastAdvancedBytePos, 671 processType: 0, 672 product: "JSON Size Profile", 673 interval: this.bytesPerSample, 674 markerSchema: [], 675 symbolicationNotSupported: true, 676 usesOnlyOneStackType: true, 677 categories: this.categories.map(cat => ({ 678 name: cat.name, 679 color: cat.color, 680 subcategories: ["Other"], 681 })), 682 sampleUnits: { 683 time: "bytes", 684 eventDelay: "ms", 685 threadCPUDelta: "µs", 686 }, 687 }; 688 689 if (this.filename) { 690 meta.fileName = this.filename; 691 } 692 693 const profile = { 694 meta, 695 libs: [], 696 threads: [ 697 { 698 processType: "default", 699 processStartupTime: 0, 700 processShutdownTime: null, 701 registerTime: 0, 702 unregisterTime: null, 703 pausedRanges: [], 704 name: "Bytes", 705 isMainThread: true, 706 pid: "0", 707 tid: "0", 708 samples: { 709 length: sampleCount, 710 stack: this.samples.stack, 711 timeDeltas: this.samples.time, 712 weight: this.samples.weight, 713 weightType: "bytes", 714 threadCPUDelta: this.samples.cpuDelta, 715 }, 716 markers: { 717 length: 0, 718 category: [], 719 data: [], 720 endTime: [], 721 name: [], 722 phase: [], 723 startTime: [], 724 }, 725 stackTable: { 726 length: this.stackTable.frame.length, 727 prefix: this.stackTable.prefix, 728 frame: this.stackTable.frame, 729 }, 730 frameTable: { 731 length: frameCount, 732 address: new Array(frameCount).fill(-1), 733 category: this.frameTable.category, 734 subcategory: new Array(frameCount).fill(0), 735 func: this.frameTable.func, 736 nativeSymbol: new Array(frameCount).fill(null), 737 innerWindowID: new Array(frameCount).fill(0), 738 line: new Array(frameCount).fill(null), 739 column: new Array(frameCount).fill(null), 740 inlineDepth: new Array(frameCount).fill(0), 741 }, 742 funcTable: { 743 length: funcCount, 744 name: Array.from({ length: funcCount }, (_, i) => i), 745 isJS: new Array(funcCount).fill(false), 746 relevantForJS: new Array(funcCount).fill(false), 747 resource: new Array(funcCount).fill(-1), 748 fileName: new Array(funcCount).fill(null), 749 lineNumber: new Array(funcCount).fill(null), 750 columnNumber: new Array(funcCount).fill(null), 751 }, 752 resourceTable: { 753 length: 0, 754 lib: [], 755 name: [], 756 host: [], 757 type: [], 758 }, 759 nativeSymbols: { 760 length: 0, 761 address: [], 762 functionSize: [], 763 libIndex: [], 764 name: [], 765 }, 766 }, 767 ], 768 profilingLog: [], 769 shared: { 770 stringArray: this.stringTableArray, 771 }, 772 }; 773 774 return profile; 775 } 776 } 777 778 /** 779 * Creates a Firefox profiler profile from a JSON string. 780 * 781 * @param {string} jsonString - The JSON string to profile 782 * @param {string} filename - Optional filename to include in the profile 783 * @returns {object} A Firefox profiler profile object 784 */ 785 export function createSizeProfile(jsonString, filename) { 786 const profiler = new JsonSizeProfiler(jsonString, filename); 787 return profiler.parse(); 788 }