From 9de70cdc3c5148196d83993486364d7eca249764 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Tue, 28 Jul 2026 14:27:46 +0700 Subject: [PATCH] Add ThreeMfDef FileDef subclass for 3MF (3D printing) files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Index .3mf files as a first-class file type. 3MF is an OPC (ZIP) package whose 3dmodel.model XML carries title/designer/description/license and a unit — searchable text that is absent from the filename — so the subclass extracts it for full-text search. A bounded parse (inflate only the model part, read the header up to the geometry) keeps extractAttributes cheap on the indexing hot path; malformed files throw FileContentMismatchError and fall back to a bare FileDef. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/3mf-file-def.gts | 210 +++++++++++ packages/base/3mf-meta-extractor.ts | 127 +++++++ packages/base/package.json | 1 + packages/host/package.json | 1 + .../tests/acceptance/3mf-file-def-test.gts | 328 ++++++++++++++++++ .../host/tests/unit/file-def-code-ref-test.ts | 49 +++ packages/runtime-common/file-def-code-ref.ts | 1 + packages/runtime-common/infer-content-type.ts | 5 +- pnpm-lock.yaml | 40 ++- pnpm-workspace.yaml | 1 + 10 files changed, 749 insertions(+), 14 deletions(-) create mode 100644 packages/base/3mf-file-def.gts create mode 100644 packages/base/3mf-meta-extractor.ts create mode 100644 packages/host/tests/acceptance/3mf-file-def-test.gts diff --git a/packages/base/3mf-file-def.gts b/packages/base/3mf-file-def.gts new file mode 100644 index 00000000000..fe007d787dd --- /dev/null +++ b/packages/base/3mf-file-def.gts @@ -0,0 +1,210 @@ +import { byteStreamToUint8Array } from '@cardstack/runtime-common'; +import Cube3dSphereIcon from '@cardstack/boxel-icons/cube-3d-sphere'; +import { + BaseDefComponent, + Component, + StringField, + contains, + field, +} from './card-api'; +import BooleanField from './boolean'; +import { + FileDef, + type ByteStream, + type SerializedFile, +} from './file-api'; +import { extract3mfMetadata } from './3mf-meta-extractor'; + +type ThreeMfExtra = { + title?: string; + designer?: string; + description?: string; + license?: string; + unit?: string; + hasThumbnail: boolean; +}; + +function threeMfTitle( + model: { title?: string | null; name?: string | null } | null | undefined, +): string { + return model?.title ?? model?.name ?? 'Untitled 3MF model'; +} + +class Isolated extends Component { + get title() { + return threeMfTitle(this.args.model); + } + +} + +class Embedded extends Component { + get title() { + return threeMfTitle(this.args.model); + } + +} + +// 3MF (3D Manufacturing Format) — the ZIP+XML successor to STL used for 3D +// printing. Unlike STL, it self-describes with title/designer/description/ +// license text that is not in the filename, so extracting it delivers real +// full-text search value. See `3mf-meta-extractor.ts` for the bounded parse. +export class ThreeMfDef extends FileDef { + static displayName = '3MF Model'; + static icon = Cube3dSphereIcon; + static acceptTypes = '.3mf,model/3mf'; + + @field title = contains(StringField); + @field designer = contains(StringField); + @field description = contains(StringField); + @field license = contains(StringField); + @field unit = contains(StringField); + @field hasThumbnail = contains(BooleanField); + + static isolated: BaseDefComponent = Isolated; + static embedded: BaseDefComponent = Embedded; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string; contentSize?: number } = {}, + ): Promise> { + let base = await super.extractAttributes(url, getStream, options); + // 3MF is a ZIP container, so we need the whole file in memory to unzip it; + // `extract3mfMetadata` then bounds the XML parse to the model header. + let bytes = await byteStreamToUint8Array(await getStream()); + let metadata = extract3mfMetadata(bytes); + return { ...base, ...metadata }; + } +} + +export default ThreeMfDef; diff --git a/packages/base/3mf-meta-extractor.ts b/packages/base/3mf-meta-extractor.ts new file mode 100644 index 00000000000..f2d82144785 --- /dev/null +++ b/packages/base/3mf-meta-extractor.ts @@ -0,0 +1,127 @@ +import { unzipSync, strFromU8 } from 'fflate'; +import { FileContentMismatchError } from './file-api'; + +// A 3MF file is an OPC package: a ZIP archive of XML parts. The part we read is +// the 3D model (conventionally `3D/3dmodel.model`), whose `` root carries +// a `unit` attribute and a `` block — both sitting *above* the +// `` geometry. Everything a user searches for (title, designer, +// description, license) lives in that header block and is absent from the +// filename, which is why 3MF earns its own FileDef. + +export interface ThreeMfMetadata { + title?: string; + designer?: string; + description?: string; + license?: string; + unit?: string; + hasThumbnail: boolean; +} + +// The model part holds all the geometry, so it can be large. We only need the +// header, which is small and at the top — decode at most this many bytes before +// parsing so the work stays bounded on the indexing hot path regardless of mesh +// size. +const MODEL_HEADER_MAX_BYTES = 262_144; // 256 KB + +// Case-insensitive, namespace-prefix tolerant (``) matchers. These +// live in a plain `.ts` module (not `.gts`) so regex literals are safe from the +// content-tag lexer quirks that affect `.gts` files. +const MODEL_PART_RE = /\.model$/i; +const GEOMETRY_START_RE = /<(?:\w+:)?(?:resources|build)\b/i; +const MODEL_UNIT_RE = + /<(?:\w+:)?model\b[^>]*\bunit\s*=\s*(?:"([^"]*)"|'([^']*)')/i; +const METADATA_RE = + /<(?:\w+:)?metadata\b[^>]*\bname\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/(?:\w+:)?metadata>/gi; +const THUMBNAIL_RE = /thumbnail/i; +const IMAGE_EXT_RE = /\.(?:png|jpe?g)$/i; + +// 3MF's default unit when the `` element omits it (spec §the model +// element): millimeter. We fill it in so the field is always meaningful. +const DEFAULT_UNIT = 'millimeter'; + +function decodeXmlEntities(value: string): string { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))) + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => + String.fromCodePoint(parseInt(hex, 16)), + ) + .replace(/&/g, '&'); +} + +function firstMatch(...values: (string | undefined)[]): string | undefined { + for (let value of values) { + if (value != null && value.trim() !== '') { + return value; + } + } + return undefined; +} + +export function extract3mfMetadata(bytes: Uint8Array): ThreeMfMetadata { + // Collect every entry name via the filter callback (called for all entries) + // while only inflating the model part — the thumbnail/texture parts stay + // compressed. The names give us the thumbnail signal for free. + let names: string[] = []; + let files: Record; + try { + files = unzipSync(bytes, { + filter: (file) => { + names.push(file.name); + return MODEL_PART_RE.test(file.name); + }, + }); + } catch { + throw new FileContentMismatchError('3MF file is not a valid ZIP archive'); + } + + let modelName = + Object.keys(files).find((name) => name === '3D/3dmodel.model') ?? + Object.keys(files).find((name) => MODEL_PART_RE.test(name)); + if (!modelName) { + throw new FileContentMismatchError( + '3MF archive has no 3D model part (no *.model entry)', + ); + } + + let modelBytes = files[modelName]; + let header = strFromU8(modelBytes.subarray(0, MODEL_HEADER_MAX_BYTES)); + let geometryStart = header.search(GEOMETRY_START_RE); + if (geometryStart >= 0) { + header = header.slice(0, geometryStart); + } + + let unitMatch = header.match(MODEL_UNIT_RE); + let unit = firstMatch(unitMatch?.[1], unitMatch?.[2]) ?? DEFAULT_UNIT; + + let metadata: Record = {}; + let match: RegExpExecArray | null; + while ((match = METADATA_RE.exec(header))) { + let name = firstMatch(match[1], match[2]); + if (!name) { + continue; + } + let value = decodeXmlEntities((match[3] ?? '').trim()); + if (value !== '') { + metadata[name] = value; + } + } + // Reset lastIndex so the shared /g regex is safe on the next call. + METADATA_RE.lastIndex = 0; + + let hasThumbnail = names.some( + (name) => THUMBNAIL_RE.test(name) && IMAGE_EXT_RE.test(name), + ); + + return { + title: metadata['Title'], + designer: metadata['Designer'], + description: metadata['Description'], + license: firstMatch(metadata['LicenseTerms'], metadata['Copyright']), + unit, + hasThumbnail, + }; +} diff --git a/packages/base/package.json b/packages/base/package.json index 542b669c508..1c83dcc4653 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -21,6 +21,7 @@ "ember-modifier": "^3.2.1", "ember-resources": "catalog:", "ember-template-lint": "catalog:", + "fflate": "catalog:", "matrix-js-sdk": "catalog:", "super-fast-md5": "catalog:", "@floating-ui/dom": "catalog:", diff --git a/packages/host/package.json b/packages/host/package.json index 053aa78047e..c12a0ae21c5 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -152,6 +152,7 @@ "ethers": "catalog:", "eventemitter3": "catalog:", "fast-json-stable-stringify": "catalog:", + "fflate": "catalog:", "filesize": "catalog:", "flat": "catalog:", "glimmer-scoped-css": "catalog:", diff --git a/packages/host/tests/acceptance/3mf-file-def-test.gts b/packages/host/tests/acceptance/3mf-file-def-test.gts new file mode 100644 index 00000000000..c5ed32cca61 --- /dev/null +++ b/packages/host/tests/acceptance/3mf-file-def-test.gts @@ -0,0 +1,328 @@ +import { visit, waitUntil } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; + +import { zipSync, strToU8 } from 'fflate'; + +import { module, test } from 'qunit'; + +import { + baseRealmRRI, + type FileExtractResponse, + type RenderRouteOptions, + type ResolvedCodeRef, + SupportedMimeType, + type RealmResourceIdentifier, +} from '@cardstack/runtime-common'; +import type { Realm } from '@cardstack/runtime-common/realm'; + +import type NetworkService from '@cardstack/host/services/network'; + +import { + setupLocalIndexing, + setupOnSave, + setupRealmCacheTeardown, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + capturePrerenderResult, + withCachedRealmSetup, +} from '../helpers'; +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupApplicationTest } from '../helpers/setup'; +import { setupTestRealmServiceWorker } from '../helpers/test-realm-service-worker'; + +// A minimal but realistic 3MF: an OPC package (ZIP) whose `3D/3dmodel.model` +// XML carries the searchable metadata and a tiny mesh, plus a thumbnail part. +function makeMinimal3mf(): Uint8Array { + let model = ` + + Benchy Boat + Creative Tools & Co + A calibration boat for testing 3D printers. + CC BY 4.0 + PrusaSlicer 2.7 + + + + + + + + + +`; + let contentTypes = ``; + let rels = ``; + return zipSync({ + '[Content_Types].xml': strToU8(contentTypes), + '_rels/.rels': strToU8(rels), + '3D/3dmodel.model': strToU8(model), + 'Metadata/thumbnail.png': new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + }); +} + +// A ZIP that is a valid archive but has no `*.model` part. +function makeZipWithoutModel(): Uint8Array { + return zipSync({ 'readme.txt': strToU8('not a 3mf model') }); +} + +module('Acceptance | 3mf file def', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + setupRealmCacheTeardown(hooks); + setupTestRealmServiceWorker(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + let realm: Realm; + + const fileExtractPath = ( + url: string, + renderOptions: RenderRouteOptions, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/file-extract`; + + const fileRenderPath = ( + url: string, + renderOptions: RenderRouteOptions, + format = 'isolated', + ancestorLevel = 0, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/html/${format}/${ancestorLevel}`; + + const makeFileURL = (path: string) => new URL(path, testRealmURL).href; + + const threeMfDefCodeRef = (): ResolvedCodeRef => ({ + module: `${baseRealmRRI}3mf-file-def` as RealmResourceIdentifier, + name: 'ThreeMfDef', + }); + + async function captureFileExtractResult( + expectedStatus?: 'ready' | 'error', + ): Promise { + await waitUntil( + () => { + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + return false; + } + let status = container.getAttribute( + 'data-prerender-file-extract-status', + ); + if (!status) { + return false; + } + if (expectedStatus && status !== expectedStatus) { + return false; + } + return status === 'ready' || status === 'error'; + }, + { timeout: 5000 }, + ); + + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + throw new Error( + 'captureFileExtractResult: missing [data-prerender-file-extract] container after wait', + ); + } + let pre = container.querySelector('pre'); + let text = pre?.textContent?.trim() ?? ''; + return JSON.parse(text) as FileExtractResponse; + } + + hooks.beforeEach(async function () { + ({ realm } = await withCachedRealmSetup(async () => + setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'model.3mf': makeMinimal3mf(), + 'not-a-3mf.3mf': 'This is plain text, not a ZIP archive.', + 'no-model.3mf': makeZipWithoutModel(), + }, + }), + )); + }); + + hooks.afterEach(function () { + delete (globalThis as any).__renderModel; + delete (globalThis as any).__boxelFileRenderData; + }); + + test('extracts metadata from 3mf', async function (assert) { + let url = makeFileURL('model.3mf'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: threeMfDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual( + result.searchDoc?.title, + 'Benchy Boat', + 'extracts title', + ); + assert.strictEqual( + result.searchDoc?.designer, + 'Creative Tools & Co', + 'extracts designer and decodes entities', + ); + assert.strictEqual( + result.searchDoc?.description, + 'A calibration boat for testing 3D printers.', + 'extracts description', + ); + assert.strictEqual( + result.searchDoc?.license, + 'CC BY 4.0', + 'extracts license', + ); + assert.strictEqual(result.searchDoc?.unit, 'millimeter', 'extracts unit'); + assert.true(result.searchDoc?.hasThumbnail, 'detects thumbnail'); + assert.strictEqual(result.searchDoc?.name, 'model.3mf'); + assert.strictEqual( + result.searchDoc?.contentType, + 'model/3mf', + 'sets 3mf content type', + ); + }); + + test('falls back when ThreeMfDef is used for non-ZIP content', async function (assert) { + let url = makeFileURL('not-a-3mf.3mf'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: threeMfDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.true(result.mismatch, 'marks mismatch when content is not a ZIP'); + assert.strictEqual(result.searchDoc?.name, 'not-a-3mf.3mf'); + }); + + test('falls back when the ZIP has no model part', async function (assert) { + let url = makeFileURL('no-model.3mf'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: threeMfDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.true(result.mismatch, 'marks mismatch when there is no model part'); + assert.strictEqual(result.searchDoc?.name, 'no-model.3mf'); + }); + + test('isolated template renders the title and designer', async function (assert) { + let url = makeFileURL('model.3mf'); + + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: threeMfDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: threeMfDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: threeMfDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let title = document.querySelector( + '[data-prerender] .threemf-isolated__title', + ); + assert.strictEqual( + title?.textContent?.trim(), + 'Benchy Boat', + 'renders the extracted title', + ); + let designer = document.querySelector( + '[data-prerender] .threemf-isolated__designer', + ); + assert.strictEqual( + designer?.textContent?.trim(), + 'by Creative Tools & Co', + 'renders the designer', + ); + }); + + test('indexing stores 3mf metadata and file meta uses it', async function (assert) { + let fileURL = new URL('model.3mf', testRealmURL); + let fileEntry = await realm.realmIndexQueryEngine.file(fileURL); + + assert.ok(fileEntry, 'file entry exists'); + assert.strictEqual( + fileEntry?.searchDoc?.title, + 'Benchy Boat', + 'index stores 3mf title', + ); + assert.strictEqual( + fileEntry?.searchDoc?.designer, + 'Creative Tools & Co', + 'index stores 3mf designer', + ); + + let network = getService('network') as NetworkService; + let response = await network.virtualNetwork.fetch(fileURL, { + headers: { Accept: SupportedMimeType.FileMeta }, + }); + + assert.true(response.ok, 'file meta request succeeds'); + + let body = await response.json(); + assert.strictEqual(body?.data?.type, 'file-meta'); + assert.strictEqual( + body?.data?.attributes?.contentType, + 'model/3mf', + 'file meta uses 3mf content type', + ); + assert.strictEqual( + body?.data?.attributes?.title, + 'Benchy Boat', + 'file meta includes 3mf title', + ); + assert.strictEqual( + body?.data?.attributes?.designer, + 'Creative Tools & Co', + 'file meta includes 3mf designer', + ); + assert.deepEqual( + body?.data?.meta?.adoptsFrom, + threeMfDefCodeRef(), + 'file meta uses 3mf def', + ); + }); +}); diff --git a/packages/host/tests/unit/file-def-code-ref-test.ts b/packages/host/tests/unit/file-def-code-ref-test.ts index 15e0af6c4ab..bd802e5bed0 100644 --- a/packages/host/tests/unit/file-def-code-ref-test.ts +++ b/packages/host/tests/unit/file-def-code-ref-test.ts @@ -5,6 +5,10 @@ import { baseRealm, baseRRI, isFileDefCodeRef, + urlNamesFile, + resolveFileDefCodeRef, + inferContentType, + isBinaryFilename, } from '@cardstack/runtime-common'; import type { RealmResourceIdentifier } from '@cardstack/runtime-common'; @@ -59,6 +63,13 @@ module('Unit | isFileDefCodeRef', function (hooks) { ), 'PngDef', ); + assert.true( + isFileDefCodeRef( + { module: baseRRI('3mf-file-def'), name: 'ThreeMfDef' }, + virtualNetwork, + ), + 'ThreeMfDef', + ); }); test('rejects a non-FileDef card ref', function (assert) { @@ -93,3 +104,41 @@ module('Unit | isFileDefCodeRef', function (hooks) { ); }); }); + +module('Unit | 3mf file def wiring', function (hooks) { + let virtualNetwork: VirtualNetwork; + + hooks.beforeEach(function () { + virtualNetwork = new VirtualNetwork(); + virtualNetwork.addURLMapping( + new URL(baseRealm.url), + new URL(resolvedBaseRealmURL), + ); + virtualNetwork.addRealmMapping('@cardstack/base/', resolvedBaseRealmURL); + }); + + test('urlNamesFile recognizes a .3mf URL', function (assert) { + assert.true(urlNamesFile(new URL('http://test-realm/test/widget.3mf'))); + assert.false(urlNamesFile(new URL('http://test-realm/test/Widget/config'))); + }); + + test('resolveFileDefCodeRef maps .3mf to ThreeMfDef', function (assert) { + let ref = resolveFileDefCodeRef( + new URL('http://test-realm/test/widget.3mf'), + virtualNetwork, + ); + assert.strictEqual(ref.name, 'ThreeMfDef', 'resolves to ThreeMfDef'); + assert.true( + ref.module.endsWith('3mf-file-def'), + 'resolves the 3mf-file-def module', + ); + }); + + test('.3mf infers the model/3mf content type and is binary', function (assert) { + assert.strictEqual(inferContentType('widget.3mf'), 'model/3mf'); + assert.true( + isBinaryFilename('widget.3mf'), + '3mf is a binary ZIP container', + ); + }); +}); diff --git a/packages/runtime-common/file-def-code-ref.ts b/packages/runtime-common/file-def-code-ref.ts index 9dc1ac622a7..446379729de 100644 --- a/packages/runtime-common/file-def-code-ref.ts +++ b/packages/runtime-common/file-def-code-ref.ts @@ -39,6 +39,7 @@ const FILEDEF_CODE_REF_BY_EXTENSION: Record = { '.opus': { module: baseModule('ogg-audio-def'), name: 'OggDef' }, '.m4a': { module: baseModule('m4a-audio-def'), name: 'M4aDef' }, '.flac': { module: baseModule('flac-audio-def'), name: 'FlacDef' }, + '.3mf': { module: baseModule('3mf-file-def'), name: 'ThreeMfDef' }, '.mismatch': { module: './filedef-mismatch' as RealmResourceIdentifier, name: 'FileDef', diff --git a/packages/runtime-common/infer-content-type.ts b/packages/runtime-common/infer-content-type.ts index ef438efd049..f56c6b18c17 100644 --- a/packages/runtime-common/infer-content-type.ts +++ b/packages/runtime-common/infer-content-type.ts @@ -4,6 +4,8 @@ const DEFAULT_FILE_CONTENT_TYPE = 'application/octet-stream'; const CONTENT_TYPE_OVERRIDES: Record = { '.gts': 'text/typescript+glimmer', '.ts': 'text/typescript', + // 3MF (3D printing) — IANA-registered `model/3mf`; mime-db has no entry. + '.3mf': 'model/3mf', }; export function inferContentType(filename: string): string { @@ -29,6 +31,7 @@ export function isBinaryFilename(filename: string): boolean { mimeType.startsWith('font/') || mimeType.startsWith('audio/') || mimeType === 'application/pdf' || - mimeType === 'application/vnd.ms-fontobject' // .eot legacy font + mimeType === 'application/vnd.ms-fontobject' || // .eot legacy font + mimeType === 'model/3mf' // 3MF is a binary ZIP container ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7cf7cd2cdd..90ceea461fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,6 +447,9 @@ catalogs: fast-json-stable-stringify: specifier: ^2.1.0 version: 2.1.0 + fflate: + specifier: ^0.8.3 + version: 0.8.3 file-loader: specifier: ^6.2.0 version: 6.2.0 @@ -940,6 +943,9 @@ importers: ember-template-lint: specifier: 'catalog:' version: 7.9.3 + fflate: + specifier: 'catalog:' + version: 0.8.3 matrix-js-sdk: specifier: 'catalog:' version: 38.3.0(patch_hash=cee0baf579283943dc5a6b48977e8ed40a13fc111b5de9c91814893f9a4989fb) @@ -1221,7 +1227,7 @@ importers: version: 5.5.6(@types/eslint@8.56.5)(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.8.4) http-server: specifier: ^14.1.1 - version: 14.1.1(debug@4.3.4) + version: 14.1.1 lucide-static: specifier: ^0.447.0 version: 0.447.0 @@ -2309,6 +2315,9 @@ importers: fast-json-stable-stringify: specifier: 'catalog:' version: 2.1.0 + fflate: + specifier: 'catalog:' + version: 0.8.3 filesize: specifier: 'catalog:' version: 10.1.6 @@ -2741,7 +2750,7 @@ importers: version: 11.1.0 http-server: specifier: 'catalog:' - version: 14.1.1(debug@4.3.4) + version: 14.1.1 js-yaml: specifier: 'catalog:' version: 4.2.0 @@ -2834,7 +2843,7 @@ importers: version: 3.2.0 wait-on: specifier: ^9.0.4 - version: 9.0.10(debug@4.3.4) + version: 9.0.10 wtfnode: specifier: ^0.10.1 version: 0.10.1 @@ -10313,6 +10322,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} engines: {node: '>=4'} @@ -20612,7 +20624,7 @@ snapshots: transitivePeerDependencies: - debug - axios@1.18.0(debug@4.3.4): + axios@1.18.0: dependencies: follow-redirects: 1.16.0(debug@4.3.4) form-data: 4.0.6 @@ -22884,7 +22896,7 @@ snapshots: heimdalljs-fs-monitor: 1.1.2 heimdalljs-graph: 1.0.0 heimdalljs-logger: 0.1.10 - http-proxy: 1.18.1(debug@4.3.4) + http-proxy: 1.18.1 inflection: 2.0.1 inquirer: 9.3.8(@types/node@24.13.2) is-git-url: 1.0.0 @@ -23026,7 +23038,7 @@ snapshots: heimdalljs-fs-monitor: 1.1.2 heimdalljs-graph: 1.0.0 heimdalljs-logger: 0.1.10 - http-proxy: 1.18.1(debug@4.3.4) + http-proxy: 1.18.1 inflection: 3.0.2 inquirer: 13.4.3(@types/node@25.9.3) is-git-url: 1.0.0 @@ -24576,6 +24588,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + figures@2.0.0: dependencies: escape-string-regexp: 1.0.5 @@ -25387,7 +25401,7 @@ snapshots: transitivePeerDependencies: - supports-color - http-proxy@1.18.1(debug@4.3.4): + http-proxy@1.18.1: dependencies: eventemitter3: 4.0.7 follow-redirects: 1.16.0(debug@4.3.4) @@ -25395,14 +25409,14 @@ snapshots: transitivePeerDependencies: - debug - http-server@14.1.1(debug@4.3.4): + http-server@14.1.1: dependencies: basic-auth: 2.0.1 chalk: 4.1.2 corser: 2.0.1 he: 1.2.0 html-encoding-sniffer: 3.0.0 - http-proxy: 1.18.1(debug@4.3.4) + http-proxy: 1.18.1 mime: 1.6.0 minimist: 1.2.8 opener: 1.5.2 @@ -26158,7 +26172,7 @@ snapshots: koa-proxies@0.12.4(koa@2.16.4): dependencies: - http-proxy: 1.18.1(debug@4.3.4) + http-proxy: 1.18.1 koa: 2.16.4 path-match: 1.2.4 uuid: 8.3.2 @@ -29220,7 +29234,7 @@ snapshots: execa: 9.6.1 express: 5.2.1 glob: 13.0.6 - http-proxy: 1.18.1(debug@4.3.4) + http-proxy: 1.18.1 js-yaml: 4.2.0 lodash: 4.18.1 minimatch: 10.2.5 @@ -30020,9 +30034,9 @@ snapshots: transitivePeerDependencies: - debug - wait-on@9.0.10(debug@4.3.4): + wait-on@9.0.10: dependencies: - axios: 1.18.0(debug@4.3.4) + axios: 1.18.0 joi: 18.2.1 lodash: 4.18.1 minimist: 1.2.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 59d07f2635c..e91d4249387 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -162,6 +162,7 @@ catalog: ethers: ^6.6.2 eventemitter3: ^5.0.1 fast-json-stable-stringify: ^2.1.0 + fflate: ^0.8.3 file-loader: ^6.2.0 filesize: ^10.0.12 flat: ^5.0.2