Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/rxc-archive-format.md
Original file line number Diff line number Diff line change
@@ -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<RXC>`)
- 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<string, Buffer>
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
40 changes: 27 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Buffer> // read single file
rxc.files(): Promise<Map<string, Buffer>> // read all files
rxc.buffer(): Promise<Buffer> // raw tar.gz buffer
rxc.stream: ReadableStream // tar.gz stream
```

**RXR (Resource)** - Complete resource (pure DTO)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, Buffer>
```

### Type Aliases
Expand Down Expand Up @@ -286,8 +300,8 @@ parseRXL(locator: string): RXL
createRXM(data: ManifestData): RXM

// Content
createRXC(data: string | Buffer | ReadableStream): RXC
loadRXC(source: string): Promise<RXC>
createRXC(files: Record<string, Buffer | string>): Promise<RXC>
createRXC({ archive: Buffer }): Promise<RXC>

// ResourceType
defineResourceType<T>(config: ResourceType<T>): ResourceType<T>
Expand Down
55 changes: 31 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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:
// {
Expand Down Expand Up @@ -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>(); // → T
content.stream; // → ReadableStream<Uint8Array>
// Read files
const buffer = await content.file("content"); // single file
const files = await content.files(); // Map<string, Buffer>
const archiveBuffer = await content.buffer(); // raw tar.gz
```

### RXR - Resource
Expand All @@ -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" }),
};
```

Expand Down Expand Up @@ -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) => {
/* ... */
},
Expand Down Expand Up @@ -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:
Expand Down
Binary file modified bdd/custom-registry/localhost/test.text@1.0.0/content
Binary file not shown.
90 changes: 90 additions & 0 deletions bdd/features/resourcex/content.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
@resourcex @content
Feature: ResourceX Content (RXC)
RXC is an archive-based content container.
Internally uses tar.gz format, externally provides file access API.

Background:
Given I have access to resourcexjs content

# ============================================
# Create - Single file
# ============================================

@create @single
Scenario: Create content with single file
When I create content with file "content" containing "Hello World"
Then content should have 1 file
And rxc file "content" should contain "Hello World"

@create @single
Scenario: Create content with named file
When I create content with file "index.ts" containing "export const foo = 1;"
Then content should have 1 file
And rxc file "index.ts" should contain "export const foo = 1;"

# ============================================
# Create - Multiple files
# ============================================

@create @multi
Scenario: Create content with multiple files
When I create content with files:
| path | content |
| index.ts | export default |
| styles.css | body {} |
Then content should have 2 files
And rxc file "index.ts" should contain "export default"
And rxc file "styles.css" should contain "body {}"

@create @nested
Scenario: Create content with nested directory structure
When I create content with files:
| path | content |
| src/index.ts | main code |
| src/utils/helper.ts | helper |
| styles/main.css | css styles |
Then content should have 3 files
And rxc file "src/index.ts" should contain "main code"
And rxc file "src/utils/helper.ts" should contain "helper"
And rxc file "styles/main.css" should contain "css styles"

# ============================================
# Read - file() and files()
# ============================================

@read @file
Scenario: Read single file from content
Given content with file "data.json" containing '{"key": "value"}'
When I read file "data.json"
Then I should get buffer containing '{"key": "value"}'

@read @files
Scenario: Read all files from content
Given content with files:
| path | content |
| a.txt | aaa |
| b.txt | bbb |
When I read all files
Then I should get a map with 2 entries
And map should contain "a.txt" with "aaa"
And map should contain "b.txt" with "bbb"

# ============================================
# Error handling
# ============================================

@error
Scenario: Read non-existent file
Given content with file "exists.txt" containing "data"
When I read file "not-exists.txt"
Then it should throw ContentError with message "file not found"

# ============================================
# Archive format
# ============================================

@archive
Scenario: Content buffer is tar.gz format
Given content with file "test.txt" containing "test data"
When I get the raw buffer
Then buffer should be valid tar.gz format
16 changes: 8 additions & 8 deletions bdd/steps/registry/registry.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,15 @@ Given(
this.resource = {
locator: parseRXL(manifest.toLocator()),
manifest,
content: createRXC("default content"),
content: await createRXC({ content: "default content" }),
};
}
);

Given("resource content {string}", async function (this: RegistryWorld, content: string) {
const { createRXC } = await import("resourcexjs");
if (this.resource) {
this.resource.content = createRXC(content);
this.resource.content = await createRXC({ content });
}
});

Expand All @@ -90,7 +90,7 @@ Given(
const rxr: RXR = {
locator: rxl,
manifest,
content: createRXC(content),
content: await createRXC({ content }),
};

await this.registry!.link(rxr);
Expand All @@ -111,7 +111,7 @@ Given("a linked resource {string}", async function (this: RegistryWorld, locator
const rxr: RXR = {
locator: rxl,
manifest,
content: createRXC("test content"),
content: await createRXC({ content: "test content" }),
};

await this.registry!.link(rxr);
Expand All @@ -135,7 +135,7 @@ Given(
const rxr: RXR = {
locator: rxl,
manifest,
content: createRXC("test content"),
content: await createRXC({ content: "test content" }),
};

await this.registry!.link(rxr);
Expand Down Expand Up @@ -166,7 +166,7 @@ When("I link a resource {string}", async function (this: RegistryWorld, locator:
this.resource = {
locator: rxl,
manifest,
content: createRXC("test content"),
content: await createRXC({ content: "test content" }),
};

try {
Expand Down Expand Up @@ -251,8 +251,8 @@ Then("I should receive an RXR object", async function (this: RegistryWorld) {
});

Then("the content should be {string}", async function (this: RegistryWorld, expected: string) {
const content = await this.resolvedResource!.content.text();
assert.equal(content, expected);
const buffer = await this.resolvedResource!.content.file("content");
assert.equal(buffer.toString(), expected);
});

Then("it should throw a RegistryError", async function (this: RegistryWorld) {
Expand Down
Loading