This commit is contained in:
2026-09-06 18:34:07 +02:00
parent 062054f450
commit e0d22283c8
14 changed files with 474 additions and 30 deletions
+5
View File
@@ -0,0 +1,5 @@
# Todo
- also update readmes in other languages
- use catalog install for common deps (typescript, vite, tsdown, etc. ) please suggest.
-
+66
View File
@@ -0,0 +1,66 @@
# v3 Breaking Changes
A list of changes users and operators need to consider when upgrading from v2 to v3.
## API
- All note endpoints moved under `/api/v3/notes/` (was `/api/notes/`)
- Status endpoint moved to `/api/v3/status` (was `/api/status`)
- All request/response bodies are now **MessagePack** (`Content-Type: application/msgpack`), not JSON
- Health check moved to `/healthz` (was `/api/live`)
- Notes can now have **both** `views` and `expiration` set simultaneously (previously mutually exclusive)
## API payload structure
The wire format changed entirely. v2 used:
```json
{ "contents": "<encrypted string>", "meta": "<stringified JSON>", "views": 5, "expiration": 30 }
```
v3 uses msgpack:
```
{ meta: { views?, expiration?, extra? }, data: <encrypted bytes> }
```
- `meta.extra` holds client-opaque data (e.g. scrypt derivation params), size-limited (default 512 bytes)
- `data` is the encrypted blob — the server never inspects its contents
- The encrypted inner payload is itself msgpack: `{ type: "text", data: string }` or `{ type: "files", data: [{ name, mime, size, data }] }`
## Environment variables
| v2 | v3 |
| ------------------- | -------------------- |
| `REDIS` | `CACHE` |
| `REDIS_PREFIX` | `CACHE_PREFIX` |
| _(new)_ | `EXTRA_SIZE_LIMIT` |
The `CACHE` env var accepts any RESP-compatible URL (valkey or redis).
`EXTRA_SIZE_LIMIT` (default `512`) limits the `extra` field size in bytes.
## Docker / Compose
- The `redis` service in docker-compose is renamed to `cache`
- Healthcheck URL updated: `http://127.0.0.1:8000/api/live/` → `http://127.0.0.1:8000/healthz`
- The default image stays `valkey/valkey:7-alpine` but operators can swap for any redis-compatible image
## CLI (`cryptgeon` npm package)
- Dropped `occulto` dependency — now uses `@noble/ciphers` + `@noble/hashes` internally
- Encryption changed from AES to **XChaCha20-Poly1305**
- The local `shared/` module removed — now imports from `@cryptgeon/shared` (workspace-internal)
- Notes created with v2 (AES) are **not readable** by v3 and vice versa
## Frontend
- Dropped `occulto` dependency
- Package import changed from `cryptgeon/shared` to `@cryptgeon/shared`
- Notes created in v2 are not accessible from the v3 frontend
## Storage
- Cache storage format changed from JSON blobs to hashes with atomic `HINCRBY` for view counting
- The per-note lock (`lock.rs`) is removed — no longer needed
- Existing v2 notes in cache are **not migrated** and will be inaccessible after upgrade
- Ensure cache is empty (or flush) before deploying v3
+323
View File
@@ -0,0 +1,323 @@
# v3 Plan
> Status: **Draft** — agreed on architecture, schema open for iteration.
>
> See also: [v3 Breaking Changes](./v3-breaking-changes.md) for the upgrade guide.
## Goals
- **XChaCha20-Poly1305** for encryption (replaces AES/`occulto`)
- **MessagePack** for all API request/response bodies (replaces JSON)
- **LZ4 compression** for note payloads (client-side, before encryption — pure JS, no wasm)
- **Cache hashes** (valkey or redis, both speak RESP) for storage — replaces JSON-blob-per-key
- **Clean break** from v1 — no backward compatibility, no v1 routes
- Remove all Redis references (env vars, service names, docs) in favor of the generic "cache" naming, so operators can choose valkey or redis
- Shared TypeScript package as the single source of truth for crypto + API client + types
## Non-goals
- Keeping v1 alive alongside v3
- Changing the backend language/framework (stays Rust + axum)
- Changing storage backend (stays valkey or redis via the `redis` crate — no separate crate)
- Publishing `@cryptgeon/shared` as a standalone npm package (workspace-internal for now)
---
## 1. Shared package — `@cryptgeon/shared`
Location: `packages/shared` (currently empty).
ESM-only, TypeScript-only. Consumed by both `packages/cli` and `packages/frontend` via workspace dependency.
### Dependencies
- `@noble/ciphers` — XChaCha20-Poly1305
- `@noble/hashes` — scrypt
- `@msgpack/msgpack` — encode/decode
- `lz4js` — LZ4 compression (pure JS, no wasm)
- `ky` — HTTP client
### Structure
```
packages/shared/src/
index.ts # re-exports
crypto.ts # key derivation, encrypt, decrypt
compression.ts # LZ4 compress / decompress
types.ts # Note, NoteMetadata, FileDTO, Status, etc.
api.ts # high-level client: create, info, view, status
api.test.ts # tests
crypto.test.ts # tests
compression.test.ts # tests
```
### `crypto.ts`
- `deriveKey(password: string): Uint8Array` — scrypt, N=2^15, r=8, p=1, dkLen=32, fixed app-specific salt
- `generateKey(): Uint8Array` — `randomBytes(32)`
- `encrypt(data: Uint8Array, key: Uint8Array): Uint8Array` — `managedNonce(xchacha20poly1305)(key).encrypt(data)`
- `decrypt(ciphertext: Uint8Array, key: Uint8Array): Uint8Array` — `managedNonce(xchacha20poly1305)(key).decrypt(ciphertext)`
> **Note (carried over from msgpack branch):** the v2 stub had a bug — `decrypt` passed `key` as a second arg to `chacha.decrypt`, which only takes ciphertext. v3 must not repeat this.
### `compression.ts`
- `compress(data: Uint8Array): Uint8Array` — LZ4 block format
- `decompress(data: Uint8Array): Uint8Array` — LZ4 block format
Compression is a **client-only** concern. The server never sees or knows about compression — it stores the encrypted `data` blob as opaque bytes. The pipeline is:
```
msgpack encode → LZ4 compress → XChaCha20-Poly1305 encrypt
XChaCha20-Poly1305 decrypt → LZ4 decompress → msgpack decode
```
Compression runs on the plaintext (inner msgpack), never on ciphertext — encrypted data is high-entropy and incompressible. Always-on for v3 (all clients share the same package, no interop flag needed).
### `api.ts`
High-level client. All requests/responses are msgpack (`Content-Type: application/msgpack`). Methods:
- `setOptions({ server })` / `getOptions()`
- `create(note, key): Promise<{ id: string }>` — encodes msgpack, compresses (LZ4), encrypts, POST `/api/v3/notes/`
- `info(id): Promise<NoteInfo>` — GET `/api/v3/notes/{id}`, returns metadata only (no `data`)
- `view(id, key): Promise<NotePublic>` — DELETE `/api/v3/notes/{id}`, decrypts `data`, decompresses (LZ4), decodes msgpack
- `status(): Promise<Status>` — GET `/api/v3/status` (still JSON — server config, not note data)
---
## 2. Backend (Rust)
### 2.1 Storage — `store.rs` rewrite
Switch from JSON-blob-per-key to **cache hashes** (valkey or redis, both speak RESP):
```
Key: {CACHE_PREFIX}{id}
Fields:
views (i32) # remaining views, or absent
expiration (u32) # unix timestamp, or absent
type ("text"|"file")
derivation (msgpack bytes, optional) # scrypt salt+params if password-based
data (bytes) # encrypted msgpack blob
```
Functions:
- `set(id, note)` → `HSET` all fields + `EXPIRE` (if time-limited)
- `get_meta(id)` → `HMGET views expiration type derivation` — never touches `data` (cheap preview)
- `get_data(id)` → `HGET data` — only when consuming
- `decrement_view(id)` → `HINCRBY views -1` — **atomic**, see 2.2
- `del(id)` → `DEL`
- `can_reach_cache()` — health check (renamed from `can_reach_redis`)
Use `rmp-serde` for msgpack (de)serialization of note structs.
### 2.2 Remove `lock.rs`
The per-id `Mutex` map in `SharedState` existed only because the consume endpoint did non-atomic read-modify-write on `views`. With `HINCRBY` this is atomic at the cache level.
- Delete `packages/backend/src/lock.rs`
- Remove `SharedState` from `main.rs` (the `.with_state(shared_state)` call)
- Remove the lock map + `Arc`/`Mutex` imports
### 2.3 Rewrite `note/`
- `model.rs` — msgpack-compatible structs (derive `Serialize`/`Deserialize` for `rmp-serde`)
- `routes.rs` — three handlers:
#### `create` — `POST /api/v3/notes/`
- Accepts `application/msgpack` body (raw `Bytes`)
- Deserialize with rmp-serde
- Validate:
- At least one of `views`/`expiration` must be set
- `views` ≤ `MAX_VIEWS` and ≥ 1
- `expiration` ≤ `MAX_EXPIRATION` (minutes) and ≥ 1
- If `ALLOW_ADVANCED=false`: force `views=1, expiration=None`
- Store via `store::set`
- Return `{ id }` as msgpack
#### `preview` (info) — `GET /api/v3/notes/{id}`
- `store::get_meta(id)` — does not load `data`
- Return metadata as msgpack (no `data` field)
- `404` if not found
#### `view` (consume) — `DELETE /api/v3/notes/{id}`
- If `views` is set:
- `HINCRBY views -1` (atomic)
- If result ≤ 0: `HGET data`, `DEL` key, return data
- If result > 0: `HGET data`, return data (note survives for remaining views)
- If `views` is not set (time-only):
- `HGET data`, `DEL` key, return data
- Expiration handled lazily by cache (`EXPIRE` on the key) — no manual `if e < n` check on read
### 2.4 Config — `config.rs`
Rename:
- `REDIS` env → `CACHE`
- `REDIS_PREFIX` → `CACHE_PREFIX`
- `REDIS_CLIENT` static → `CACHE_CLIENT`
Everything else stays.
### 2.5 Health — `health/mod.rs`
- Rename `can_reach_redis` → `can_reach_cache`. Update panic message in `main.rs`.
- Move route from `/api/live` to `/healthz` (k8s standard). Not under `/api/v3/` — health checks are infrastructure, not API surface.
### 2.6 Status — `status/mod.rs`
Keep as JSON. It's server configuration, not note data — msgpack adds nothing. Frontend fetches once on load.
### 2.7 Dependencies — `Cargo.toml`
- Add `rmp-serde` (msgpack)
- Remove `serde_json` if no longer used (status endpoint still uses `Json<T>` which needs `serde_json` — keep)
- Keep `redis` crate (RESP client, works with valkey and redis)
---
## 3. "Both" constraint (views AND expiration)
New in v3: a note can have **both** `views` and `expiration` set simultaneously.
Implementation:
- `views` decremented via `HINCRBY views -1` on each consume
- `expiration` set via key-level `EXPIRE` (unix timestamp → seconds remaining)
- Whichever trips first removes the note:
- Views hit 0 → we `DEL` on the last consume
- Time expires → cache lazily removes the key
- No read-time expiration check needed in application code
---
## 4. Infra / docs cleanup
- `docker-compose.dev.yaml`: rename `redis` service → `cache` (image stays `valkey/valkey:7-alpine`, operators can swap for redis)
- `docker-compose.yaml` (if present): same
- `Dockerfile`: `ENV REDIS=...` → `ENV CACHE=...`
- `package.json` (root): `dev:docker` script service name
- `README.md`, `README_ES.md`, `README_zh-CN.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `examples/*` — replace "redis" with "cache" (or "valkey/redis" where context calls for naming the implementation)
- `Cryptgeon.postman_collection.json` — update content types to `application/msgpack`
- `.env.dev` — update `REDIS` → `CACHE` if present
- Healthcheck URLs: update all `/api/live` references → `/healthz` (docker-compose files, README, postman collection)
---
## 5. CLI (`packages/cli`)
- Drop `occulto` dependency
- Delete `packages/cli/src/shared/` (api.ts, adapters.ts, shared.ts) — replaced by `@cryptgeon/shared`
- `actions/upload.ts` and `actions/download.ts` call into `@cryptgeon/shared` API client
- Package still published as `cryptgeon` on npm
- `@cryptgeon/shared` stays workspace-internal (not published) for now
---
## 6. Frontend (`packages/frontend`)
- Drop `occulto` dependency
- Import from `@cryptgeon/shared` instead of `cryptgeon/shared`
- Update `package.json`: `"cryptgeon": "workspace:*"` → `"@cryptgeon/shared": "workspace:*"`
- Update files:
- `src/lib/views/Create.svelte`
- `src/lib/ui/ShowNote.svelte`
- `src/lib/ui/FileUpload.svelte`
- `src/lib/ui/PastedFilesPreview.svelte`
- `src/lib/ui/AdvancedParameters.svelte`
- `src/lib/stores/status.ts`
- `src/routes/note/[id]/+page.svelte`
---
## 7. msgpack note schema — "matrioshka" design
The server is **agnostic to the content**. It only sees an outer envelope with metadata and an opaque encrypted blob. The content type (text vs. files) lives inside the encrypted inner layer, invisible to the server.
### Outer layer (server-visible)
```
{
meta: {
expiration: u32? # optional, unix timestamp
views: u32? # optional, remaining view count
extra: bytes? # optional, client-opaque, size-limited
}
data: bytes # encrypted inner msgpack blob
}
```
- `meta.expiration` / `meta.views`: at least one must be set; both can be set simultaneously (see section 3)
- `meta.extra`: opaque client-owned data the server stores and returns verbatim in preview, but never interprets. Used for `derivation` (scrypt salt + params) so the client knows at preview time whether to prompt for a password. Size-limited (e.g. 512 bytes) to prevent abuse.
### Inner layer (encrypted, client-only)
Inside the encrypted `data` blob, after decryption, is a msgpack union:
```
# Text note
{ type: "text", data: string }
# File note
{ type: "files", data: [{ name: string, mime: string, data: bytes }] }
```
The server never sees this structure — it stores/retrieves `data` as opaque bytes.
The inner msgpack blob is **LZ4-compressed before encryption** (see `compression.ts`). Full client pipeline: `msgpack encode → lz4 compress → xchacha20poly1305 encrypt`, reversed on consume. The server is unaware of compression — it only ever handles the encrypted `data` bytes.
### Endpoints
#### `POST /api/v3/notes/` — create
**Request** (msgpack): outer layer `{ meta: { expiration?, views?, extra? }, data }`
**Response** (msgpack): `{ id: string }`
#### `GET /api/v3/notes/{id}` — preview / info
**Response** (msgpack): `{ meta: { expiration?, views?, extra? } }`
Returns metadata only — does not load `data` from cache. Client inspects `meta.extra` to determine key derivation strategy (password vs. URL-fragment key) before consuming.
#### `DELETE /api/v3/notes/{id}` — view / consume
**Response** (msgpack): `{ meta: { expiration?, views?, extra? }, data: bytes }`
Returns the full envelope. Client decrypts `data` using key derived from `meta.extra` (if present) or URL fragment, then decodes the inner msgpack to get text/files.
#### `GET /api/v3/status` — server config
**Response** (JSON, not msgpack): server configuration, not note data. Kept as JSON for simplicity.
### Valkey hash field layout
```
Key: {CACHE_PREFIX}{id}
Fields:
views (i32) # remaining views, or absent
expiration (u32) # unix timestamp, or absent
extra (bytes) # client-opaque, size-limited
data (bytes) # encrypted msgpack blob
```
`get_meta(id)` does `HMGET views expiration extra` — never touches `data`.
---
## 8. Implementation order
1. Shared package scaffolding (package.json, tsconfig, vitest config)
2. `crypto.ts` + tests
3. `compression.ts` + tests
4. `types.ts`
5. Backend: config rename + store rewrite + remove lock.rs
6. Backend: note routes rewrite (msgpack)
7. `api.ts` in shared (client) + tests
8. CLI rewrite (drop shared/, use @cryptgeon/shared)
9. Frontend migration
10. Infra/docs cleanup (cache rename, compose, Dockerfile)
11. Integration tests (playwright)
+1 -1
View File
@@ -2,7 +2,7 @@
"scripts": {
"dev:docker": "docker compose -f docker-compose.dev.yaml up cache",
"dev:packages": "pnpm --parallel run dev",
"dev": "pnpm --parallel run dev:*",
"dev": "pnpm --parallel run /dev/",
"docker:up": "docker compose -f docker-compose.dev.yaml up",
"docker:build": "docker compose -f docker-compose.dev.yaml build",
"test": "playwright test --project=chrome --project=firefox --project=safari",
+2 -2
View File
@@ -3,7 +3,7 @@ import { access, constants, writeFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import { decode } from '@msgpack/msgpack'
import pretty from 'pretty-bytes'
import { decrypt, deriveKey, setServer, getServer, info, get } from '@cryptgeon/shared'
import { decrypt, deriveKey, setServer, getServer, info, get, decompress } from '@cryptgeon/shared'
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
setServer(url.origin)
@@ -32,7 +32,7 @@ export async function download(url: URL, all: boolean, suggestedPassword?: strin
if (!note) throw new Error('Could not load note')
const decrypted = decrypt(note.data, key)
const content = decode(decrypted) as any
const content = decode(decompress(decrypted)) as any
switch (content.type) {
case 'files':
+2 -2
View File
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
import { encode } from '@msgpack/msgpack'
import mime from 'mime'
import { encrypt, generateKey, deriveKey, randomBytes, setServer, getServer, create, utf8ToBytes } from '@cryptgeon/shared'
import { encrypt, generateKey, deriveKey, randomBytes, setServer, getServer, create, utf8ToBytes, compress } from '@cryptgeon/shared'
export type UploadOptions = { views?: number; expiration?: number; password?: string }
@@ -35,7 +35,7 @@ export async function upload(input: string | string[], options: UploadOptions):
inner = encode({ type: 'files', data: files })
}
const data = encrypt(inner, key)
const data = encrypt(compress(inner), key)
const result = await create({ meta: { ...noteOptions, extra }, data })
let url = `${getServer()}/note/${result.id}`
if (!password) url += `#${Buffer.from(key).toString('hex')}`
@@ -1,7 +1,7 @@
<script lang="ts">
import {
deriveKey, generateKey, encrypt, randomBytes,
bytesToHex, encode,
bytesToHex, encode, compress,
create as apiCreate,
type FileDTO, type ServerNote
} from '@cryptgeon/shared'
@@ -131,7 +131,12 @@
inner = encode({ type: 'text', data: textContent })
}
const data = encrypt(inner, key)
const originalSize =inner.byteLength
const compressed = compress(inner)
const compresseedSize= compressed.byteLength
console.debug({originalSize, compresseedSize, ratio: originalSize/compresseedSize})
const data = encrypt(compress(inner), key)
const extra = customPassword
? encode({ salt: salt!, N: 32768, r: 8, p: 1 })
: new Uint8Array()
@@ -281,4 +286,4 @@
transform: rotate(360deg);
}
}
</style>
</style>
@@ -1,5 +1,5 @@
<script lang="ts">
import { deriveKey, hexToBytes, decrypt, decode, info, get as apiGet, type FileDTO } from '@cryptgeon/shared'
import { deriveKey, hexToBytes, decrypt, decode, decompress, info, get as apiGet, type FileDTO } from '@cryptgeon/shared'
import { onMount } from 'svelte'
import { t } from 'svelte-intl-precompile'
@@ -69,8 +69,8 @@
key = hexToBytes(password!)
}
const decrypted = decrypt(serverNote.data, key)
const content = decode(decrypted) as any
const decrypted = decrypt(serverNote.data, key)
const content = decode(decompress(decrypted)) as any
switch (content.type) {
case 'text':
+3 -1
View File
@@ -9,9 +9,11 @@
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"@noble/ciphers": "^2.2.0",
"@noble/hashes": "^2.2.0"
"@noble/hashes": "^2.2.0",
"lz4js": "^0.2.0"
},
"devDependencies": {
"@types/lz4js": "^0.2.2",
"typescript": "^5.9.3",
"vitest": "^4.1.7"
},
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { compress, decompress, utf8ToBytes } from "./index";
describe("compression", () => {
it("round-trips small text", () => {
const data = utf8ToBytes("hello world");
const compressed = compress(data);
const decompressed = decompress(compressed);
expect(decompressed).toEqual(data);
});
it("round-trips highly compressible data", () => {
const data = utf8ToBytes("a".repeat(10_000));
const compressed = compress(data);
expect(compressed.length).toBeLessThan(data.length);
const decompressed = decompress(compressed);
expect(decompressed).toEqual(data);
});
it("round-trips arbitrary bytes", () => {
const data = new Uint8Array([0, 128, 255, 1, 2, 3, 200, 100]);
const compressed = compress(data);
const decompressed = decompress(compressed);
expect(decompressed).toEqual(data);
});
});
+9
View File
@@ -0,0 +1,9 @@
import LZ4 from "lz4js";
export function compress(data: Uint8Array): Uint8Array {
return LZ4.compress(data);
}
export function decompress(data: Uint8Array): Uint8Array {
return LZ4.decompress(data);
}
+8 -17
View File
@@ -1,9 +1,14 @@
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
import { managedNonce, randomBytes, utf8ToBytes } from "@noble/ciphers/utils.js";
import { managedNonce, randomBytes } from "@noble/ciphers/utils.js";
import { scrypt } from "@noble/hashes/scrypt.js";
export { bytesToUtf8, utf8ToBytes } from "@noble/ciphers/utils.js";
export { randomBytes } from "@noble/ciphers/utils.js";
export {
bytesToUtf8,
utf8ToBytes,
hexToBytes,
bytesToHex,
randomBytes,
} from "@noble/ciphers/utils.js";
const N = 2 ** 15;
const KEY_SIZE = 32;
@@ -25,17 +30,3 @@ export function decrypt(data: Uint8Array, key: Uint8Array): Uint8Array {
const chacha = managedNonce(xchacha20poly1305)(key);
return chacha.decrypt(data);
}
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
+2 -1
View File
@@ -1,4 +1,5 @@
export * from "./crypto.js";
export * from "./types.js";
export * from "./api.js";
export { encode, decode } from "@msgpack/msgpack";
export * from "./compression.js";
export { encode, decode } from "@msgpack/msgpack";
+16
View File
@@ -123,7 +123,13 @@ importers:
'@noble/hashes':
specifier: ^2.2.0
version: 2.2.0
lz4js:
specifier: ^0.2.0
version: 0.2.0
devDependencies:
'@types/lz4js':
specifier: ^0.2.2
version: 0.2.2
typescript:
specifier: ^5.9.3
version: 5.9.3
@@ -828,6 +834,9 @@ packages:
'@types/inquirer@9.0.10':
resolution: {integrity: sha512-vFW2WbXwO9eZpRT5GJGFJ/shgyMNnYozmnjakt9jCQSS1lvqX8pZEQMjJ9RdDPct/YxwciQ8+V8OYn9euIrZDA==}
'@types/lz4js@0.2.2':
resolution: {integrity: sha512-pqXfoJ2AwllcLTf1Nia0+HT1KHj8z4wWo3bBP6vn7Aen5ySywBqrh/u1TyBwWxuKDS+mPzVHkPbHivqZEfk6pA==}
'@types/node@22.20.0':
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
@@ -1440,6 +1449,9 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lz4js@0.2.0:
resolution: {integrity: sha512-gY2Ia9Lm7Ep8qMiuGRhvUq0Q7qUereeldZPP1PMEJxPtEWHJLqw9pgX68oHajBH0nzJK4MaZEA/YNV3jT8u8Bg==}
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
@@ -2799,6 +2811,8 @@ snapshots:
'@types/through': 0.0.33
rxjs: 7.8.1
'@types/lz4js@0.2.2': {}
'@types/node@22.20.0':
dependencies:
undici-types: 6.21.0
@@ -3334,6 +3348,8 @@ snapshots:
dependencies:
yallist: 3.1.1
lz4js@0.2.0: {}
magic-string@0.30.17:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0