From 7862a520fb68286c3f367a93581c657234c65b9c Mon Sep 17 00:00:00 2001 From: sean Date: Mon, 19 Jan 2026 13:48:49 +0800 Subject: [PATCH] feat(core): rxc archive format with multi-file support - RXC now uses tar.gz format internally for multi-file resources - New API: createRXC({ content: "..." }) for single file - New API: createRXC({ 'path': Buffer }) for multi-file - New API: createRXC({ archive: Buffer }) for restoring from archive - New methods: rxc.file(path), rxc.files() - Removed: loadRXC (use loadResource instead) - Removed: rxc.text(), rxc.json() (use rxc.file('content')) - FolderLoader now supports any file names and nested directories - Added modern-tar dependency for tar packaging Co-Authored-By: Claude Opus 4.5 --- .changeset/rxc-archive-format.md | 41 +++++ CLAUDE.md | 40 +++-- README.md | 55 +++--- .../localhost/test.text@1.0.0/content | Bin 12 -> 101 bytes bdd/features/resourcex/content.feature | 90 ++++++++++ bdd/steps/registry/registry.steps.ts | 16 +- bdd/steps/resourcex/content.steps.ts | 151 ++++++++++++++++ bun.lock | 15 +- packages/core/README.md | 76 ++++---- packages/core/package.json | 3 +- packages/core/src/content/createRXC.ts | 148 ++++++++++------ packages/core/src/content/index.ts | 3 +- packages/core/src/content/loadRXC.ts | 26 --- packages/core/src/content/types.ts | 24 ++- packages/core/src/index.ts | 4 +- .../core/tests/unit/content/createRXC.test.ts | 166 ++++++++++-------- .../core/tests/unit/content/loadRXC.test.ts | 58 ------ packages/loader/src/FolderLoader.ts | 74 +++++--- .../loader/tests/unit/loadResource.test.ts | 85 +++++++-- .../registry/tests/unit/ARPRegistry.test.ts | 31 ++-- packages/resourcex/src/index.ts | 4 +- packages/type/src/builtinTypes.ts | 28 ++- .../type/tests/unit/TypeHandlerChain.test.ts | 42 +++-- 23 files changed, 782 insertions(+), 398 deletions(-) create mode 100644 .changeset/rxc-archive-format.md create mode 100644 bdd/features/resourcex/content.feature create mode 100644 bdd/steps/resourcex/content.steps.ts delete mode 100644 packages/core/src/content/loadRXC.ts delete mode 100644 packages/core/tests/unit/content/loadRXC.test.ts diff --git a/.changeset/rxc-archive-format.md b/.changeset/rxc-archive-format.md new file mode 100644 index 0000000..72c1f19 --- /dev/null +++ b/.changeset/rxc-archive-format.md @@ -0,0 +1,41 @@ +--- +"@resourcexjs/core": minor +"@resourcexjs/loader": minor +"@resourcexjs/type": minor +"@resourcexjs/registry": minor +"resourcexjs": minor +--- + +feat: RXC archive format - multi-file resource support + +**Breaking Changes:** + +- `createRXC` now accepts a files record instead of string/Buffer/Stream +- `createRXC` is now async (returns `Promise`) +- Removed `loadRXC` function (use `loadResource` instead) +- Removed `rxc.text()` and `rxc.json()` methods + +**New API:** + +```typescript +// Create from files +await createRXC({ content: "Hello" }); // single file +await createRXC({ "a.ts": "...", "b.css": "..." }); // multi-file +await createRXC({ archive: tarGzBuffer }); // from archive + +// Read files +await rxc.file("content"); // single file → Buffer +await rxc.files(); // all files → Map +await rxc.buffer(); // raw tar.gz → Buffer +``` + +**FolderLoader improvements:** + +- No longer requires `content` file name +- Supports any file names and nested directories +- All files (except `resource.json`) are packaged into RXC + +**Internal:** + +- RXC now stores content as tar.gz archive internally +- Uses `modern-tar` for tar packaging diff --git a/CLAUDE.md b/CLAUDE.md index 47db1e9..c1154c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,13 +81,19 @@ createRXM({ domain, path?, name, type, version }) // → { domain, path, name, type, version, toLocator(), toJSON() } ``` -**RXC (Content)** - Stream-based content (consumed once) +**RXC (Content)** - Archive-based content (tar.gz internally) ```typescript -createRXC(data: string | Buffer | ReadableStream) -loadRXC(source: string) // file path or URL - -// Methods: text(), buffer(), json(), stream +// Create from files +await createRXC({ content: "Hello" }) // single file +await createRXC({ 'index.ts': '...', 'styles.css': '...' }) // multi-file +await createRXC({ archive: tarGzBuffer }) // from existing archive + +// Methods +rxc.file(path): Promise // read single file +rxc.files(): Promise> // read all files +rxc.buffer(): Promise // raw tar.gz buffer +rxc.stream: ReadableStream // tar.gz stream ``` **RXR (Resource)** - Complete resource (pure DTO) @@ -163,7 +169,7 @@ interface Registry { └── {path}/ └── {name}.{type}@{version}/ ├── manifest.json # RXM as JSON - └── content # RXC serialized by type's serializer + └── content # RXC as tar.gz archive ``` ### Resolution Flow @@ -249,14 +255,22 @@ class ARPRegistry implements Registry { This allows swapping storage implementations without changing serialization logic. -### Stream-based Content +### Archive-based Content -RXC can only be consumed once (like fetch Response): +RXC stores content as tar.gz archive internally, supporting single or multi-file resources: ```typescript -const content = createRXC("Hello"); -await content.text(); // ✅ "Hello" -await content.text(); // ❌ Throws ContentError: already consumed +// Single file resource +const content = await createRXC({ content: "Hello" }); +const buffer = await content.file("content"); // ✅ Buffer +buffer.toString(); // "Hello" + +// Multi-file resource +const content = await createRXC({ + "src/index.ts": "main code", + "src/utils.ts": "helper code", +}); +const files = await content.files(); // Map ``` ### Type Aliases @@ -286,8 +300,8 @@ parseRXL(locator: string): RXL createRXM(data: ManifestData): RXM // Content -createRXC(data: string | Buffer | ReadableStream): RXC -loadRXC(source: string): Promise +createRXC(files: Record): Promise +createRXC({ archive: Buffer }): Promise // ResourceType defineResourceType(config: ResourceType): ResourceType diff --git a/README.md b/README.md index f5a098d..b2fdf1b 100644 --- a/README.md +++ b/README.md @@ -91,14 +91,15 @@ const manifest = createRXM({ const rxr = { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC("You are a helpful assistant."), + content: await createRXC({ content: "You are a helpful assistant." }), }; await registry.link(rxr); // Resolve the resource const resource = await registry.resolve("localhost/my-prompt.text@1.0.0"); -console.log(await resource.content.text()); // "You are a helpful assistant." +const contentBuffer = await resource.content.file("content"); +console.log(contentBuffer.toString()); // "You are a helpful assistant." // Check existence const exists = await registry.exists("localhost/my-prompt.text@1.0.0"); @@ -117,7 +118,7 @@ import { loadResource, createRegistry } from "resourcexjs"; // Create a resource folder: // my-prompt/ // ├── resource.json # Resource metadata -// └── content # Resource content +// └── content # Resource content (or any file names) // resource.json format: // { @@ -201,24 +202,30 @@ console.log(manifest.toJSON()); // Plain object ### RXC - Resource Content -Stream-based content that can only be consumed once (like fetch Response): +Archive-based content (internally tar.gz), supports single or multi-file resources: ```typescript -import { createRXC, loadRXC } from "resourcexjs"; +import { createRXC } from "resourcexjs"; -// From string/Buffer/Stream -const content = createRXC("Hello, World!"); -console.log(await content.text()); // "Hello, World!" +// Single file +const content = await createRXC({ content: "Hello, World!" }); -// From file or URL -const content = await loadRXC("./file.txt"); -const content = await loadRXC("https://example.com/data.txt"); +// Multiple files +const content = await createRXC({ + "index.ts": "export default 1", + "styles.css": "body {}", +}); + +// Nested directories +const content = await createRXC({ + "src/index.ts": "main code", + "src/utils/helper.ts": "helper code", +}); -// Available methods -await content.text(); // → string -await content.buffer(); // → Buffer -await content.json(); // → T -content.stream; // → ReadableStream +// Read files +const buffer = await content.file("content"); // single file +const files = await content.files(); // Map +const archiveBuffer = await content.buffer(); // raw tar.gz ``` ### RXR - Resource @@ -236,7 +243,7 @@ interface RXR { const rxr: RXR = { locator: parseRXL("localhost/test.text@1.0.0"), manifest: createRXM({ domain: "localhost", name: "test", type: "text", version: "1.0.0" }), - content: createRXC("content"), + content: await createRXC({ content: "Hello" }), }; ``` @@ -288,24 +295,24 @@ defineResourceType({ description: "AI Prompt template", serializer: { async serialize(rxr) { - // Convert RXR to Buffer - return Buffer.from(JSON.stringify({ template: await rxr.content.text() })); + // Convert RXR to Buffer (returns tar.gz archive) + return rxr.content.buffer(); }, async deserialize(data, manifest) { - // Convert Buffer to RXR - const obj = JSON.parse(data.toString()); + // Convert Buffer to RXR (data is tar.gz archive) return { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC(obj.template), + content: await createRXC({ archive: data }), }; }, }, resolver: { async resolve(rxr) { // Convert RXR to usable object + const buffer = await rxr.content.file("content"); return { - template: await rxr.content.text(), + template: buffer.toString(), compile: (vars) => { /* ... */ }, @@ -377,7 +384,7 @@ Resources are stored in: │ └── {path}/ │ └── {name}.{type}@{version}/ │ ├── manifest.json # RXM serialized -│ └── content # RXC serialized (via type's serializer) +│ └── content # RXC as tar.gz archive ``` Example: diff --git a/bdd/custom-registry/localhost/test.text@1.0.0/content b/bdd/custom-registry/localhost/test.text@1.0.0/content index 08cf6101416f0ce0dda3c80e627f333854c4085c..78274fa1510534fb3e89c6e54d033e1b16f058ad 100644 GIT binary patch literal 101 zcmb2|=3oE=;kTCz`5F{>STD%6D*w5~_1v&URCvXAb1C(54(I!yChPs@KbyWasMn3D zVZ|@ChI*zoN36R$@;c7jdM0s6?0QvPYAu!Mb-ig#33ufoVT | null; + rawBuffer: Buffer | null; + error: Error | null; +} + +Given("I have access to resourcexjs content", async function (this: ContentWorld) { + const { createRXC } = await import("resourcexjs"); + assert.ok(createRXC, "createRXC should be defined"); +}); + +// ============================================ +// Create - Single file +// ============================================ + +When( + "I create content with file {string} containing {string}", + async function (this: ContentWorld, path: string, content: string) { + const { createRXC } = await import("resourcexjs"); + this.rxc = await createRXC({ [path]: Buffer.from(content) }); + } +); + +// ============================================ +// Create - Multiple files +// ============================================ + +When("I create content with files:", async function (this: ContentWorld, dataTable: DataTable) { + const { createRXC } = await import("resourcexjs"); + const files: Record = {}; + + for (const row of dataTable.hashes()) { + files[row.path] = Buffer.from(row.content); + } + + this.rxc = await createRXC(files); +}); + +// ============================================ +// Given - Pre-created content +// ============================================ + +Given( + "content with file {string} containing {string}", + async function (this: ContentWorld, path: string, content: string) { + const { createRXC } = await import("resourcexjs"); + this.rxc = await createRXC({ [path]: Buffer.from(content) }); + } +); + +Given("content with files:", async function (this: ContentWorld, dataTable: DataTable) { + const { createRXC } = await import("resourcexjs"); + const files: Record = {}; + + for (const row of dataTable.hashes()) { + files[row.path] = Buffer.from(row.content); + } + + this.rxc = await createRXC(files); +}); + +// ============================================ +// Read - file() and files() +// ============================================ + +When("I read file {string}", async function (this: ContentWorld, path: string) { + assert.ok(this.rxc, "RXC should be defined"); + try { + this.fileBuffer = await this.rxc.file(path); + } catch (e) { + this.error = e as Error; + } +}); + +When("I read all files", async function (this: ContentWorld) { + assert.ok(this.rxc, "RXC should be defined"); + this.filesMap = await this.rxc.files(); +}); + +When("I get the raw buffer", async function (this: ContentWorld) { + assert.ok(this.rxc, "RXC should be defined"); + this.rawBuffer = await this.rxc.buffer(); +}); + +// ============================================ +// Then - Assertions +// ============================================ + +Then("content should have {int} file(s)", async function (this: ContentWorld, count: number) { + assert.ok(this.rxc, "RXC should be defined"); + const files = await this.rxc.files(); + assert.equal(files.size, count, `Expected ${count} files but got ${files.size}`); +}); + +Then( + "rxc file {string} should contain {string}", + async function (this: ContentWorld, path: string, expected: string) { + assert.ok(this.rxc, "RXC should be defined"); + const buffer = await this.rxc.file(path); + assert.equal(buffer.toString(), expected); + } +); + +Then("I should get buffer containing {string}", function (this: ContentWorld, expected: string) { + assert.ok(this.fileBuffer, "File buffer should be defined"); + assert.equal(this.fileBuffer.toString(), expected); +}); + +Then("I should get a map with {int} entries", function (this: ContentWorld, count: number) { + assert.ok(this.filesMap, "Files map should be defined"); + assert.equal(this.filesMap.size, count); +}); + +Then( + "map should contain {string} with {string}", + function (this: ContentWorld, path: string, expected: string) { + assert.ok(this.filesMap, "Files map should be defined"); + const buffer = this.filesMap.get(path); + assert.ok(buffer, `File "${path}" should exist in map`); + assert.equal(buffer.toString(), expected); + } +); + +Then( + "it should throw ContentError with message {string}", + async function (this: ContentWorld, expectedMessage: string) { + const { ContentError } = await import("resourcexjs"); + assert.ok(this.error, "Error should have been thrown"); + assert.ok( + this.error instanceof ContentError, + `Expected ContentError but got ${this.error.name}` + ); + assert.ok( + this.error.message.includes(expectedMessage), + `Expected message to include "${expectedMessage}" but got "${this.error.message}"` + ); + } +); + +Then("buffer should be valid tar.gz format", function (this: ContentWorld) { + assert.ok(this.rawBuffer, "Raw buffer should be defined"); + // tar.gz magic bytes: 1f 8b (gzip header) + assert.equal(this.rawBuffer[0], 0x1f, "First byte should be 0x1f (gzip)"); + assert.equal(this.rawBuffer[1], 0x8b, "Second byte should be 0x8b (gzip)"); +}); diff --git a/bun.lock b/bun.lock index ee4fbac..a147122 100644 --- a/bun.lock +++ b/bun.lock @@ -36,25 +36,26 @@ }, "packages/arp": { "name": "@resourcexjs/arp", - "version": "0.9.0", + "version": "1.0.0", }, "packages/core": { "name": "@resourcexjs/core", - "version": "0.9.0", + "version": "1.0.0", "dependencies": { "@resourcexjs/arp": "workspace:*", + "modern-tar": "^0.7.3", }, }, "packages/loader": { "name": "@resourcexjs/loader", - "version": "0.9.0", + "version": "1.0.0", "dependencies": { "@resourcexjs/core": "workspace:*", }, }, "packages/registry": { "name": "@resourcexjs/registry", - "version": "0.9.0", + "version": "1.0.0", "dependencies": { "@resourcexjs/arp": "workspace:*", "@resourcexjs/core": "workspace:*", @@ -63,7 +64,7 @@ }, "packages/resourcex": { "name": "resourcexjs", - "version": "0.9.0", + "version": "1.0.0", "dependencies": { "@resourcexjs/arp": "workspace:*", "@resourcexjs/core": "workspace:*", @@ -74,7 +75,7 @@ }, "packages/type": { "name": "@resourcexjs/type", - "version": "0.9.0", + "version": "1.0.0", "dependencies": { "@resourcexjs/core": "workspace:*", }, @@ -719,6 +720,8 @@ "mkdirp": ["mkdirp@2.1.6", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A=="], + "modern-tar": ["modern-tar@0.7.3", "", {}, "sha512-4W79zekKGyYU4JXVmB78DOscMFaJth2gGhgfTl2alWE4rNe3nf4N2pqenQ0rEtIewrnD79M687Ouba3YGTLOvg=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], diff --git a/packages/core/README.md b/packages/core/README.md index 2014a3f..543095e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -75,33 +75,35 @@ manifest.toJSON(); // → plain object ### RXC - Resource Content -Stream-based content (can only be consumed once, like fetch Response): +Archive-based content (internally tar.gz), supports single or multi-file resources: ```typescript -import { createRXC, loadRXC } from "@resourcexjs/core"; - -// Create from memory -const content = createRXC("Hello, World!"); -const content = createRXC(Buffer.from([1, 2, 3])); -const content = createRXC(readableStream); - -// Load from file or URL (async) -const content = await loadRXC("./file.txt"); -const content = await loadRXC("https://example.com/data.txt"); - -// Consume (choose one, can only use once) -const text = await content.text(); // → string -const buffer = await content.buffer(); // → Buffer -const json = await content.json(); // → T -const stream = content.stream; // → ReadableStream -``` +import { createRXC } from "@resourcexjs/core"; -**Important**: Content can only be consumed once! +// Single file +const content = await createRXC({ content: "Hello, World!" }); -```typescript -const content = createRXC("Hello"); -await content.text(); // ✅ "Hello" -await content.text(); // ❌ ContentError: already consumed +// Multiple files +const content = await createRXC({ + "index.ts": "export default 1", + "styles.css": "body {}", +}); + +// Nested directories +const content = await createRXC({ + "src/index.ts": "main code", + "src/utils/helper.ts": "helper code", +}); + +// From existing tar.gz archive (for deserialization) +const content = await createRXC({ archive: tarGzBuffer }); + +// Read files +const buffer = await content.file("content"); // → Buffer +const buffer = await content.file("src/index.ts"); // → Buffer +const files = await content.files(); // → Map +const archiveBuffer = await content.buffer(); // → raw tar.gz Buffer +const stream = content.stream; // → ReadableStream (tar.gz) ``` ### RXR - Resource @@ -182,8 +184,8 @@ const manifest = createRXM({ // Create locator from manifest const locator = parseRXL(manifest.toLocator()); -// Create content -const content = createRXC("You are a helpful assistant."); +// Create content (single file) +const content = await createRXC({ content: "You are a helpful assistant." }); // Assemble RXR const rxr: RXR = { @@ -193,18 +195,26 @@ const rxr: RXR = { }; ``` -### Load Content from File +### Multi-file Resource ```typescript -import { loadRXC } from "@resourcexjs/core"; +import { createRXC } from "@resourcexjs/core"; -// Load from local file -const content = await loadRXC("./prompt.txt"); -const text = await content.text(); +// Create multi-file content +const content = await createRXC({ + "prompt.md": "# System Prompt\nYou are...", + "config.json": '{"temperature": 0.7}', +}); -// Load from URL -const remoteContent = await loadRXC("https://example.com/prompt.txt"); -const remoteText = await remoteContent.text(); +// Read individual files +const promptBuffer = await content.file("prompt.md"); +const configBuffer = await content.file("config.json"); + +// Read all files +const allFiles = await content.files(); +for (const [path, buffer] of allFiles) { + console.log(path, buffer.toString()); +} ``` ### Manifest Serialization diff --git a/packages/core/package.json b/packages/core/package.json index b3fca33..ffad438 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -37,7 +37,8 @@ "clean": "rm -rf dist" }, "dependencies": { - "@resourcexjs/arp": "workspace:*" + "@resourcexjs/arp": "workspace:*", + "modern-tar": "^0.7.3" }, "devDependencies": {}, "publishConfig": { diff --git a/packages/core/src/content/createRXC.ts b/packages/core/src/content/createRXC.ts index bce95f0..c7d9f36 100644 --- a/packages/core/src/content/createRXC.ts +++ b/packages/core/src/content/createRXC.ts @@ -1,76 +1,122 @@ -import type { RXC } from "./types.js"; +import { gzip, gunzip } from "node:zlib"; +import { promisify } from "node:util"; +import { packTar, unpackTar } from "modern-tar"; +import type { RXC, RXCInput } from "./types.js"; import { ContentError } from "~/errors.js"; +const gzipAsync = promisify(gzip); +const gunzipAsync = promisify(gunzip); + class RXCImpl implements RXC { - private _stream: ReadableStream; - private _consumed = false; + private _buffer: Buffer; + private _filesCache: Map | null = null; - constructor(stream: ReadableStream) { - this._stream = stream; + constructor(buffer: Buffer) { + this._buffer = buffer; } get stream(): ReadableStream { - if (this._consumed) { - throw new ContentError("Content has already been consumed"); - } - this._consumed = true; - return this._stream; + const buffer = this._buffer; + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buffer)); + controller.close(); + }, + }); } - async text(): Promise { - const buffer = await this.buffer(); - return buffer.toString("utf-8"); + async buffer(): Promise { + return this._buffer; } - async buffer(): Promise { - if (this._consumed) { - throw new ContentError("Content has already been consumed"); + async file(path: string): Promise { + const filesMap = await this.files(); + const content = filesMap.get(path); + if (!content) { + throw new ContentError(`file not found: ${path}`); } - this._consumed = true; - - const reader = this._stream.getReader(); - const chunks: Uint8Array[] = []; + return content; + } - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); + async files(): Promise> { + if (this._filesCache) { + return this._filesCache; } - return Buffer.concat(chunks); - } + // Decompress gzip + const tarBuffer = await gunzipAsync(this._buffer); + + // Unpack tar + const entries = await unpackTar(tarBuffer); - async json(): Promise { - const text = await this.text(); - return JSON.parse(text) as T; + const filesMap = new Map(); + for (const entry of entries) { + if ((entry.header.type === "file" || entry.header.type === undefined) && entry.data) { + filesMap.set(entry.header.name, Buffer.from(entry.data)); + } + } + + this._filesCache = filesMap; + return filesMap; } } /** - * Create RXC from string, Buffer, or ReadableStream. + * Create RXC from a record of file paths to their content. + * + * @example + * ```typescript + * // Single file + * const content = await createRXC({ 'content': Buffer.from('Hello') }); + * + * // Multiple files + * const content = await createRXC({ + * 'index.ts': Buffer.from('export default 1'), + * 'styles.css': Buffer.from('body {}'), + * }); + * + * // Nested directories + * const content = await createRXC({ + * 'src/index.ts': Buffer.from('main'), + * 'src/utils/helper.ts': Buffer.from('helper'), + * }); + * ``` + */ +/** + * Check if input is an archive input */ -export function createRXC(data: string | Buffer | ReadableStream): RXC { - let stream: ReadableStream; +function isArchiveInput(input: RXCInput): input is { archive: Buffer } { + return "archive" in input && Buffer.isBuffer(input.archive); +} - if (typeof data === "string") { - const encoded = new TextEncoder().encode(data); - stream = new ReadableStream({ - start(controller) { - controller.enqueue(encoded); - controller.close(); - }, - }); - } else if (Buffer.isBuffer(data)) { - stream = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(data)); - controller.close(); - }, - }); - } else { - // Already a ReadableStream - stream = data; +export async function createRXC(input: RXCInput): Promise { + // If archive buffer provided, use it directly + if (isArchiveInput(input)) { + return new RXCImpl(input.archive); } - return new RXCImpl(stream); + // Otherwise, pack files into tar.gz + const entries = Object.entries(input).map(([name, content]) => { + const body = + typeof content === "string" + ? content + : content instanceof Uint8Array + ? content + : new Uint8Array(content); + + const size = typeof content === "string" ? Buffer.byteLength(content) : content.length; + + return { + header: { name, size, type: "file" as const }, + body, + }; + }); + + // Pack to tar + const tarBuffer = await packTar(entries); + + // Compress with gzip + const gzipBuffer = await gzipAsync(Buffer.from(tarBuffer)); + + return new RXCImpl(gzipBuffer); } diff --git a/packages/core/src/content/index.ts b/packages/core/src/content/index.ts index e301740..c9050fc 100644 --- a/packages/core/src/content/index.ts +++ b/packages/core/src/content/index.ts @@ -1,3 +1,2 @@ -export type { RXC } from "./types.js"; +export type { RXC, RXCInput } from "./types.js"; export { createRXC } from "./createRXC.js"; -export { loadRXC } from "./loadRXC.js"; diff --git a/packages/core/src/content/loadRXC.ts b/packages/core/src/content/loadRXC.ts deleted file mode 100644 index 1b50630..0000000 --- a/packages/core/src/content/loadRXC.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createReadStream } from "node:fs"; -import { Readable } from "node:stream"; -import type { RXC } from "./types.js"; -import { createRXC } from "./createRXC.js"; - -/** - * Load RXC from file path or URL. - */ -export async function loadRXC(source: string): Promise { - // Check if it's a URL - if (source.startsWith("http://") || source.startsWith("https://")) { - const response = await fetch(source); - if (!response.ok) { - throw new Error(`Failed to fetch ${source}: ${response.statusText}`); - } - if (!response.body) { - throw new Error(`No body in response from ${source}`); - } - return createRXC(response.body); - } - - // Otherwise, treat as file path - const nodeStream = createReadStream(source); - const webStream = Readable.toWeb(nodeStream) as ReadableStream; - return createRXC(webStream); -} diff --git a/packages/core/src/content/types.ts b/packages/core/src/content/types.ts index 4b6964b..1f69ca2 100644 --- a/packages/core/src/content/types.ts +++ b/packages/core/src/content/types.ts @@ -1,18 +1,26 @@ /** * RXC - ResourceX Content * - * Represents resource content as a stream. + * Archive-based content container using tar.gz format internally. + * Provides unified file access API for both single and multi-file resources. */ export interface RXC { - /** Content as a readable stream */ + /** Content as a readable stream (tar.gz format) */ readonly stream: ReadableStream; - /** Read content as text */ - text(): Promise; - - /** Read content as Buffer */ + /** Get raw archive buffer (tar.gz format) */ buffer(): Promise; - /** Read content as JSON */ - json(): Promise; + /** Read a specific file from the archive */ + file(path: string): Promise; + + /** Read all files from the archive */ + files(): Promise>; } + +/** + * Input type for createRXC. + * - Files record: { 'path/to/file': content } + * - Archive: { archive: tarGzBuffer } + */ +export type RXCInput = Record | { archive: Buffer }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8438d73..28af486 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,8 +16,8 @@ export type { RXM, ManifestData } from "~/manifest/index.js"; export { createRXM } from "~/manifest/index.js"; // Content (RXC) -export type { RXC } from "~/content/index.js"; -export { createRXC, loadRXC } from "~/content/index.js"; +export type { RXC, RXCInput } from "~/content/index.js"; +export { createRXC } from "~/content/index.js"; // Resource (RXR) export type { RXR } from "~/resource/index.js"; diff --git a/packages/core/tests/unit/content/createRXC.test.ts b/packages/core/tests/unit/content/createRXC.test.ts index a58bf47..39bd5ca 100644 --- a/packages/core/tests/unit/content/createRXC.test.ts +++ b/packages/core/tests/unit/content/createRXC.test.ts @@ -2,110 +2,120 @@ import { describe, it, expect } from "bun:test"; import { createRXC } from "../../../src/content/index.js"; describe("createRXC", () => { - describe("from string", () => { - it("creates RXC from string", () => { - const rxc = createRXC("Hello, World!"); + describe("from files record", () => { + it("creates RXC from single file", async () => { + const rxc = await createRXC({ content: "Hello, World!" }); expect(rxc).toBeDefined(); }); - it("returns text content", async () => { - const rxc = createRXC("Hello, World!"); - const text = await rxc.text(); - expect(text).toBe("Hello, World!"); - }); - - it("returns buffer content", async () => { - const rxc = createRXC("Hello, World!"); - const buffer = await rxc.buffer(); - expect(Buffer.isBuffer(buffer)).toBe(true); + it("returns file content", async () => { + const rxc = await createRXC({ content: "Hello, World!" }); + const buffer = await rxc.file("content"); expect(buffer.toString()).toBe("Hello, World!"); }); - it("returns json content", async () => { - const rxc = createRXC('{"name": "test"}'); - const json = await rxc.json<{ name: string }>(); - expect(json).toEqual({ name: "test" }); + it("returns all files", async () => { + const rxc = await createRXC({ + "index.ts": "export default 1", + "styles.css": "body {}", + }); + const files = await rxc.files(); + expect(files.size).toBe(2); + expect(files.get("index.ts")?.toString()).toBe("export default 1"); + expect(files.get("styles.css")?.toString()).toBe("body {}"); }); - it("has stream property", () => { - const rxc = createRXC("Hello, World!"); - expect(rxc.stream).toBeDefined(); + it("supports nested paths", async () => { + const rxc = await createRXC({ + "src/index.ts": "main", + "src/utils/helper.ts": "helper", + }); + const files = await rxc.files(); + expect(files.size).toBe(2); + expect(files.get("src/index.ts")?.toString()).toBe("main"); + expect(files.get("src/utils/helper.ts")?.toString()).toBe("helper"); }); - it("can read from stream", async () => { - const rxc = createRXC("Hello, World!"); - const reader = rxc.stream.getReader(); - const chunks: Uint8Array[] = []; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } + it("supports Buffer values", async () => { + const data = Buffer.from([0x01, 0x02, 0x03]); + const rxc = await createRXC({ content: data }); + const buffer = await rxc.file("content"); + expect(buffer).toEqual(data); + }); - const result = Buffer.concat(chunks).toString(); - expect(result).toBe("Hello, World!"); + it("supports Uint8Array values", async () => { + const data = new Uint8Array([0x01, 0x02, 0x03]); + const rxc = await createRXC({ content: data }); + const buffer = await rxc.file("content"); + expect(buffer).toEqual(Buffer.from(data)); }); }); - describe("from Buffer", () => { - it("creates RXC from Buffer", () => { - const buffer = Buffer.from("Hello, Buffer!"); - const rxc = createRXC(buffer); - expect(rxc).toBeDefined(); - }); + describe("from archive buffer", () => { + it("creates RXC from existing archive", async () => { + // First create an archive + const original = await createRXC({ content: "Hello" }); + const archiveBuffer = await original.buffer(); - it("returns text content", async () => { - const buffer = Buffer.from("Hello, Buffer!"); - const rxc = createRXC(buffer); - const text = await rxc.text(); - expect(text).toBe("Hello, Buffer!"); + // Then restore from archive + const restored = await createRXC({ archive: archiveBuffer }); + const buffer = await restored.file("content"); + expect(buffer.toString()).toBe("Hello"); }); - it("returns buffer content", async () => { - const buffer = Buffer.from([0x01, 0x02, 0x03]); - const rxc = createRXC(buffer); - const result = await rxc.buffer(); - expect(result).toEqual(buffer); + it("preserves all files when restoring from archive", async () => { + const original = await createRXC({ + "a.txt": "aaa", + "b.txt": "bbb", + }); + const archiveBuffer = await original.buffer(); + + const restored = await createRXC({ archive: archiveBuffer }); + const files = await restored.files(); + expect(files.size).toBe(2); + expect(files.get("a.txt")?.toString()).toBe("aaa"); + expect(files.get("b.txt")?.toString()).toBe("bbb"); }); }); - describe("from ReadableStream", () => { - it("creates RXC from ReadableStream", () => { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("Hello, Stream!")); - controller.close(); - }, - }); - const rxc = createRXC(stream); - expect(rxc).toBeDefined(); + describe("buffer()", () => { + it("returns tar.gz buffer", async () => { + const rxc = await createRXC({ content: "test" }); + const buffer = await rxc.buffer(); + // Check gzip magic bytes + expect(buffer[0]).toBe(0x1f); + expect(buffer[1]).toBe(0x8b); }); + }); - it("returns text content", async () => { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("Hello, Stream!")); - controller.close(); - }, - }); - const rxc = createRXC(stream); - const text = await rxc.text(); - expect(text).toBe("Hello, Stream!"); + describe("stream", () => { + it("has stream property", async () => { + const rxc = await createRXC({ content: "Hello" }); + expect(rxc.stream).toBeDefined(); }); - }); - describe("content can only be consumed once", () => { - it("throws error on second text() call", async () => { - const rxc = createRXC("Hello"); - await rxc.text(); - await expect(rxc.text()).rejects.toThrow(); + it("can read from stream", async () => { + const rxc = await createRXC({ content: "Hello" }); + const reader = rxc.stream.getReader(); + const chunks: Uint8Array[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + + const result = Buffer.concat(chunks); + // Should be gzip format + expect(result[0]).toBe(0x1f); + expect(result[1]).toBe(0x8b); }); + }); - it("throws error if stream already consumed", async () => { - const rxc = createRXC("Hello"); - await rxc.text(); - expect(() => rxc.stream.getReader()).toThrow(); + describe("error handling", () => { + it("throws error for non-existent file", async () => { + const rxc = await createRXC({ content: "Hello" }); + await expect(rxc.file("not-exist")).rejects.toThrow("file not found"); }); }); }); diff --git a/packages/core/tests/unit/content/loadRXC.test.ts b/packages/core/tests/unit/content/loadRXC.test.ts deleted file mode 100644 index d600f81..0000000 --- a/packages/core/tests/unit/content/loadRXC.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { loadRXC } from "../../../src/content/index.js"; - -const TEST_DIR = join(process.cwd(), ".test-rxc-load"); - -describe("loadRXC", () => { - beforeEach(async () => { - await mkdir(TEST_DIR, { recursive: true }); - }); - - afterEach(async () => { - await rm(TEST_DIR, { recursive: true, force: true }); - }); - - describe("from file", () => { - it("loads text file", async () => { - const testFile = join(TEST_DIR, "test.txt"); - await writeFile(testFile, "Hello from file!"); - - const rxc = await loadRXC(testFile); - const text = await rxc.text(); - - expect(text).toBe("Hello from file!"); - }); - - it("loads binary file", async () => { - const testFile = join(TEST_DIR, "test.bin"); - const binaryData = Buffer.from([0x01, 0x02, 0x03, 0x04]); - await writeFile(testFile, binaryData); - - const rxc = await loadRXC(testFile); - const buffer = await rxc.buffer(); - - expect(buffer).toEqual(binaryData); - }); - - it("loads JSON file", async () => { - const testFile = join(TEST_DIR, "test.json"); - await writeFile(testFile, '{"key": "value"}'); - - const rxc = await loadRXC(testFile); - const json = await rxc.json<{ key: string }>(); - - expect(json).toEqual({ key: "value" }); - }); - - it("has stream property", async () => { - const testFile = join(TEST_DIR, "test.txt"); - await writeFile(testFile, "Hello!"); - - const rxc = await loadRXC(testFile); - - expect(rxc.stream).toBeDefined(); - }); - }); -}); diff --git a/packages/loader/src/FolderLoader.ts b/packages/loader/src/FolderLoader.ts index af45b4d..f59220d 100644 --- a/packages/loader/src/FolderLoader.ts +++ b/packages/loader/src/FolderLoader.ts @@ -1,5 +1,5 @@ -import { join } from "node:path"; -import { stat, readFile } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { stat, readFile, readdir } from "node:fs/promises"; import type { ResourceLoader } from "./types.js"; import type { RXR } from "@resourcexjs/core"; import { createRXM, createRXC, parseRXL, ResourceXError } from "@resourcexjs/core"; @@ -11,7 +11,7 @@ import { createRXM, createRXC, parseRXL, ResourceXError } from "@resourcexjs/cor * ``` * folder/ * ├── resource.json # Resource metadata (required) - * └── content # Resource content (required) + * └── ... # Any other files/directories (content) * ``` * * resource.json format: @@ -24,6 +24,8 @@ import { createRXM, createRXC, parseRXL, ResourceXError } from "@resourcexjs/cor * "path": "optional/path" // optional * } * ``` + * + * All files in the folder (except resource.json) will be packaged into the RXC. */ export class FolderLoader implements ResourceLoader { async canLoad(source: string): Promise { @@ -33,14 +35,11 @@ export class FolderLoader implements ResourceLoader { return false; } - // Check if required files exist + // Check if resource.json exists const manifestPath = join(source, "resource.json"); - const contentPath = join(source, "content"); - const manifestStats = await stat(manifestPath); - const contentStats = await stat(contentPath); - return manifestStats.isFile() && contentStats.isFile(); + return manifestStats.isFile(); } catch { return false; } @@ -59,7 +58,7 @@ export class FolderLoader implements ResourceLoader { } // 2. Parse JSON - let manifestData: any; + let manifestData: Record; try { manifestData = JSON.parse(manifestJson); } catch (error) { @@ -81,26 +80,22 @@ export class FolderLoader implements ResourceLoader { // 4. Create RXM with defaults const manifest = createRXM({ - domain: manifestData.domain ?? "localhost", - path: manifestData.path, - name: manifestData.name, - type: manifestData.type, - version: manifestData.version, + domain: (manifestData.domain as string) ?? "localhost", + path: manifestData.path as string | undefined, + name: manifestData.name as string, + type: manifestData.type as string, + version: manifestData.version as string, }); - // 5. Read content file - const contentPath = join(folderPath, "content"); - let contentBuffer: Buffer; - try { - contentBuffer = await readFile(contentPath); - } catch (error) { - throw new ResourceXError( - `Failed to read content file: ${error instanceof Error ? error.message : String(error)}` - ); + // 5. Read all files in folder (except resource.json) + const files = await this.readFolderFiles(folderPath); + + if (Object.keys(files).length === 0) { + throw new ResourceXError("No content files found in resource folder"); } // 6. Create RXC - const content = createRXC(contentBuffer); + const content = await createRXC(files); // 7. Assemble RXR const locator = parseRXL(manifest.toLocator()); @@ -111,4 +106,35 @@ export class FolderLoader implements ResourceLoader { content, }; } + + /** + * Recursively read all files in a folder, returning a map of relative paths to buffers. + */ + private async readFolderFiles( + folderPath: string, + basePath: string = folderPath + ): Promise> { + const files: Record = {}; + const entries = await readdir(folderPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(folderPath, entry.name); + const relativePath = relative(basePath, fullPath); + + // Skip resource.json + if (relativePath === "resource.json") { + continue; + } + + if (entry.isFile()) { + files[relativePath] = await readFile(fullPath); + } else if (entry.isDirectory()) { + // Recursively read subdirectory + const subFiles = await this.readFolderFiles(fullPath, basePath); + Object.assign(files, subFiles); + } + } + + return files; + } } diff --git a/packages/loader/tests/unit/loadResource.test.ts b/packages/loader/tests/unit/loadResource.test.ts index 54d40d4..9613963 100644 --- a/packages/loader/tests/unit/loadResource.test.ts +++ b/packages/loader/tests/unit/loadResource.test.ts @@ -41,7 +41,8 @@ describe("loadResource", () => { expect(rxr.manifest.type).toBe("text"); expect(rxr.manifest.version).toBe("1.0.0"); expect(rxr.locator.toString()).toBe("localhost/test-resource.text@1.0.0"); - expect(await rxr.content.text()).toBe("Hello, World!"); + const contentBuffer = await rxr.content.file("content"); + expect(contentBuffer.toString()).toBe("Hello, World!"); }); it("loads resource with custom domain", async () => { @@ -98,7 +99,7 @@ describe("loadResource", () => { await expect(loadResource(resourceDir)).rejects.toThrow("Cannot load resource from"); }); - it("throws error if content is missing", async () => { + it("throws error if no content files exist", async () => { const resourceDir = join(TEST_DIR, "no-content"); await mkdir(resourceDir, { recursive: true }); await writeFile( @@ -111,7 +112,7 @@ describe("loadResource", () => { ); await expect(loadResource(resourceDir)).rejects.toThrow(ResourceXError); - await expect(loadResource(resourceDir)).rejects.toThrow("Cannot load resource from"); + await expect(loadResource(resourceDir)).rejects.toThrow("No content files found"); }); it("throws error if resource.json has invalid JSON", async () => { @@ -184,6 +185,54 @@ describe("loadResource", () => { await expect(loadResource(filePath)).rejects.toThrow(ResourceXError); await expect(loadResource(filePath)).rejects.toThrow("Cannot load resource from"); }); + + it("loads resource with multiple files", async () => { + const resourceDir = join(TEST_DIR, "multi-file"); + await mkdir(resourceDir, { recursive: true }); + + await writeFile( + join(resourceDir, "resource.json"), + JSON.stringify({ + name: "multi", + type: "text", + version: "1.0.0", + }) + ); + + await writeFile(join(resourceDir, "index.ts"), "export default 1"); + await writeFile(join(resourceDir, "styles.css"), "body {}"); + + const rxr = await loadResource(resourceDir); + + const files = await rxr.content.files(); + expect(files.size).toBe(2); + expect(files.get("index.ts")?.toString()).toBe("export default 1"); + expect(files.get("styles.css")?.toString()).toBe("body {}"); + }); + + it("loads resource with nested directory", async () => { + const resourceDir = join(TEST_DIR, "nested"); + await mkdir(join(resourceDir, "src", "utils"), { recursive: true }); + + await writeFile( + join(resourceDir, "resource.json"), + JSON.stringify({ + name: "nested", + type: "text", + version: "1.0.0", + }) + ); + + await writeFile(join(resourceDir, "src", "index.ts"), "main"); + await writeFile(join(resourceDir, "src", "utils", "helper.ts"), "helper"); + + const rxr = await loadResource(resourceDir); + + const files = await rxr.content.files(); + expect(files.size).toBe(2); + expect(files.get("src/index.ts")?.toString()).toBe("main"); + expect(files.get("src/utils/helper.ts")?.toString()).toBe("helper"); + }); }); describe("with custom loader", () => { @@ -205,7 +254,7 @@ describe("loadResource", () => { return { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC("mocked content"), + content: await createRXC({ content: "mocked content" }), }; } } @@ -214,7 +263,8 @@ describe("loadResource", () => { expect(rxr.manifest.domain).toBe("mock.com"); expect(rxr.manifest.name).toBe("any-source"); - expect(await rxr.content.text()).toBe("mocked content"); + const contentBuffer = await rxr.content.file("content"); + expect(contentBuffer.toString()).toBe("mocked content"); }); it("throws error if custom loader cannot load source", async () => { @@ -248,7 +298,7 @@ describe("FolderLoader", () => { }); describe("canLoad", () => { - it("returns true for valid resource folder", async () => { + it("returns true for valid resource folder with resource.json", async () => { const resourceDir = join(TEST_DIR, "valid-resource"); await mkdir(resourceDir, { recursive: true }); await writeFile(join(resourceDir, "resource.json"), "{}"); @@ -258,6 +308,16 @@ describe("FolderLoader", () => { expect(await loader.canLoad(resourceDir)).toBe(true); }); + it("returns true even without content file (only needs resource.json)", async () => { + const resourceDir = join(TEST_DIR, "manifest-only"); + await mkdir(resourceDir, { recursive: true }); + await writeFile(join(resourceDir, "resource.json"), "{}"); + // Note: load() will fail later if no content files exist + + const loader = new FolderLoader(); + expect(await loader.canLoad(resourceDir)).toBe(true); + }); + it("returns false if not a directory", async () => { const filePath = join(TEST_DIR, "file.txt"); await writeFile(filePath, "content"); @@ -275,15 +335,6 @@ describe("FolderLoader", () => { expect(await loader.canLoad(resourceDir)).toBe(false); }); - it("returns false if content is missing", async () => { - const resourceDir = join(TEST_DIR, "no-content"); - await mkdir(resourceDir, { recursive: true }); - await writeFile(join(resourceDir, "resource.json"), "{}"); - - const loader = new FolderLoader(); - expect(await loader.canLoad(resourceDir)).toBe(false); - }); - it("returns false for non-existent path", async () => { const loader = new FolderLoader(); expect(await loader.canLoad("/non/existent/path")).toBe(false); @@ -310,8 +361,8 @@ describe("FolderLoader", () => { const loader = new FolderLoader(); const rxr = await loader.load(resourceDir); - const buffer = await rxr.content.buffer(); - expect(buffer).toEqual(binaryData); + const contentBuffer = await rxr.content.file("content"); + expect(contentBuffer).toEqual(binaryData); }); }); }); diff --git a/packages/registry/tests/unit/ARPRegistry.test.ts b/packages/registry/tests/unit/ARPRegistry.test.ts index 26b01ba..024fee2 100644 --- a/packages/registry/tests/unit/ARPRegistry.test.ts +++ b/packages/registry/tests/unit/ARPRegistry.test.ts @@ -7,7 +7,7 @@ import type { RXR } from "@resourcexjs/core"; const TEST_DIR = join(process.cwd(), ".test-registry"); -function createTestRXR(name: string, content: string): RXR { +async function createTestRXR(name: string, content: string): Promise { const manifest = createRXM({ domain: "localhost", name, @@ -18,7 +18,7 @@ function createTestRXR(name: string, content: string): RXR { return { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC(content), + content: await createRXC({ content }), }; } @@ -34,7 +34,7 @@ describe("ARPRegistry", () => { describe("link", () => { it("links a resource to local registry", async () => { const registry = createRegistry({ path: TEST_DIR }); - const rxr = createTestRXR("test-prompt", "Hello, {{name}}!"); + const rxr = await createTestRXR("test-prompt", "Hello, {{name}}!"); await registry.link(rxr); @@ -46,7 +46,7 @@ describe("ARPRegistry", () => { describe("resolve", () => { it("resolves a linked resource", async () => { const registry = createRegistry({ path: TEST_DIR }); - const rxr = createTestRXR("hello", "Hello World!"); + const rxr = await createTestRXR("hello", "Hello World!"); await registry.link(rxr); @@ -54,7 +54,8 @@ describe("ARPRegistry", () => { expect(resolved.manifest.name).toBe("hello"); expect(resolved.manifest.type).toBe("text"); - expect(await resolved.content.text()).toBe("Hello World!"); + const contentBuffer = await resolved.content.file("content"); + expect(contentBuffer.toString()).toBe("Hello World!"); }); it("throws error for non-existent resource", async () => { @@ -70,7 +71,7 @@ describe("ARPRegistry", () => { describe("exists", () => { it("returns true for existing resource", async () => { const registry = createRegistry({ path: TEST_DIR }); - const rxr = createTestRXR("exists-test", "content"); + const rxr = await createTestRXR("exists-test", "content"); await registry.link(rxr); @@ -89,7 +90,7 @@ describe("ARPRegistry", () => { describe("delete", () => { it("deletes a linked resource", async () => { const registry = createRegistry({ path: TEST_DIR }); - const rxr = createTestRXR("to-delete", "content"); + const rxr = await createTestRXR("to-delete", "content"); await registry.link(rxr); expect(await registry.exists("localhost/to-delete.text@1.0.0")).toBe(true); @@ -111,13 +112,14 @@ describe("ARPRegistry", () => { const rxr: RXR = { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC("Hello via alias!"), + content: await createRXC({ content: "Hello via alias!" }), }; await registry.link(rxr); const resolved = await registry.resolve("localhost/alias-test.txt@1.0.0"); - expect(await resolved.content.text()).toBe("Hello via alias!"); + const contentBuffer = await resolved.content.file("content"); + expect(contentBuffer.toString()).toBe("Hello via alias!"); }); it("supports config as alias for json", async () => { @@ -131,13 +133,14 @@ describe("ARPRegistry", () => { const rxr: RXR = { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC('{"key": "value"}'), + content: await createRXC({ content: '{"key": "value"}' }), }; await registry.link(rxr); const resolved = await registry.resolve("localhost/config-test.config@1.0.0"); - const json = await resolved.content.json<{ key: string }>(); + const contentBuffer = await resolved.content.file("content"); + const json = JSON.parse(contentBuffer.toString()); expect(json.key).toBe("value"); }); @@ -153,14 +156,14 @@ describe("ARPRegistry", () => { const rxr: RXR = { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC(binaryData), + content: await createRXC({ content: binaryData }), }; await registry.link(rxr); const resolved = await registry.resolve("localhost/binary-test.bin@1.0.0"); - const buffer = await resolved.content.buffer(); - expect(buffer).toEqual(binaryData); + const contentBuffer = await resolved.content.file("content"); + expect(contentBuffer).toEqual(binaryData); }); }); }); diff --git a/packages/resourcex/src/index.ts b/packages/resourcex/src/index.ts index b5f4a57..69552a9 100644 --- a/packages/resourcex/src/index.ts +++ b/packages/resourcex/src/index.ts @@ -43,8 +43,8 @@ export { createRXM } from "@resourcexjs/core"; // ============================================ // RXC - ResourceX Content // ============================================ -export type { RXC } from "@resourcexjs/core"; -export { createRXC, loadRXC } from "@resourcexjs/core"; +export type { RXC, RXCInput } from "@resourcexjs/core"; +export { createRXC } from "@resourcexjs/core"; // ============================================ // RXR - ResourceX Resource diff --git a/packages/type/src/builtinTypes.ts b/packages/type/src/builtinTypes.ts index fdcb6a6..40bd4c0 100644 --- a/packages/type/src/builtinTypes.ts +++ b/packages/type/src/builtinTypes.ts @@ -3,20 +3,18 @@ import type { RXR, RXM } from "@resourcexjs/core"; import { createRXC, parseRXL } from "@resourcexjs/core"; /** - * Text serializer - stores content as UTF-8 text + * Text serializer - stores RXC archive as-is */ const textSerializer: ResourceSerializer = { async serialize(rxr: RXR): Promise { - const text = await rxr.content.text(); - return Buffer.from(text, "utf-8"); + return rxr.content.buffer(); }, async deserialize(data: Buffer, manifest: RXM): Promise { - const text = data.toString("utf-8"); return { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC(text), + content: await createRXC({ archive: data }), }; }, }; @@ -26,7 +24,8 @@ const textSerializer: ResourceSerializer = { */ const textResolver: ResourceResolver = { async resolve(rxr: RXR): Promise { - return rxr.content.text(); + const buffer = await rxr.content.file("content"); + return buffer.toString("utf-8"); }, }; @@ -42,20 +41,18 @@ export const textType: ResourceType = { }; /** - * JSON serializer - stores content as JSON string + * JSON serializer - stores RXC archive as-is */ const jsonSerializer: ResourceSerializer = { async serialize(rxr: RXR): Promise { - const json = await rxr.content.json(); - return Buffer.from(JSON.stringify(json, null, 2), "utf-8"); + return rxr.content.buffer(); }, async deserialize(data: Buffer, manifest: RXM): Promise { - const text = data.toString("utf-8"); return { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC(text), + content: await createRXC({ archive: data }), }; }, }; @@ -65,7 +62,8 @@ const jsonSerializer: ResourceSerializer = { */ const jsonResolver: ResourceResolver = { async resolve(rxr: RXR): Promise { - return rxr.content.json(); + const buffer = await rxr.content.file("content"); + return JSON.parse(buffer.toString("utf-8")); }, }; @@ -81,7 +79,7 @@ export const jsonType: ResourceType = { }; /** - * Binary serializer - stores content as raw bytes + * Binary serializer - stores RXC archive as-is */ const binarySerializer: ResourceSerializer = { async serialize(rxr: RXR): Promise { @@ -92,7 +90,7 @@ const binarySerializer: ResourceSerializer = { return { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC(data), + content: await createRXC({ archive: data }), }; }, }; @@ -102,7 +100,7 @@ const binarySerializer: ResourceSerializer = { */ const binaryResolver: ResourceResolver = { async resolve(rxr: RXR): Promise { - return rxr.content.buffer(); + return rxr.content.file("content"); }, }; diff --git a/packages/type/tests/unit/TypeHandlerChain.test.ts b/packages/type/tests/unit/TypeHandlerChain.test.ts index 9debfa2..60cfb4f 100644 --- a/packages/type/tests/unit/TypeHandlerChain.test.ts +++ b/packages/type/tests/unit/TypeHandlerChain.test.ts @@ -115,12 +115,14 @@ describe("TypeHandlerChain (global singleton)", () => { const rxr: RXR = { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC("Hello, World!"), + content: await createRXC({ content: "Hello, World!" }), }; const buffer = await globalTypeHandlerChain.serialize(rxr); - expect(buffer.toString("utf-8")).toBe("Hello, World!"); + // Buffer is tar.gz format, check it's valid gzip + expect(buffer[0]).toBe(0x1f); + expect(buffer[1]).toBe(0x8b); }); it("serializes json resource", async () => { @@ -133,13 +135,14 @@ describe("TypeHandlerChain (global singleton)", () => { const rxr: RXR = { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC('{"key": "value"}'), + content: await createRXC({ content: '{"key": "value"}' }), }; const buffer = await globalTypeHandlerChain.serialize(rxr); - const json = JSON.parse(buffer.toString("utf-8")); - expect(json.key).toBe("value"); + // Buffer is tar.gz format + expect(buffer[0]).toBe(0x1f); + expect(buffer[1]).toBe(0x8b); }); it("throws error for unsupported type", async () => { @@ -152,7 +155,7 @@ describe("TypeHandlerChain (global singleton)", () => { const rxr: RXR = { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC("test"), + content: await createRXC({ content: "test" }), }; await expect(globalTypeHandlerChain.serialize(rxr)).rejects.toThrow(ResourceTypeError); @@ -170,11 +173,14 @@ describe("TypeHandlerChain (global singleton)", () => { type: "text", version: "1.0.0", }); - const data = Buffer.from("Hello, World!", "utf-8"); + // Create a proper tar.gz buffer + const originalRxc = await createRXC({ content: "Hello, World!" }); + const data = await originalRxc.buffer(); const rxr = await globalTypeHandlerChain.deserialize(data, manifest); - expect(await rxr.content.text()).toBe("Hello, World!"); + const contentBuffer = await rxr.content.file("content"); + expect(contentBuffer.toString()).toBe("Hello, World!"); }); it("deserializes using alias", async () => { @@ -184,11 +190,13 @@ describe("TypeHandlerChain (global singleton)", () => { type: "txt", // Using alias version: "1.0.0", }); - const data = Buffer.from("Via alias", "utf-8"); + const originalRxc = await createRXC({ content: "Via alias" }); + const data = await originalRxc.buffer(); const rxr = await globalTypeHandlerChain.deserialize(data, manifest); - expect(await rxr.content.text()).toBe("Via alias"); + const contentBuffer = await rxr.content.file("content"); + expect(contentBuffer.toString()).toBe("Via alias"); }); it("throws error for unsupported type", async () => { @@ -198,10 +206,12 @@ describe("TypeHandlerChain (global singleton)", () => { type: "unknown", version: "1.0.0", }); + const originalRxc = await createRXC({ content: "test" }); + const data = await originalRxc.buffer(); - await expect( - globalTypeHandlerChain.deserialize(Buffer.from("test"), manifest) - ).rejects.toThrow(ResourceTypeError); + await expect(globalTypeHandlerChain.deserialize(data, manifest)).rejects.toThrow( + ResourceTypeError + ); }); }); @@ -216,7 +226,7 @@ describe("TypeHandlerChain (global singleton)", () => { const rxr: RXR = { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC("Hello"), + content: await createRXC({ content: "Hello" }), }; const result = await globalTypeHandlerChain.resolve(rxr); @@ -234,7 +244,7 @@ describe("TypeHandlerChain (global singleton)", () => { const rxr: RXR = { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC('{"key": "value"}'), + content: await createRXC({ content: '{"key": "value"}' }), }; const result = await globalTypeHandlerChain.resolve<{ key: string }>(rxr); @@ -252,7 +262,7 @@ describe("TypeHandlerChain (global singleton)", () => { const rxr: RXR = { locator: parseRXL(manifest.toLocator()), manifest, - content: createRXC("test"), + content: await createRXC({ content: "test" }), }; await expect(globalTypeHandlerChain.resolve(rxr)).rejects.toThrow(ResourceTypeError);