teyyare 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  **Teyyare** is a high-performance, resumable file transfer engine and CLI built directly on top of:
4
4
 
5
- - [`muttafa`](https://github.com/ahmet/muttafa): low-level mutation buffering, generation freeze & lifecycle engine
6
- - [`raptiye`](https://github.com/ahmet/raptiye): high-performance byte-first replicated log & TCP binary transport
5
+ - [`muttafa`](https://github.com/litepacks/muttafa): low-level mutation buffering, generation freeze & lifecycle engine
6
+ - [`raptiye`](https://github.com/litepacks/raptiye): high-performance byte-first replicated log & TCP binary transport
7
7
 
8
8
  Teyyare is the first real integration workload for both projects, proving that a mutation lifecycle engine and a binary replication/transport engine compose into a fast, bounded-memory, resumable file transfer system without introducing redundant batching layers or memory copies.
9
9
 
package/bin/teyyare.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import path from 'node:path';
8
- import { serve, send } from '../src/index.js';
8
+ import { serve, send, pull } from '../src/index.js';
9
9
 
10
10
  function printUsage() {
11
11
  console.log(`
@@ -106,9 +106,35 @@ Protocol overhead:${snap.protocolOverheadPercent.toFixed(2)}%
106
106
  Peak RSS: ${(snap.peakRSS / (1024 * 1024)).toFixed(2)} MB
107
107
  `);
108
108
  } else if (command === 'pull') {
109
- console.log('Pull command: inverting peer roles to request remote transfer.');
110
- // In point-to-point architecture, pull is receiver-initiated transfer
111
- console.error('Pulling is handled via remote agent or server command.');
109
+ const source = args[1];
110
+ const target = args[2] || './';
111
+
112
+ if (!source) {
113
+ console.error('Error: teyyare pull requires <source> and [destination]');
114
+ printUsage();
115
+ process.exit(1);
116
+ }
117
+
118
+ const { host, port, destPath: remotePath } = parseDestination(source);
119
+ const metrics = await pull({
120
+ sourceRemotePath: remotePath,
121
+ host,
122
+ port,
123
+ destinationDir: target,
124
+ showProgress: true
125
+ });
126
+
127
+ const snap = metrics.snapshot();
128
+ console.log(`
129
+ \x1b[1m\x1b[32mPull completed successfully!\x1b[0m
130
+ Throughput: \x1b[1m${snap.throughputMBs.toFixed(2)} MB/s\x1b[0m
131
+ Duration: ${(snap.elapsedMs / 1000).toFixed(2)}s
132
+ Bytes: ${(snap.fileBytesWritten / (1024 * 1024)).toFixed(2)} MB
133
+ Generations: ${snap.generations}
134
+ Backpressure: ${snap.backpressureCount}
135
+ Protocol overhead:${snap.protocolOverheadPercent.toFixed(2)}%
136
+ Peak RSS: ${(snap.peakRSS / (1024 * 1024)).toFixed(2)} MB
137
+ `);
112
138
  } else if (command === 'status') {
113
139
  console.log('\x1b[1m\x1b[36mTeyyare\x1b[0m v0.1.0 status: ready');
114
140
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teyyare",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "High-performance resumable file transfer engine and CLI built on muttafa and raptiye",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -11,7 +11,9 @@ export const Opcodes = Object.freeze({
11
11
  FILE_BEGIN: 11,
12
12
  CHUNK: 12,
13
13
  FILE_END: 13,
14
- TRANSFER_END: 14
14
+ TRANSFER_END: 14,
15
+ PULL_REQUEST: 15,
16
+ PULL_ERROR: 16
15
17
  });
16
18
 
17
19
  export const CHUNK_META_SIZE = 25;
@@ -161,3 +163,44 @@ export function unpackTransferEnd(buf) {
161
163
  const transferId = decoder.decode(buf.subarray(3, 3 + idLen));
162
164
  return { transferId, status };
163
165
  }
166
+
167
+ /**
168
+ * Pack PULL_REQUEST metadata.
169
+ */
170
+ export function packPullRequest({ remotePath }) {
171
+ const pathBytes = encoder.encode(remotePath);
172
+ const buf = new Uint8Array(2 + pathBytes.byteLength);
173
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
174
+ view.setUint16(0, pathBytes.byteLength, false);
175
+ buf.set(pathBytes, 2);
176
+ return buf;
177
+ }
178
+
179
+ export function unpackPullRequest(buf) {
180
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
181
+ const pathLen = view.getUint16(0, false);
182
+ const remotePath = decoder.decode(buf.subarray(2, 2 + pathLen));
183
+ return { remotePath };
184
+ }
185
+
186
+ /**
187
+ * Pack PULL_ERROR metadata.
188
+ */
189
+ export function packPullError({ errorCode = 1, message = '' }) {
190
+ const msgBytes = encoder.encode(message);
191
+ const buf = new Uint8Array(6 + msgBytes.byteLength);
192
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
193
+ view.setUint32(0, errorCode, false);
194
+ view.setUint16(4, msgBytes.byteLength, false);
195
+ buf.set(msgBytes, 6);
196
+ return buf;
197
+ }
198
+
199
+ export function unpackPullError(buf) {
200
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
201
+ const errorCode = view.getUint32(0, false);
202
+ const msgLen = view.getUint16(4, false);
203
+ const message = decoder.decode(buf.subarray(6, 6 + msgLen));
204
+ return { errorCode, message };
205
+ }
206
+
package/src/index.js CHANGED
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  import path from 'node:path';
6
+ import { existsSync } from 'node:fs';
7
+ import { MutableBuffer, ArenaStore, BumpAllocator, FrozenArenaStore } from 'muttafa';
6
8
  import { TCPTransport, ReplicationPipeline } from 'raptiye';
7
9
  import { SenderPipeline } from './sender/pipeline.js';
8
10
  import { ReceiverPipeline } from './receiver/pipeline.js';
@@ -10,7 +12,15 @@ import { TransferManifest } from './manifest/manifest.js';
10
12
  import { FileResumeState } from './resume/state.js';
11
13
  import { MetricsCollector } from './metrics/collector.js';
12
14
  import { ProgressReporter } from './cli/progress.js';
13
- import { Opcodes, packChunkMeta, unpackChunkMeta } from './core/protocol.js';
15
+ import {
16
+ Opcodes,
17
+ packChunkMeta,
18
+ unpackChunkMeta,
19
+ packPullRequest,
20
+ unpackPullRequest,
21
+ packPullError,
22
+ unpackPullError
23
+ } from './core/protocol.js';
14
24
 
15
25
  export {
16
26
  SenderPipeline,
@@ -21,11 +31,35 @@ export {
21
31
  ProgressReporter,
22
32
  Opcodes,
23
33
  packChunkMeta,
24
- unpackChunkMeta
34
+ unpackChunkMeta,
35
+ packPullRequest,
36
+ unpackPullRequest,
37
+ packPullError,
38
+ unpackPullError
25
39
  };
26
40
 
41
+ async function submitSingleMutation(replication, opcode, key, value = new Uint8Array(0)) {
42
+ const store = new ArenaStore({ allocator: new BumpAllocator(16 * 1024) });
43
+ const mutable = new MutableBuffer(Date.now() & 0x7fffffff, store);
44
+ mutable.append(1, opcode, key, value);
45
+ const frozen = mutable.freeze();
46
+ frozen.markFlushing();
47
+ const receipt = await replication.submitBatch({
48
+ generationId: frozen.generation,
49
+ firstSequence: frozen.firstSequence,
50
+ lastSequence: frozen.lastSequence,
51
+ entryCount: frozen.entryCount,
52
+ byteLength: frozen.byteLength,
53
+ buffers: frozen.buffers()
54
+ });
55
+ await receipt.delivered();
56
+ frozen.markFlushed();
57
+ frozen.release();
58
+ }
59
+
27
60
  /**
28
- * Start a Teyyare receiver server.
61
+ * Start a Teyyare receiver/file server.
62
+ * Handles both incoming push transfers (send) and remote pull requests (pull).
29
63
  * @param {object} options
30
64
  * @param {number} [options.port=7421]
31
65
  * @param {string} [options.host='0.0.0.0']
@@ -61,7 +95,42 @@ export async function serve({
61
95
  replication,
62
96
  destinationDir,
63
97
  metrics,
64
- writeDelayMs
98
+ writeDelayMs,
99
+ autoHookBatch: false
100
+ });
101
+
102
+ replication.onBatch(async (msg) => {
103
+ if (!msg.buffers || msg.buffers.length < 2) return;
104
+ const [metaBuf, payloadBuf] = msg.buffers;
105
+ const store = FrozenArenaStore.fromBuffers(metaBuf, payloadBuf);
106
+ if (store.length > 0 && store.getType(0) === Opcodes.PULL_REQUEST) {
107
+ const { remotePath } = unpackPullRequest(store.getKey(0));
108
+ let resolved = path.isAbsolute(remotePath) ? remotePath : path.resolve(destinationDir, remotePath);
109
+ if (!existsSync(resolved)) {
110
+ const alt = path.resolve(destinationDir, remotePath);
111
+ if (existsSync(alt)) resolved = alt;
112
+ }
113
+
114
+ if (!existsSync(resolved)) {
115
+ await submitSingleMutation(
116
+ replication,
117
+ Opcodes.PULL_ERROR,
118
+ packPullError({ errorCode: 404, message: `Path not found on remote: ${remotePath}` })
119
+ );
120
+ return;
121
+ }
122
+
123
+ const manifest = await TransferManifest.build(resolved);
124
+ const sender = new SenderPipeline({
125
+ replication,
126
+ manifest,
127
+ metrics
128
+ });
129
+ await sender.transfer();
130
+ return;
131
+ }
132
+
133
+ await receiver._processGenerationBatch(msg);
65
134
  });
66
135
 
67
136
  return {
@@ -137,3 +206,71 @@ export async function send({
137
206
 
138
207
  return metrics;
139
208
  }
209
+
210
+ /**
211
+ * Pull a file or directory from a remote Teyyare server.
212
+ * @param {object} options
213
+ * @param {string} options.sourceRemotePath
214
+ * @param {string} [options.host='127.0.0.1']
215
+ * @param {number} [options.port=7421]
216
+ * @param {string} [options.destinationDir='./']
217
+ * @param {object} [options.pipelineOptions]
218
+ * @param {boolean} [options.showProgress=true]
219
+ * @returns {Promise<MetricsCollector>}
220
+ */
221
+ export async function pull({
222
+ sourceRemotePath,
223
+ host = '127.0.0.1',
224
+ port = 7421,
225
+ destinationDir = './',
226
+ pipelineOptions = {},
227
+ showProgress = true
228
+ }) {
229
+ const metrics = new MetricsCollector();
230
+ const progress = showProgress ? new ProgressReporter() : null;
231
+ if (progress) progress.start();
232
+
233
+ const clientPort = 10000 + Math.floor(Math.random() * 20000);
234
+ const transport = new TCPTransport({
235
+ id: 1,
236
+ port: clientPort,
237
+ host: '127.0.0.1',
238
+ peerAddresses: new Map([[2, { host, port }]])
239
+ });
240
+
241
+ await transport.start();
242
+
243
+ const replication = new ReplicationPipeline({
244
+ transport,
245
+ localNode: 1,
246
+ remoteNode: 2,
247
+ maxInflightBatches: pipelineOptions.maxInflightBatches || 16,
248
+ maxInflightBytes: pipelineOptions.maxInflightBytes || 64 * 1024 * 1024
249
+ });
250
+
251
+ const receiver = new ReceiverPipeline({
252
+ replication,
253
+ destinationDir,
254
+ metrics,
255
+ progress
256
+ });
257
+
258
+ try {
259
+ // Submit PULL_REQUEST batch
260
+ await submitSingleMutation(
261
+ replication,
262
+ Opcodes.PULL_REQUEST,
263
+ packPullRequest({ remotePath: sourceRemotePath })
264
+ );
265
+
266
+ // Wait for receiver pipeline to receive and write all files
267
+ await receiver.waitForCompletion();
268
+ } finally {
269
+ if (progress) progress.stop();
270
+ metrics.stop();
271
+ await transport.close();
272
+ }
273
+
274
+ return metrics;
275
+ }
276
+
@@ -10,7 +10,7 @@ import path from 'node:path';
10
10
  import crypto from 'node:crypto';
11
11
  import { FrozenArenaStore } from 'muttafa';
12
12
  import { crc32 } from 'raptiye';
13
- import { Opcodes, unpackChunkMeta, unpackFileBegin, unpackTransferBegin, unpackFileEnd } from '../core/protocol.js';
13
+ import { Opcodes, unpackChunkMeta, unpackFileBegin, unpackTransferBegin, unpackFileEnd, unpackPullError } from '../core/protocol.js';
14
14
  import { FileResumeState } from '../resume/state.js';
15
15
 
16
16
  export class ReceiverPipeline {
@@ -25,27 +25,38 @@ export class ReceiverPipeline {
25
25
  replication,
26
26
  destinationDir,
27
27
  metrics = null,
28
- writeDelayMs = 0
28
+ progress = null,
29
+ writeDelayMs = 0,
30
+ autoHookBatch = true
29
31
  }) {
30
32
  this.replication = replication;
31
33
  this.destinationDir = path.resolve(destinationDir);
32
34
  this.metrics = metrics;
35
+ this.progress = progress;
33
36
  this.writeDelayMs = writeDelayMs;
34
37
 
35
38
  this.activeFiles = new Map(); // fileId -> { destPath, partPath, statePath, fileHandle, resumeState, ... }
36
39
  this.completedFiles = new Set();
37
40
  this.transferFinished = false;
41
+ this.transferError = null;
38
42
  this._finishResolvers = [];
43
+ this._errorRejecters = [];
39
44
 
40
- // Hook up Raptiye batch handler
41
- this.replication.onBatch(async (msg) => {
42
- await this._processGenerationBatch(msg);
43
- });
45
+ // Hook up Raptiye batch handler if autoHookBatch is true
46
+ if (this.replication && autoHookBatch) {
47
+ this.replication.onBatch(async (msg) => {
48
+ await this._processGenerationBatch(msg);
49
+ });
50
+ }
44
51
  }
45
52
 
46
53
  async waitForCompletion() {
54
+ if (this.transferError) throw this.transferError;
47
55
  if (this.transferFinished) return;
48
- return new Promise((resolve) => this._finishResolvers.push(resolve));
56
+ return new Promise((resolve, reject) => {
57
+ this._finishResolvers.push(resolve);
58
+ this._errorRejecters.push(reject);
59
+ });
49
60
  }
50
61
 
51
62
  async _processGenerationBatch(msg) {
@@ -74,6 +85,8 @@ export class ReceiverPipeline {
74
85
 
75
86
  switch (type) {
76
87
  case Opcodes.TRANSFER_BEGIN: {
88
+ this.transferFinished = false;
89
+ this.transferError = null;
77
90
  const tinfo = unpackTransferBegin(key);
78
91
  break;
79
92
  }
@@ -99,8 +112,21 @@ export class ReceiverPipeline {
99
112
  resolve();
100
113
  }
101
114
  this._finishResolvers = [];
115
+ this._errorRejecters = [];
102
116
  break;
103
117
  }
118
+
119
+ case Opcodes.PULL_ERROR: {
120
+ const { errorCode, message } = unpackPullError(key);
121
+ const err = new Error(`Remote pull error [${errorCode}]: ${message}`);
122
+ this.transferError = err;
123
+ for (const reject of this._errorRejecters) {
124
+ reject(err);
125
+ }
126
+ this._finishResolvers = [];
127
+ this._errorRejecters = [];
128
+ return;
129
+ }
104
130
  }
105
131
  }
106
132
  }
@@ -138,6 +164,13 @@ export class ReceiverPipeline {
138
164
  sha256: info.sha256,
139
165
  chunksWrittenSinceSave: 0
140
166
  });
167
+
168
+ if (this.progress) {
169
+ this.progress.update({
170
+ fileName: info.path,
171
+ totalBytes: Number(info.fileSize)
172
+ });
173
+ }
141
174
  }
142
175
 
143
176
  async _handleChunk(key, val) {
@@ -184,6 +217,14 @@ export class ReceiverPipeline {
184
217
  this.metrics.chunksVerified++;
185
218
  }
186
219
 
220
+ if (this.progress) {
221
+ this.progress.update({
222
+ fileName: file.relativePath,
223
+ bytesDone: this.metrics ? this.metrics.fileBytesWritten : 0,
224
+ chunksDone: this.metrics ? this.metrics.chunksVerified : 0
225
+ });
226
+ }
227
+
187
228
  file.resumeState.markVerified(meta.chunkIndex);
188
229
  file.chunksWrittenSinceSave++;
189
230
 
@@ -0,0 +1,131 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import { serve, pull } from '../../src/index.js';
7
+
8
+ async function computeSha256(filePath) {
9
+ const content = await fs.readFile(filePath);
10
+ return crypto.createHash('sha256').update(content).digest('hex');
11
+ }
12
+
13
+ test('Integration - end-to-end file pull with SHA-256 verification', async () => {
14
+ const testDir = path.resolve('tmp/pull-test-single');
15
+ const serverDir = path.join(testDir, 'server');
16
+ const clientDir = path.join(testDir, 'client');
17
+
18
+ await fs.rm(testDir, { recursive: true, force: true });
19
+ await fs.mkdir(serverDir, { recursive: true });
20
+ await fs.mkdir(clientDir, { recursive: true });
21
+
22
+ // Create 5 MB test file
23
+ const testFile = path.join(serverDir, 'archive.bin');
24
+ const size = 5 * 1024 * 1024;
25
+ const buf = Buffer.alloc(size);
26
+ for (let i = 0; i < size; i += 4096) {
27
+ buf.writeUInt32LE((i * 17) >>> 0, i);
28
+ }
29
+ await fs.writeFile(testFile, buf);
30
+ const sourceSha = await computeSha256(testFile);
31
+
32
+ const port = 8821;
33
+ const server = await serve({ port, destinationDir: serverDir });
34
+
35
+ try {
36
+ const metrics = await pull({
37
+ sourceRemotePath: 'archive.bin',
38
+ host: '127.0.0.1',
39
+ port,
40
+ destinationDir: clientDir,
41
+ showProgress: false
42
+ });
43
+
44
+ const pulledFile = path.join(clientDir, 'archive.bin');
45
+ assert.strictEqual(await fs.stat(pulledFile).then(s => s.size), size);
46
+
47
+ const pulledSha = await computeSha256(pulledFile);
48
+ assert.strictEqual(pulledSha, sourceSha);
49
+
50
+ const snap = metrics.snapshot();
51
+ assert.strictEqual(snap.fileBytesWritten, size);
52
+ } finally {
53
+ await server.close();
54
+ await fs.rm(testDir, { recursive: true, force: true });
55
+ }
56
+ });
57
+
58
+ test('Integration - directory pull with nested structure', async () => {
59
+ const testDir = path.resolve('tmp/pull-test-dir');
60
+ const serverDir = path.join(testDir, 'server');
61
+ const clientDir = path.join(testDir, 'client');
62
+
63
+ await fs.rm(testDir, { recursive: true, force: true });
64
+ await fs.mkdir(serverDir, { recursive: true });
65
+ await fs.mkdir(clientDir, { recursive: true });
66
+
67
+ const tree = {
68
+ 'docs/readme.txt': 'Hello world documentation',
69
+ 'src/index.js': 'console.log("hello");',
70
+ 'src/nested/deep.json': JSON.stringify({ ok: true, nested: [1, 2, 3] })
71
+ };
72
+
73
+ for (const [relPath, content] of Object.entries(tree)) {
74
+ const fullPath = path.join(serverDir, 'my-pkg', relPath);
75
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
76
+ await fs.writeFile(fullPath, content);
77
+ }
78
+
79
+ const port = 8822;
80
+ const server = await serve({ port, destinationDir: serverDir });
81
+
82
+ try {
83
+ await pull({
84
+ sourceRemotePath: 'my-pkg',
85
+ host: '127.0.0.1',
86
+ port,
87
+ destinationDir: clientDir,
88
+ showProgress: false
89
+ });
90
+
91
+ for (const [relPath, content] of Object.entries(tree)) {
92
+ const destFile = path.join(clientDir, relPath);
93
+ const read = await fs.readFile(destFile, 'utf8');
94
+ assert.strictEqual(read, content);
95
+ }
96
+ } finally {
97
+ await server.close();
98
+ await fs.rm(testDir, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ test('Integration - pull non-existent path throws remote error', async () => {
103
+ const testDir = path.resolve('tmp/pull-test-err');
104
+ const serverDir = path.join(testDir, 'server');
105
+ const clientDir = path.join(testDir, 'client');
106
+
107
+ await fs.rm(testDir, { recursive: true, force: true });
108
+ await fs.mkdir(serverDir, { recursive: true });
109
+ await fs.mkdir(clientDir, { recursive: true });
110
+
111
+ const port = 8823;
112
+ const server = await serve({ port, destinationDir: serverDir });
113
+
114
+ try {
115
+ await assert.rejects(
116
+ async () => {
117
+ await pull({
118
+ sourceRemotePath: 'does-not-exist.tar',
119
+ host: '127.0.0.1',
120
+ port,
121
+ destinationDir: clientDir,
122
+ showProgress: false
123
+ });
124
+ },
125
+ /Remote pull error \[404\]: Path not found on remote/
126
+ );
127
+ } finally {
128
+ await server.close();
129
+ await fs.rm(testDir, { recursive: true, force: true });
130
+ }
131
+ });
@@ -10,7 +10,11 @@ import {
10
10
  packTransferBegin,
11
11
  unpackTransferBegin,
12
12
  packFileEnd,
13
- unpackFileEnd
13
+ unpackFileEnd,
14
+ packPullRequest,
15
+ unpackPullRequest,
16
+ packPullError,
17
+ unpackPullError
14
18
  } from '../../src/core/protocol.js';
15
19
 
16
20
  test('Protocol - pack and unpack chunk metadata', () => {
@@ -71,3 +75,17 @@ test('Protocol - pack and unpack transfer begin', () => {
71
75
  assert.strictEqual(unpacked.fileCount, 250);
72
76
  assert.strictEqual(unpacked.totalBytes, 53687091200n);
73
77
  });
78
+
79
+ test('Protocol - pack and unpack pull request', () => {
80
+ const req = packPullRequest({ remotePath: '/root/cdn/app.zip' });
81
+ const unpacked = unpackPullRequest(req);
82
+ assert.strictEqual(unpacked.remotePath, '/root/cdn/app.zip');
83
+ });
84
+
85
+ test('Protocol - pack and unpack pull error', () => {
86
+ const err = packPullError({ errorCode: 404, message: 'File not found: /nonexistent' });
87
+ const unpacked = unpackPullError(err);
88
+ assert.strictEqual(unpacked.errorCode, 404);
89
+ assert.strictEqual(unpacked.message, 'File not found: /nonexistent');
90
+ });
91
+