From 0838e34daadbb720e9dd6082dbbf5a09e78476e5 Mon Sep 17 00:00:00 2001 From: MH4GF Date: Thu, 19 Dec 2024 20:25:25 +0900 Subject: [PATCH] feat: refactor compression utilities to support Base64 and URL-safe encoding --- .../ERDContent/useInitialAutoLayout.ts | 4 +- .../erd-core/src/stores/userEditing/store.ts | 4 +- .../src/utils/compressionString.test.ts | Bin 1792 -> 2493 bytes .../erd-core/src/utils/compressionString.ts | 142 +++++++++++------- 4 files changed, 95 insertions(+), 55 deletions(-) diff --git a/frontend/packages/erd-core/src/components/ERDRenderer/ERDContent/useInitialAutoLayout.ts b/frontend/packages/erd-core/src/components/ERDRenderer/ERDContent/useInitialAutoLayout.ts index add72d5f8..b167c8b4a 100644 --- a/frontend/packages/erd-core/src/components/ERDRenderer/ERDContent/useInitialAutoLayout.ts +++ b/frontend/packages/erd-core/src/components/ERDRenderer/ERDContent/useInitialAutoLayout.ts @@ -1,6 +1,6 @@ import type { QueryParam } from '@/schemas/queryParam' import { addHiddenNodeIds, updateActiveTableName } from '@/stores' -import { decompressFromUTF16 } from '@/utils' +import { decompressFromEncodedURIComponent } from '@/utils' import { useNodesInitialized } from '@xyflow/react' import { useEffect } from 'react' import { useERDContentContext } from './ERDContentContext' @@ -19,7 +19,7 @@ const getHiddenNodeIdsFromUrl = async (): Promise => { const hiddenQueryParam: QueryParam = 'hidden' const compressed = urlParams.get(hiddenQueryParam) const hiddenNodeIds = compressed - ? await decompressFromUTF16(compressed).catch(() => undefined) + ? await decompressFromEncodedURIComponent(compressed).catch(() => undefined) : undefined return hiddenNodeIds ? hiddenNodeIds.split(',') : [] diff --git a/frontend/packages/erd-core/src/stores/userEditing/store.ts b/frontend/packages/erd-core/src/stores/userEditing/store.ts index 89a839efe..edabad137 100644 --- a/frontend/packages/erd-core/src/stores/userEditing/store.ts +++ b/frontend/packages/erd-core/src/stores/userEditing/store.ts @@ -1,6 +1,6 @@ import type { QueryParam } from '@/schemas/queryParam' import type { ShowMode } from '@/schemas/showMode' -import { compressToUTF16 } from '@/utils' +import { compressToEncodedURIComponent } from '@/utils' import { proxy, subscribe } from 'valtio' import { proxySet } from 'valtio/utils' @@ -41,7 +41,7 @@ subscribe(userEditingStore.hiddenNodeIds, async () => { url.searchParams.delete(activeQueryParam) if (hiddenNodeIds) { - const compressed = await compressToUTF16(hiddenNodeIds) + const compressed = await compressToEncodedURIComponent(hiddenNodeIds) url.searchParams.set(activeQueryParam, compressed) } diff --git a/frontend/packages/erd-core/src/utils/compressionString.test.ts b/frontend/packages/erd-core/src/utils/compressionString.test.ts index aec8dea2f476222f278c8d634d6095ef90ab227f..5fd69d6b5d5a8cbf6d35b2f0562ef249d7520de2 100644 GIT binary patch literal 2493 zcmds2%Z}nk6y5U`mDxZ`G~r9_WYmnW#6h$V1uncMFHqr7XknT(UFw_2w}FSeq{=Rovx2vA89@enKv}|{tdcGQ%$biB$lQ2 z5tuxoZl? zuk{)1!+~%A1NUIIKv0Mv&6SG9;$>%@TjW*sX51f(yLM0(^k&BtgrRSst^FB%jo2)6 zAqJ)c2{b7p(My{1u>W}Y=Hp@i<6-~9VgJ+N!~WNwzyIyKZ-4(~|H}LJI5hxx90xm5 z+BH}6YeigLD-CwUJ9Yx3h~!!kY@~5`gC?GGUAIS#a%;`n{Bf=W@SkVJ!u26ZfCVXh zK{cKhLg8@}HCPoJY}w?V(4a53N^nI*MbTCU6#eqN4ijy}fKhQ11+ zif;!qy{&2!+@9$Ca#R}hr$uX43#PmcB{eeE;d-%dhqu#`2g}PXr+BORVA$qw(PG0P zAJ@>3RQdT%nG1o^7!~`?oh!S++Z)f=4Bb}T8?W#{EArh`CQ4#f`f1&cx^vakaX6jK zlO(#kn^z|#empIW&EmkDR%joVtcqH;mdkpL(}gASIzo43ID*rZWTUHkl4RAi3|p$}Od{XuHAZ%<#;z=zoaS{DnY$VLy_lh}wZt{~I;U-y*D^O; SZu>;42Cbs}ul_8yi2ndWNlpR) delta 313 zcmdlh+`u=%OidvKTW30Cnxfro&ndq~qb5#&;7M z-u3T)+1L1@d-1!z$* { - const encoder = new TextEncoder() - const inputBytes = encoder.encode(input) - - // Deflate compression - const compressionStream = new CompressionStream('deflate') - const compressedStream = - createReadableStreamFromBytes(inputBytes).pipeThrough(compressionStream) - const compressedBuffer = await new Response(compressedStream).arrayBuffer() - const compressedBytes = new Uint8Array(compressedBuffer) - - // Store the length of the compressed bytes in the first 4 bytes - const length = compressedBytes.length - const totalLength = 4 + length // Store length in the first 4 bytes - // Align to 2-byte (UTF-16 code unit) boundary, add 1 byte padding if necessary - const paddedLength = totalLength % 2 === 0 ? totalLength : totalLength + 1 - - const resultBytes = new Uint8Array(paddedLength) - const dataView = new DataView(resultBytes.buffer) +/** + * Perform Deflate compression and return a Uint8Array + */ +async function deflateCompress(inputBytes: Uint8Array): Promise { + const compression = new CompressionStream('deflate') + const stream = + createReadableStreamFromBytes(inputBytes).pipeThrough(compression) + const compressedBuffer = await new Response(stream).arrayBuffer() + return new Uint8Array(compressedBuffer) +} - // Store length information in the first 4 bytes (Little Endian) - dataView.setUint32(0, length, true) - // Store the compressed data after the first 4 bytes - resultBytes.set(compressedBytes, 4) +/** + * Perform Deflate decompression and return a Uint8Array + */ +async function deflateDecompress(inputBytes: Uint8Array): Promise { + const decompression = new DecompressionStream('deflate') + const stream = + createReadableStreamFromBytes(inputBytes).pipeThrough(decompression) + const decompressedBuffer = await new Response(stream).arrayBuffer() + return new Uint8Array(decompressedBuffer) +} - // Convert to Uint16Array to interpret as UTF-16 code units - const uint16Array = new Uint16Array(resultBytes.buffer) +/** + * Encode byte array to Base64 string + */ +function bytesToBase64(bytes: Uint8Array): string { + let binaryString = '' + for (const b of Array.from(bytes)) { + binaryString += String.fromCharCode(b) + } + return btoa(binaryString) +} - // Convert each Uint16 element to a character - return String.fromCharCode(...Array.from(uint16Array)) +/** + * Decode Base64 string to byte array + */ +function base64ToBytes(base64: string): Uint8Array { + const binaryString = atob(base64) + const length = binaryString.length + const bytes = new Uint8Array(length) + for (let i = 0; i < length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + return bytes } -export async function decompressFromUTF16(input: string): Promise { - const decoder = new TextDecoder() +/** + * Convert Base64 string to URL-safe string (`+` => `-`, `/` => `_`, remove `=`) + */ +function base64ToUrlSafe(base64: string): string { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} - // Reconstruct Uint16Array from UTF-16 code units - const codeUnits = new Uint16Array(input.length) - for (let i = 0; i < input.length; i++) { - codeUnits[i] = input.charCodeAt(i) +/** + * Convert URL-safe string back to Base64 + */ +function urlSafeToBase64(urlSafe: string): string { + let base64 = urlSafe.replace(/-/g, '+').replace(/_/g, '/') + while (base64.length % 4) { + base64 += '=' } + return base64 +} - // Reference Uint8Array from Uint16Array - const bytes = new Uint8Array(codeUnits.buffer) - const dataView = new DataView(bytes.buffer) - - // Get the actual length of the compressed data from the first 4 bytes - const length = dataView.getUint32(0, true) +/** + * Compress string using Deflate and return as Base64 + */ +async function compressToBase64(input: string): Promise { + const textEncoder = new TextEncoder() + const inputBytes = textEncoder.encode(input) + const compressedBytes = await deflateCompress(inputBytes) + return bytesToBase64(compressedBytes) +} - // Extract the compressed data part - const compressedBytes = bytes.subarray(4, 4 + length) +/** + * Decompress Base64 string and return the original string + */ +async function decompressFromBase64(input: string): Promise { + const textDecoder = new TextDecoder() + const compressedBytes = base64ToBytes(input) + const decompressedBytes = await deflateDecompress(compressedBytes) + return textDecoder.decode(decompressedBytes) +} - // Decompress - const decompressionStream = new DecompressionStream('deflate') - const decompressedStream = - createReadableStreamFromBytes(compressedBytes).pipeThrough( - decompressionStream, - ) - const decompressedBuffer = await new Response( - decompressedStream, - ).arrayBuffer() +/** + * Compress string using Deflate and return as URL-safe encoded string (based on Base64) + */ +export async function compressToEncodedURIComponent( + input: string, +): Promise { + const base64 = await compressToBase64(input) + return base64ToUrlSafe(base64) +} - return decoder.decode(decompressedBuffer) +/** + * Restore original string from URL-safe encoded string + */ +export async function decompressFromEncodedURIComponent( + input: string, +): Promise { + const base64 = urlSafeToBase64(input) + return decompressFromBase64(base64) }