// Copyright (c) 2025, Compiler Explorer Authors // All rights reserved. // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: // // * Redistributions of source code must retain the above copyright notice, // this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above copyright // notice, this list of conditions and the following disclaimer in the // documentation and/or other materials provided with the distribution. // // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE // ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE // LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR // CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF // SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS // INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN // CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // POSSIBILITY OF SUCH DAMAGE. import lzstring from 'lz-string'; import * as rison from './rison.js'; /** * ConfigMinifier - Reimplementation of GoldenLayout's ConfigMinifier * * Minifies and unminifies configs by replacing frequent keys and values * with one letter substitutes using base36 encoding. * * This is a Node-compatible reimplementation that doesn't depend on * the browser-only GoldenLayout library. */ class ConfigMinifier { private readonly _keys: string[]; private readonly _values: (string | boolean)[]; constructor() { // Array position matters - these map to base36 characters (0-9, a-z) this._keys = [ 'settings', 'hasHeaders', 'constrainDragToContainer', 'selectionEnabled', 'dimensions', 'borderWidth', 'minItemHeight', 'minItemWidth', 'headerHeight', 'dragProxyWidth', 'dragProxyHeight', 'labels', 'close', 'maximise', 'minimise', 'popout', 'content', 'componentName', 'componentState', 'id', 'width', 'type', 'height', 'isClosable', 'title', 'popoutWholeStack', 'openPopouts', 'parentId', 'activeItemIndex', 'reorderEnabled', 'borderGrabWidth', ]; this._values = [ true, false, 'row', 'column', 'stack', 'component', 'close', 'maximise', 'minimise', 'open in new window', ]; if (this._keys.length > 36) { throw new Error('Too many keys in config minifier map'); } } /** * Takes a GoldenLayout configuration object and replaces its keys * and values recursively with one letter counterparts */ minifyConfig(config: any): any { const min: any = {}; this._nextLevel(config, min, '_min'); return min; } /** * Takes a configuration Object that was previously minified * using minifyConfig and returns its original version */ unminifyConfig(minifiedConfig: any): any { const orig: any = {}; this._nextLevel(minifiedConfig, orig, '_max'); return orig; } /** * Recursive function, called for every level of the config structure */ private _nextLevel(from: any, to: any, translationFn: '_min' | '_max'): void { for (const key in from) { // Skip prototype properties if (!from.hasOwnProperty(key)) continue; // For arrays, cast keys to numbers (not strings!) // This is important because the single-char check in _min/_max // should not trigger for numeric indices like "0", "1", etc. let processedKey: string | number = key; if (Array.isArray(from)) { processedKey = parseInt(key, 10); } // Translate the key to a one letter substitute const minKey = this[translationFn](processedKey, this._keys); // For Arrays and Objects, create a new Array/Object and recurse if (typeof from[key] === 'object' && from[key] !== null) { to[minKey] = Array.isArray(from[key]) ? [] : {}; this._nextLevel(from[key], to[minKey], translationFn); } else { // For primitive values, minify the value to[minKey] = this[translationFn](from[key], this._values); } } } /** * Minifies value based on a dictionary */ private _min(value: any, dictionary: readonly (string | boolean)[]): any { // If a value actually is a single character, prefix it with ___ // to avoid mistaking it for a minification code if (typeof value === 'string' && value.length === 1) { return '___' + value; } const index = dictionary.indexOf(value); // Value not found in the dictionary, return it unmodified if (index === -1) { return value; } // Value found in dictionary, return its base36 counterpart return index.toString(36); } /** * Unminifies value based on a dictionary */ private _max(value: any, dictionary: readonly (string | boolean)[]): any { // Value is a single character - assume it's a translation if (typeof value === 'string' && value.length === 1) { return dictionary[parseInt(value, 36)]; } // Value originally was a single character and was prefixed with ___ if (typeof value === 'string' && value.substr(0, 3) === '___') { return value[3]; } // Value was not minified return value; } } // Create a singleton instance const configMinifier = new ConfigMinifier(); /** * Minify a GoldenLayout config object */ export function minifyConfig(config: any): any { return configMinifier.minifyConfig(config); } /** * Unminify a GoldenLayout config object */ export function unminifyConfig(config: any): any { return configMinifier.unminifyConfig(config); } /** * Convert object to rison-encoded string */ export function risonify(obj: rison.JSONValue): string { return rison.quote(rison.encode_object(obj)); } /** * Convert rison-encoded string to object */ export function unrisonify(text: string): any { return rison.decode_object(decodeURIComponent(text.replace(/\+/g, '%20'))); } /** * Serialise state object to URL hash string * * Process: * 1. Minify the config (replace common keys/values with single chars) * 2. Rison encode the minified config * 3. If compression saves >20%, compress with lzstring and wrap in {z: ...} * 4. Return the final rison-encoded string */ export function serialiseState(stateText: any): string { const ctx = minifyConfig({content: stateText.content}); ctx.version = 4; const uncompressed = risonify(ctx); const compressed = risonify({z: lzstring.compressToBase64(uncompressed)}); const MinimalSavings = 0.2; if (compressed.length < uncompressed.length * (1.0 - MinimalSavings)) { return compressed; } return uncompressed; } /** * Deserialise URL hash string to state object * * Process: * 1. Rison decode the hash * 2. If it contains {z: ...}, decompress the lzstring data * 3. Rison decode again if decompressed * 4. Unminify the config (expand single chars back to full keys/values) * 5. Handle version migrations for old state formats */ export function deserialiseState(stateText: string): any { let state; try { state = unrisonify(stateText); if (state?.z) { const data = lzstring.decompressFromBase64(state.z); // lzstring returns empty string on failure rather than throwing if (data === '') { throw new Error('lzstring decompress error, url is corrupted'); } state = unrisonify(data); } } catch (ex) { // If we can't parse it, return false so caller can handle console.warn('Failed to deserialise state:', ex); return false; } // Handle version migrations if (!state || state.version === undefined) return false; switch (state.version) { case 4: state = unminifyConfig(state); break; default: // Versions 1-3 require GoldenLayout and Components, which are browser-only // These should be handled by the frontend-specific code in static/url.ts return state; } return state; }