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 +2 -2
- package/bin/teyyare.js +30 -4
- package/package.json +1 -1
- package/src/core/protocol.js +44 -1
- package/src/index.js +141 -4
- package/src/receiver/pipeline.js +48 -7
- package/test/integration/pull.test.js +131 -0
- package/test/unit/protocol.test.js +19 -1
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/
|
|
6
|
-
- [`raptiye`](https://github.com/
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
package/src/core/protocol.js
CHANGED
|
@@ -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 {
|
|
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
|
+
|
package/src/receiver/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
42
|
-
|
|
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) =>
|
|
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
|
+
|