Skip to content

Node.js package, s3 backend, and the behavior of rclone - #32

Merged
sntran merged 87 commits into
mainfrom
refactor/node-package
Oct 18, 2026
Merged

sntran merged 87 commits into
mainfrom
refactor/node-package

Conversation

@sntran

@sntran sntran commented Oct 18, 2026

Copy link
Copy Markdown
Owner

This branch makes rfetch a normal Node.js package and a stateless "rclone as a service" Worker.

Package and build

  • Each backend is an npm workspace @rfetch/NAME. The shared code is the workspace @rfetch/lib.
  • rfetch runs on Node.js 22.2 or later, on Deno and on Bun 1.4 or later.
  • deno.json, deps.js and dev_deps.js are removed. Each module imports its own packages.
  • Small own code replaces @std/fmt, @std/ini and @std/media-types. node:crypto replaces aes-js.
  • wrangler.toml is the configuration of the Pages project rfetch.

Backends

  • New s3 backend: Signature Version 4, lists, ranges and multipart uploads. It is tested with R2.
  • crypt gives the same results as rclone 1.75.1, in the two directions. XSalsa20-Poly1305 is in WebAssembly, and scrypt comes from node:crypto.
  • All backends accept ranges of bytes (RFC 9110).
  • memory, local, http, alias, chunker, crypt and s3 pass a shared test suite, as the fstests of rclone.

Configuration

  • The config file is read and written as rclone does, and the comments in the file stay.
  • Options come from the headers of a request, from the environment and from the query. RFETCH_* is the primary prefix, and RCLONE_* also works.
  • A request can give a named remote in its headers, for example RFETCH_CONFIG_R2_TYPE: s3.

Commands

  • lsjson, ls, lsl, lsd and lsf give the same output as rclone, with --human-readable, --csv and the times of rclone.
  • Flags work as in rclone: a value after a space or after =, the same error messages and the same exit codes.
  • Absolute local paths and ../ work, as in rclone.
  • serve http is read-only, as in rclone. It serves the path of the remote with all its options. A client cannot set an option of the remote, and TRACE is refused.

Worker

  • The Worker keeps no configuration. Each request gives all its options.
  • The path of a request is a remote and a path, for example /:s3:bucket/a.txt or /r2:bucket/a.txt.
  • A GET with Accept: application/json gives the list of lsjson. The Depth header sets how many levels.
  • The Worker has no local backend.

Breaking changes

  • The Deno tasks are removed. Use node main.js, npm test and npm start.
  • Node.js 22.2 or later is necessary.

Tests

  • The tests compare the results with rclone 1.75.1: crypt, the lists, ranges and the config file.
  • CI runs the tests with rclone and Go, checks the format with deno fmt, and checks that secretbox.wasm builds to the same bytes.
  • bench/ measures the backends in Node.js and in workerd. The README has the times of the commands on each runtime and with rclone.

sntran added 30 commits October 17, 2026 01:02
eme.js now follows the paper of Halevi and Rogaway and rfjakob/eme step by step, with the names of the paper (P, C, T, L, PPP, MP, MC, M, CCC), and a comment for each step. The doubling in GF(2^128) changes the block in place, with no branch on the data. The checks give a TypeError or a RangeError with a clear message, and the check of the sizes in xorBlocks, which used && in place of ||, goes away. The exports are encrypt and decrypt. The output is the same as before for 2000 random inputs, and the speed is the same.

eme_test.js checks the two vectors of rfjakob/eme (16 and 2048 bytes) with the AES of aes-js and of node:crypto, the decrypt of each encrypt, the change of each block when one bit of the input or of the tweak changes, and the checks of the input.
decryptName gave 0 blocks to EME for an empty segment of a path, for example the end of a folder (x/), and EME threw. An empty segment now stays empty in the two directions, as in rclone. The test encode, with an empty name from fast-check, passes now.
aes.js gives AES-256-ECB of node:crypto with no padding as the block cipher of EME, and the dependency aes-js goes away. It works in Node.js and in workerd with nodejs_compat at the compatibility date of wrangler.toml (a check with wrangler dev). workerd refuses null as the IV, so the IV is empty.

A block cipher of EME now takes one or more blocks (ECB), so each ECB layer of EME is one call: 4 calls for each name. With node:crypto, 2048 bytes take 58 us (176 us before), and a short name about 9 us.

EME copies with new Uint8Array in place of slice: the slice of a Buffer is a view, and node:crypto gives Buffers. A new test examines a Buffer as input. The output is the same as the first code for 2000 random inputs.
deno.json, the Deno settings of VS Code and the engine deno go away. main.js runs with node, and the backends have no shebang of deno serve: a backend is a module, not a program. The two benchmarks run with node and lib/bench.js, a small helper with the timer of Deno.bench, in place of Deno.bench and Deno.Command (npm run bench). The README and CONTRIBUTING.md tell the npm commands and node:test. The tests give the same result as before: 68 of 93 pass on Node.js 26.
The key of obscure came from a top-level await. With the circular imports of mod.js and the backends, esbuild puts each module in a lazy function, and a top-level await cannot be in such a function: wrangler pages dev 4.142 could not build _worker.js, also on the first commit. The key is now a promise that getKey makes at its first use.
deps.js and dev_deps.js re-exported the packages, as deps.ts in Deno. Now each module imports its packages, and package.json lists them. The modules that use HTMLRewriter import it: crypt used the global that deps.js set.

Node.js has the same functions for four packages, so they go away: @std/path is node:path, @std/encoding is Buffer, the MD5 of @std/crypto is createHash of node:crypto (with no WebAssembly, which Workers block), and nobody used @std/http. The tests import node:assert/strict and fast-check. The helpers of deps.js go to their users: toLocaleISOString to lib/time.js, config_dir to cmd/config.

The tests give the same result as before (68 of 93 pass). In wrangler pages dev, PUT, GET and the list of memory and of crypt over memory work, and rclone 1.75.1 decodes the name that crypt wrote.
The date was 2024-06-28. 2026-09-01 is also the date of mkfifo.com, and workerd 1.20260926 has it. At this date nodejs_compat is on by default; the flag stays, so that the file tells it. In wrangler pages dev at this date, with no flag, PUT, GET and the list of memory and of crypt over memory work. workerd still refuses a null IV for AES-ECB, so aes.js keeps the empty IV.
These three packages came from JSR through npm:@jsr aliases and the .npmrc of this repository, so a different project could not install rfetch. Now small modules of rfetch do the work, with the APIs of the web and of Node.js, and .npmrc goes away:

- lib/format.js: formatBytes and formatDuration give the text of @std/fmt for the options of rfetch (no difference in 160,103 cases), but with KiB in place of kiB, the symbol of IEC, as rclone writes it.
- lib/ini.js: parse and stringify in the format of the configuration file of rclone: [name], key = value, and an empty line between sections. @std/ini 0.225 did not use the options section and whitespace that cmd/config gave it. Two sections with the same name become one, as in rclone, and a name such as __proto__ does not change the prototype.
- lib/mime.js: mimeType as MimeTypeFromName of rclone: the table of Go, the types that rclone adds, and .txt. An unknown extension gives application/octet-stream, as in rclone, in place of an empty Content-Type.

The new tests of lib pass.
With no configuration file, config file gave an empty path, and create wrote nowhere. Now the file is the default file of rclone, CONFIG_DIR/rclone/rclone.conf, as the test expects. With the new INI module, the 7 tests of cmd/config pass.
pages_build_output_dir makes this file the configuration of the deploys from Git and of wrangler pages dev, in place of the settings of the dashboard. The output is the repository root, with _worker.js. The name is rfetch: denoclr was the earlier name, rcloned in reverse, with Deno in it.
decodePath gives the path of a URL with no percent-encoding. It gives null for a path that can go out of the root, for example /..%2Fsecret.txt, which the URL parser keeps.

escapeHTML puts a character reference in place of each character that has a meaning in HTML.
backendSuite(name, backend) runs the same tests on each backend, in a new folder that it deletes at the end:

- 404 for a file or a folder that does not exist;
- PUT and DELETE of files and folders;
- the names of the test FsEncoding of rclone;
- a file of 0 bytes, and the two files of fstests;
- the list of each folder;
- the bytes and the headers of GET and HEAD.

remote() is a client of a backend in the same process. parseListing reads the HTML list of a folder.
The memory backend now:

- removes the percent-encoding of the path, and gives 400 for a path that is not safe;
- writes each name with percent-encoding in the link, and with escapes in the HTML;
- gives the type of each file in the list, not the type of the folder;
- gives Content-Type, Content-Length and Last-Modified for GET and HEAD of a file;
- gives 404 for HEAD and DELETE of a path that does not exist, and 405 for other methods;
- finds the files of a folder with no regular expression, which failed for a name with ( or [;
- gives the size of a file of 0 bytes in the list.

The tests of the memory backend are now the shared suite.
The local backend now:

- removes the percent-encoding of the path, and gives 400 for a path that can go out of the current folder, for example /..%2Fsecret.txt;
- gives 403 for DELETE /. Before, DELETE / deleted the current folder and all that is in it;
- gives 404 for a path that does not exist, in place of an error;
- creates the parent folders for PUT of a file, and accepts a PUT with no body;
- writes each name with percent-encoding in the link, and with escapes in the HTML, and gives the type of each file.

The tests use a new temporary folder as the current folder. They run the shared suite, and tests for the root folder.
The links of a list have percent-encoding. Thus lsjson gave the name a%20b.txt for the file a b.txt.
CONTRIBUTING.md tells the answers that a backend gives, how to use decodePath and escapeHTML, and how a backend runs the shared test suite.
…arser

lib/ini.js now has the rules of goconfig v1.0.0, which rclone uses to read and write its configuration file:

- the white space of Go, and the byte order mark;
- ":" as the end of a key, as "=";
- keys and values in quotes;
- the keys before the first section in the section DEFAULT;
- numbers for the keys "-".

ParseStream is a TransformStream from the text, in chunks of any size, to an entry for each section and each key. parse(text) and read(stream) use the same reader of lines.

stringify puts a key or a value in quotes when rclone does not read it the same with no quotes. It throws a TypeError for what rclone cannot read back. rclone 1.75.1 itself writes the value a`b as "a`b" and the value "  a  " with no quotes, and then it reads back different values.

An error gives the number of the line, but not its text, which can have a password.

The property tests:

- parse reads what stringify writes, or stringify throws a TypeError;
- stringify writes each configuration that rclone makes, and each configuration that parse reads;
- read gives the result of parse for each split of the text;
- parse reads each file and each line as rclone 1.75.1 reads it, and rclone reads what stringify writes. These tests run when rclone is installed, or when RCLONE is its path.
…he file cannot keep

The old rule for names accepted a line break, and a space at the end. A line break in a name could break the file. Now the rule is fspath.CheckConfigName of rclone.

create and update give 400, and do not change the file, for an option that the file cannot keep, for example a value with a line break. The error is in the body, because a status text cannot have all characters.

show and update find only the own keys of the configuration: the name toString is not a remote.
The note on the tests against rclone had long lines.
ConfigFile changes only the lines of a change: update, set and delete. All other lines stay as they are: the comments, the empty lines and the format of each line.

A comment directly above a section or a key, with no empty line between, is a comment of that line. It goes away only when that line goes away. All other comments always stay. rclone 1.75.1 does less: when it saves the file, it loses the comments at the end of the file, and a delete of a section also deletes the comments above the section that are for the whole file.

Other changes:

- The entries of ParseStream have a type (section, key or comment) and the number of the line.
- A key that starts with "[" or U+FEFF needs no quotes. A line of a key that starts with "[" must not end with "]", thus its value goes in quotes then. The property tests found such keys, which parse reads but stringify could not write.
- When a key "-" goes away, the keys "-" after it get their numbers as keys in quotes. Thus their numbers do not change.
- The source has escapes in place of invisible characters.

The new property tests:

- ConfigFile keeps the text of each file that parse reads;
- ConfigFile changes the configuration as a model of maps;
- ConfigFile removes only the comments directly above a line that goes away;
- rclone reads the file that ConfigFile changes.
create, update and delete change the file with ConfigFile. show with no name gives the whole file with its comments, as rclone config show does.

The tests use a new folder as the current folder and as the home folder. Before, they wrote rclone.conf in the folder of the repository, as main_test.js does, and the two test files ran at the same time.
The data:

- DecryptionStream reads the header and the blocks from chunks of any size. Before, the header had to be in the first chunk.
- It checks the magic bytes, and gives the errors of rclone: file too short, truncated block header, bad magic, block that does not authenticate.
- encryptedSize and decryptedSize are the functions of rclone, and decryptedSize throws for a size that no encrypted data has.

The names:

- PathCipher removes the padding with the checks of rclone, and refuses bad UTF-8, a size that is not a number of blocks, and a name of more than 2048 bytes.
- padding.js goes away: crypt uses only PKCS#7.

The tests use the vectors of backend/crypt/cipher_test.go of rclone: the names, the paths, the files of TestEncryptData, the sizes and the errors. Properties test each split of the chunks and each size of block.
The crypt backend now:

- puts no password and no salt in the links of a list. Before, each link had the query of the request with the password, and a browser keeps the links in its history;
- writes each name with percent-encoding in the link and with escapes in the HTML, and shows no link for a name that does not decrypt, as rclone skips such a name;
- gives the error of the remote, for example 404, and does not decrypt it;
- gives Content-Type, Content-Length and Last-Modified for GET and HEAD, and no other header of the encrypted data;
- sends the encrypted size as Content-Length on PUT, and not the type of the file;
- gives 400 for a path that is not safe, with no remote, with no password, or with a password that is not obscured;
- makes the keys as rclone: an empty password gives keys of zero bytes. It keeps the keys of the last 16 passwords, as scrypt takes much time.

The tests have the key vectors of rclone (TestKey) and run the shared suite over the memory backend.

In a Worker, a response of a stream has no Content-Length. Thus crypt sends the data through a FixedLengthStream when the runtime has one, and the memory backend gives a Blob.
rclone copy encrypts files and rfetch reads them: the bytes, the sizes and the lists, as rclone lsjson -R. rfetch encrypts files and rclone reads them: rclone copy and rclone cryptcheck. A property sends random names and data in the two directions.

The files have hard names and the sizes 0, 1, 65535, 65536, 65537 and 1 MiB + 1. The tests run when rclone is installed.
lib/listing.js reads and writes the HTML table of a list. The tests and the backends use it:

- readListing gives the entries of a list, as rclone lsjson. It was lib/testing/listing.js.
- renderListing writes a list from the entries. The memory backend uses it, and other backends can use it.
- relink puts a query in place of the query of each link of a list, for a backend on top of another backend.
- childOf gives the name of a file or a folder directly in a folder, from a link.

A link of renderListing has a query only when the query is not empty.
The shared suite has two new options:

- skipName: the tests skip the names that match it. Use it only for a limit that the backend has in rclone too.
- writer: for a read only backend, the PUT and DELETE requests go to this backend, and the backend under test reads the files.

When a test of a name fails, the test deletes its folder. Thus the next tests do not see its files.
The alias backend now:

- gives 400 with no remote, and for a path that is not safe. Before, it threw an error;
- sends the path with percent-encoding to the remote, with the method, the headers and the body of the request;
- puts its own query in the links of a list, not the query of the remote;
- gives the path of the alias in Content-Location.

lib/path.js has encodePath, the reverse of decodePath.
parseSize reads a size as SizeSuffix of rclone: no suffix is KiB, b is bytes, and k, M, G, T, P and E are powers of 1024, with an optional i and B. off is -1. The test has the vectors of TestSizeSuffixSet of rclone.
The chunker backend now writes and reads the files of the chunker of rclone 1.75.1:

- The names of the chunks are as name_format, with start_from, and with the suffix of a transaction for norename. A name with the form of a chunk, a control chunk or a temporary chunk is not a file of the list.
- The metadata is simplejson, version 1 or 2, with the size, the number of chunks, md5 or sha1, and the transaction. As rclone, it reads at most 1023 bytes and writes at most 255 bytes, and it refuses metadata that is not correct.
- meta_format none needs hash_type none, as in rclone. Then a file is always in chunks.
- A small file goes to the remote as it is. When its content looks like metadata, it gets one chunk and metadata.
- A file of unknown size goes in chunks with metadata when it is larger than 1 MiB or than the chunk size.
- A list gives the sum of the sizes of the chunks, and it does not show a file with missing chunks. GET of such a file gives 500.
- DELETE deletes the metadata first, then the chunks.
- chunk_size is a size of rclone, 2 GiB by default.

The backends of rfetch cannot rename. Thus with the transactions rename, a reader can see a file that is not complete. The transactions auto are norename.

The tests run the shared suite in three modes, and check the files on the remote. With rclone, the tests copy files in the two directions, and check that rfetch and rclone make the same files. In the mode rename, the chunks and the metadata are the same bytes.
parseList reads an option of the type CommaSepList or SpaceSepList of rclone. As in rclone, it reads one record with the CSV rules of Go: a value in quotes can have the separator, a line break, and "" for one quote.

A property test sends 5000 random texts to parseList and to encoding/csv of Go, and they must give the same values and the same errors. It runs when Go is installed.
sntran added 28 commits October 18, 2026 12:44
toLocaleString and localeCompare make a new format or collator of the
locale for each call. Now renderListing uses one Intl.Collator, and
formatBytes uses one Intl.NumberFormat. The text and the order are the
same.

In Node.js, renderListing of 10000 entries: before 69 ms, after 58 ms.
A handler of an end tag is one more call from the HTML parser into
JavaScript for each row, which costs much time in a Worker. Now the
entry of a row goes in the list at its link. The last link of a row
gives the entry, as before.

The new tests in lib/listing_test.js pass with the old and the new
code.

In workerd, readListing of 10000 entries: before 397 ms, after 375 ms.
lsjson had its own HTMLRewriter for the rows, and a second
TransformStream to remove the last comma. For --recursive, it parsed
again the JSON lines of each folder below, and it thought that each
chunk was one line.

Now lsjson reads each list with readListing of lib, and one
ReadableStream gives one chunk and line for each entry. The lists of
the folders below start at the same time, as before. The output has a
fixed order now: the entries of a folder, then the entries of each
folder in it. A check of 12 cases against the old code gave the same
output.

A folder below that lsjson cannot list now stops the list with an
error, as 'rclone lsjson -R' exits with an error. Before, the list
ended with no entries of that folder, and with no error.

Crypt lsjson of 1000 files, in workerd: before 188 ms, after 97 ms.
lsjson of 1000 files of memory, in Node.js: before 56 ms, after 43 ms.
The data of rclone crypt is NaCl secretbox: XSalsa20 and Poly1305.
Node.js, WebCrypto and Workers have no Salsa20, thus the blocks went
through @noble/ciphers in JavaScript. Now backend/crypt/secretbox.wasm
seals and opens the blocks, up to 16 blocks for each call. Its source is
the Rust crate in backend/crypt/native, on crypto_secretbox of RustCrypto,
as the crypt NIF of mkfifo. The module has no imports, and it uses the
SIMD of WebAssembly. The tests of rclone (TestEncryptData) and the tests
against real rclone pass.

Node.js, Deno and Bun read the file. A Worker cannot compile a module at
run time, thus the field imports of package.json gives wrangler a file
that imports the module. The new job wasm of CI builds the module again,
and compares its bytes with the file in the repository.

The key of a password now comes from the scrypt of node:crypto, which is
native code in Node.js and in workerd. @noble/ciphers and @noble/hashes
are not dependencies now. The test of the imports of the workspaces
accepts a name of the field imports of package.json, to a file in the
folder.

8 MiB in Node.js: PUT before 78 ms, after 39 ms; GET before 67 ms,
after 37 ms. 8 MiB over memory in workerd: PUT before 200 ms, after
138 ms; GET before 157 ms, after 109 ms. scrypt of a password: in
workerd before 147 ms, after 34 ms; in Node.js before 92 ms, after
48 ms.
The memory backend kept each file as a File, and each folder as a key
with no value. A list read all the keys of all the folders, and a GET
gave the File as the body: a Worker reads a Blob body in chunks of
4 KiB.

Now a file is its bytes, its media type and its time, and a GET gives a
copy of the bytes as the body. Each folder has the names of its
children, thus a list reads only its folder. The behavior is the same,
and the shared suite passes.

A list of 10 files, with 10000 other files in the store, in Node.js:
before 0.39 ms, after 0.13 ms. 8 MiB in workerd, in one run with the
two versions: PUT before 12 ms, after 8 ms; GET with a reader before
31 ms, after 24 ms. A delete of a folder of 10000 files: before 1.5 ms,
after 3.7 ms.
The module had the path of the crates of cargo on the machine of the
build, for example /root/.cargo/registry/..., thus the job wasm of CI
built other bytes. Now build.sh changes the path of the crates to /cargo
and the path of the crate to /crate, with --remap-path-prefix. Two
builds in two folders, with two homes of cargo, gave the same bytes.

The flags are in build.sh now, in place of .cargo/config.toml: the
variable of the flags takes the place of the flags of the file.
rclone lsjson remote:tree lists the folder tree. rfetch lsjson read a
path with no / at the end as a file: it listed the parent folder and
gave only the entry of tree. Now, when no file has the name and a folder
has it, lsjson lists that folder, as rclone. A file of the name is still
a file. The comparison with rclone found the difference.
A GET read the file in parts of 64 KiB, with one call to the system for
each part. Now a part is 1 MiB. The list of a folder made the stat of
each file one after the other. Now the stats go at the same time, and a
file that goes away before its stat is not in the list.

rfetch cat of 64 MiB of a local file, the whole command: before 257 ms,
after 217 ms; cat of a chunker file of 64 MiB in chunks of 8 MiB: before
324 ms, after 253 ms. In one process: the list of a folder of 100 files,
before 11.5 ms, after 8.9 ms; lsjson --recursive of 1000 files, before
156 ms, after 102 ms.
bench/rclone.js runs the same commands with the two tools, with the same
remotes in the variables RCLONE_CONFIG_*, on the same files: cat, rcat
and lsjson --recursive on local files, crypt and chunker. rclone writes
the files of crypt and chunker. First the script makes sure that the two
tools give the same data and the same lists, and that rclone reads the
files that rfetch writes. The time is the time of the whole process.

The tools run in a temporary folder, with relative paths: the local
backend of rfetch reads only the files in its current folder.
The command line loaded all the commands through mod.js, and with them
HTMLRewriter. Now main.js loads only the module of its command, from
the list cmd/folders.js. The fetch of rfetch is in fetch.js: mod.js,
the commands and _worker.js import it. An unknown command gives an
error and the help, with the exit code 1.

The median time of 21 runs, in Node.js 22, before and after:
- obscure: 135 ms, 109 ms.
- help: 123 ms, 103 ms.
- cat of a small file: 127 ms, 114 ms.

The test cmd/folders_test.js makes sure that cmd/folders.js has each
command of cmd/main.js.
The new module @rfetch/lib/rewriter gives htmlRewriter(). It loads
HTMLRewriter at its first use, thus a command that reads no list does
not load it. readListing, relink, the list of crypt and the index of
http use it; relink and the list of crypt are now async.

In Node.js, the HTMLRewriter of WebAssembly puts each chunk of its
output in the queue of a stream. A list in one large chunk gave a queue
of more than 16000 chunks, and a read from such a queue copies it: the
time was quadratic. Now HTMLRewriter gets the HTML in chunks of at most
8 KiB, and the small chunks become one write. The HTMLRewriter of
workerd and of Bun gets the chunks as they come: a stream of JavaScript
between two of them costs more time.

bench/node.js, before and after:
- crypt: list 1000 files: 1022 ms, 85 ms.
- crypt: lsjson of 1000 files: 627 ms, 150 ms.
- lib: readListing of 10000 entries: 2990 ms, 349 ms.

In workerd, the times do not change: crypt lsjson of 1000 files is 88
to 98 ms before, and 96 to 102 ms after.
node bench/rclone.js [RUNTIME...] runs rfetch on each runtime that the
arguments name, for example node, deno and bun, and rclone. Each
runtime must give the same data and the same lists as rclone. The output
is a table in Markdown: one row for each case, and one column for each
runtime and for rclone.

The median of 5 runs, in ms, on this code (rclone 1.75.1):
- start: node 22 164, node 24 140, node 26 124, deno 110, bun 43,
  rclone 47.
- local lsjson --recursive, 1000 files: node 22 286, node 24 270,
  node 26 252, deno 281, bun 68, rclone 52.
- crypt cat 64 MiB: node 22 449, node 24 474, node 26 488, deno 510,
  bun 448, rclone 216.
The new scanTags of @rfetch/lib/html reads the tags of an HTML document
in JavaScript, as the tokenizer of the HTML standard: the attributes,
the comments, and the text of script, style, textarea and title, which
is not markup. readListing, and the index of the http backend, read the
lists with it. The crypt list and relink of alias read the list of the
other backend with readListing, change the entries, and write the list
with renderListing. Thus rfetch does not use HTMLRewriter, and
lib/rewriter.js goes away. @sntran/html-rewriter stays only as a
devDependency of lib: a property test makes sure that readListing gives
the entries of HTMLRewriter for each list of renderListing.

An attribute value now gets the decode of the basic and the numeric
character references, as the standard tells. HTMLRewriter gives the raw
value. A list of rfetch has no reference in its links.

bench/node.js, before and after:
- lib: readListing of 10000 entries: 349 ms, 62 ms.
- crypt: list 1000 files: 85 ms, 31 ms.
- crypt: lsjson of 1000 files: 150 ms, 36 ms.

workerd (wrangler dev, 3 runs), before and after:
- lib: readListing of 10000 entries: 297 to 379 ms, 49 to 64 ms.
- crypt: list 1000 files: 41 to 60 ms, 24 to 29 ms.
- crypt: lsjson of 1000 files: 88 to 102 ms, 34 to 39 ms.

The command line in Node.js 22, median of 21 runs, before and after:
- lsjson of an empty folder: 140 ms, 116 ms.
- lsjson --recursive of 1000 local files: 333 ms, 256 ms.
A local path of the command line or of the code is now relative to the
current folder, as in rclone: file.txt, ../file.txt and /tmp/file.txt.
Before, the local backend joined each path to the current folder, thus
/tmp/x was the folder tmp/x in it, and ../x was x.

fetch.js resolves the path as a URL on the file URL of the current
folder, and gives the local backend the absolute path, with
RFETCH_LOCAL_ROOT: / in env. RFETCH_LOCAL_ROOT is not an option: fetch.js
removes it from the options, thus a config file, a variable, a header or
the query cannot set it. :local:path, a named remote of type local, and a
client of rfetch serve, which gives no env, stay in the current folder.
The Worker gives no local path: a path with no remote is a file of its
site.

The local backend refuses to delete its root and the current folder.

Tests: an absolute path and ../ read a file out of the current folder; a
header, a variable and a remote cannot set the root; a delete of the
current folder gives 403.
The command line gave the exit code 0 and no message for an error. Now
an answer that is not ok writes the error to stderr, and gives the exit
code 3 for 404, as "directory not found" of rclone, else 1.

lsjson of a path that names no file and no folder gives 404, as rclone.
Before, it gave an empty list.

The output waits for drain when the pipe is full: before, a slow reader
kept all the output in memory. A reader that closes the pipe, for
example head, stops the command with no error. Before, Node.js wrote
the stack of an EPIPE error. Node.js, Deno and Bun give the same data,
and stop with no error.
rclone lsjson gives the time of a local file with 9 digits after the
second, for example 2026-10-18T15:02:41.540476887Z: the format of Go for
a remote with a precision of 1 ns. rfetch gave milliseconds.

Now the local backend writes the time of each file and folder of a list
with 9 digits, from the stat in nanoseconds. renderListing and
toLocaleISOString keep the digits of a time, from 3 to 9, thus crypt and
chunker on a local remote give them too. A remote with milliseconds,
for example memory, keeps 3 digits. The size of a folder stays -1, as
for the other backends.

Node.js and Bun give the nanoseconds of a file. Deno gives
milliseconds, thus on Deno the last 6 digits are 0.

cmd/lsjson/rclone_test.js now compares the ModTime of each file and
folder with rclone, digit for digit. bench/rclone.js compares the times
to the millisecond, for Deno.
The crate salsa20 has no SIMD for WebAssembly, and Salsa20 was 55% of
the time of the module. The new src/salsa.rs of the crate computes 4
blocks of Salsa20 at a time, one in each lane of the SIMD of
WebAssembly, and HSalsa20 for the key of each box. Poly1305 is the crate
poly1305, as before, and the compare of the tags takes the same time
for each tag (subtle). crypto_secretbox is now only a dev-dependency.

The boxes are the same bytes as before:
- cargo test compares each box with crypto_secretbox for 16 sizes,
  with a counter of blocks near a carry, and a bad box and a bad tag
  do not open. The code with no SIMD runs on the machine of the build.
- secretbox_test.js decrypts the box of the first test of NaCl, of 131
  bytes, over 3 blocks of Salsa20, with the module.
- The rclone tests of crypt compare the data with rclone.
The job wasm of CI runs cargo test.

The module only, 64 MiB in blocks of 64 KiB, in Node.js 22: seal
167 ms before, 110 ms after; open 183 ms before, 128 ms after.
bench/node.js: crypt PUT 8 MiB 33.7 ms before, 20.8 ms after; crypt
GET 8 MiB 35.7 ms before, 29.5 ms after. Bun gains less: its
WebAssembly makes less of the SIMD.
EncryptionStream and DecryptionStream joined the bytes that were not a
full block yet with each new chunk. A request body in a Worker comes in
chunks of a few KiB, thus each chunk of 4 KiB copied up to 64 KiB. Now
the chunks stay in a list, and join one time when a whole block, box or
header is there.

8 MiB in chunks of 4 KiB, in Node.js, 2 runs: seal 73 and 90 ms
before, 37 and 45 ms after; open 55 and 60 ms before, 33 and 37 ms
after. Chunks of 64 KiB and of 1 MiB do not change.
workerd (wrangler dev, 3 runs), with the module of the commit before:
crypt PUT 8 MiB 107 to 145 ms before, 69 to 82 ms after; crypt GET
8 MiB 102 to 124 ms before, 72 to 78 ms after.
rfetch serve gave the request of a client to the backend with its query
and its headers. A field of the query won over the option of the served
remote, for example ?remote=/etc/ for an alias, and the headers RFETCH_*
and RCLONE_* went on to the remotes below, where fetch.js reads them as
options and remotes. Thus a client could read the other remotes of the
config, and with the absolute local paths of the commit before, each file
of the disk. Now serve removes the query and these headers of each
request, and sets only the options of the served remote.

The Worker has no local backend now: a client of the Worker tells all
its options, thus an alias to / would read the files of the runtime.
In workerd, node:fs reads no file today, but in Node.js it does.

Tests: a client of serve with ?remote= or the headers of an option reads
only the served folder; the Worker gives 404 for :local:, for an alias to
/etc/, and for a named remote of type local. Both tests fail with the
code before.
scanTags of d1133f0 was a small HTML parser of rfetch. A list can come
from any server: the index page of the http backend, or the remote of a
crypt at a URL. A small parser can read such a page wrongly. Now
readListing and the index of http read HTML with readElements of the new
@rfetch/lib/rewriter: HTMLRewriter, that is lol-html, the HTML parser of
Cloudflare.

- In workerd and in Bun, it is the HTMLRewriter of the runtime.
- In Node.js and in Deno, it is @sntran/lol-html, lol-html 2.8 in
  WebAssembly, with no stream: each chunk of HTML goes to write(), and the
  output goes away. Thus no queue of output chunks grows, as it did with
  html-rewriter-wasm. It loads at its first use.

HTMLRewriter gives the value of an attribute as it is in the document.
attributeOf decodes the character references with decodeHTMLAttribute of
the package entities, as the HTML standard tells. scanTags decoded only
the basic and the numeric references. A new case in
backend/http/rclone_test.js has a script, a comment and the reference
é: rfetch and rclone list the same files. With scanTags, this case
fails.

The list of crypt and relink of alias stay as in d1133f0: they read the
entries with readListing and write the list with renderListing.

bench/node.js in Node.js 22, with scanTags and now (3 runs):
- lib: readListing of 10000 entries: 62 ms, 140 to 154 ms.
- crypt: list 1000 files: 31 ms, 38 to 43 ms.
- crypt: lsjson of 1000 files: 36 ms, 49 to 53 ms.
With html-rewriter-wasm, before d1133f0, these were 349, 85 and 150 ms.

workerd (wrangler dev, 3 runs), with scanTags and now:
- lib: readListing of 10000 entries: 49 to 64 ms, 256 to 297 ms.
- crypt: list 1000 files: 24 to 29 ms, 42 to 58 ms.
- crypt: lsjson of 1000 files: 34 to 39 ms, 73 to 102 ms.
These are the times of the HTMLRewriter of workerd before d1133f0. The
output goes to arrayBuffer(): a pipe to a WritableStream of JavaScript
took 835 to 922 ms for 10000 entries.

Bun, now: 49 to 53 ms, 16 to 23 ms and 21 to 32 ms.
rfetch serve http REMOTE:PATH served the root of the remote, or the
current folder for a local path, and not PATH. The backend got only the
options in the query of TRACE: the options of the config file and of the
variables did not go, for example the password of a crypt remote in the
config file. Now each request of a client goes to the fetch of rfetch, at
REMOTE:PATH/ and the path of the request, with the flags of the backend
in the query. Thus the remote gets all its options, as for the other
commands.

The server now has all the options of the remote, thus these rules
protect it:
- A client only reads, as with rclone serve http: each method but GET
  and HEAD gives 405. The fetch of rfetch answers TRACE with the options
  of the remote, with its secrets.
- A name with a / after the decoding, for example ..%2F, gives 400.
- The links of a list have no query: the server reads no query, and the
  options of the backend do not show.
- An error gives 500 with no message, and the server continues.

The server is now node:http, not @sntran/serve. In @sntran/serve 0.0.1 to
0.0.3, new Request() refuses the method TRACE with a TypeError, and no
code catches it: one TRACE request of a client stopped rfetch serve with
the exit code 1. node:http works in Node.js, Deno and Bun. The test of
the global fetch in main_test.js uses listen of @rfetch/lib/testing/server.

main.js knows the flag --addr, thus --addr 127.0.0.1:8080 works. Before,
only --addr=127.0.0.1:8080 worked: parseArgs read the address as an
argument of the command.

Tests: cmd/serve/main_test.js serves a subfolder, a named remote of the
variables, a crypt remote of a config file, and a crypt remote with the
flag crypt_password. ..%2F gives 400. TRACE, PUT and DELETE give 405, and
the file stays. The links of a list have no query. An error gives 500,
and the next request works. main_test.js starts rfetch serve http . with
--addr 127.0.0.1:0, sends TRACE, then reads a file.
The new section Runtimes tells how to run rfetch on Node.js, Deno and
Bun, and gives one run of bench/rclone.js on Node.js 22, 24 and 26, Deno
2.9, Bun 1.4 and rclone 1.75.1. It recommends no runtime: the times
change from one run to the next and from one machine to the next, thus a
user runs the benchmark on the machine of the user to choose.
The property of f2e5f34 made a name of each text, and failed about one
time in five runs. The link of the name . is the folder, the link of ..
is its parent, and a name with / is two names: readListing cannot give
them back, and decodePath refuses them. Now the property makes no such
name. It passes in 150 runs.
The parseArgs of Node.js takes the next argument as the value only for
a flag that it knows as a string. main.js told it few flags, with strict
false: thus --crypt-password x or --max-depth 2 gave the flag true, and
the value became an argument of the command.

Now parseFlags of the new cmd/flags.js tells parseArgs the type of each
flag, as rclone:
- FLAGS has the flags of the commands. A boolean flag takes no value, or
  a value after =, for example --dir-slash=false. A string flag takes
  the next argument, or a value after =.
- A flag of a backend, for example --crypt-password, has the type of the
  option in the options of the module of the backend. Only the backends
  of the flags load. alias, crypt and drive now export their options.
- An unknown flag, a flag with no value, and a boolean value that Go does
  not read give the error of rclone, with the exit code 1.
- A flag with the name no-... is not the negation of a flag. Before,
  allowNegative made --no-clobber of copyurl into clobber: false.
- --help shows the help, and rfetch help flags lists the flags.
- The short flags of rclone: -R, -F, -s, -d, -a, -p, -n.

Two bugs of the boolean flags in the lists, fixed with isTrue of
@rfetch/lib/options, which reads true and the text true:
- lsd gave recursive the text false, which is true in JavaScript: thus
  lsd listed all the folders below. Now it lists one level, as rclone.
- lsjson . and lsjson .. gave directory not found. A path with . or .. at
  the end is a folder now, as in rclone.

Tests: cmd/flags_test.js reads flags after a space and after =, the
flags of backends, the booleans of rclone, the arguments after --, the
errors, and each option of each backend of rfetch as a flag. main_test.js
runs lsf with flags after a space, an unknown flag, lsd and lsjson .
…, as rclone

rclone gives a folder with no time the time of the flag --default-time,
2000-01-01T00:00:00Z. lsjson of rfetch gave "", for example for the
folders of memory, http and s3. Now lsjson gives DEFAULT_TIME of
@rfetch/lib/time.

The precision of the http backend of rclone is 1 s: lsjson gives
2026-01-02T03:04:05Z. rfetch gave 2026-01-02T03:04:05.000Z. Now the http
backend writes its times to the second, and toLocaleISOString keeps the
digits of a time in a string from 0 to 9, not from 3 to 9: the digits
tell the precision of the remote. A Date still gives 3 digits.

Test: lsjson -R of rfetch and of rclone on the same http index, of
python3 -m http.server, give the same items. lsjson of a folder of
memory gives 2000-01-01T00:00:00Z.
The lists of text of rfetch had tabs and ISO times. Now each line is the
line of rclone 1.75:
- ls: the size on the right of 9 characters, then the path.
- lsl: the size, the local time 2006-01-02 15:04:05.000000000, then the
  path.
- lsd: the size on the right of 12 characters, the local time to the
  second, the count of items on the right of 9 characters, then the path
  with no / at the end. A folder has -1 for the size and the count. rclone
  gives the size of a local folder on the disk, for example 4096: rfetch
  gives -1, as the other backends.
- --human-readable: the size with a suffix of 1024, for example
  120.562Ki, and the count with a suffix of 1000, in fields of 9 and 8
  characters. The rounding is the rounding of %.3f of Go: a half goes to
  the even digit.
- lsf: the letter t gives the local time 2006-01-02 15:04:05. With --csv,
  the separator is , when the flag does not tell it, and a value with the
  separator, a quote, a line break or a space at the start is in quotes,
  as csv.Writer of Go.

The new listLines of cmd/lsf/main.js reads the items of lsjson, and each
command gives the line of an item. toListTime of @rfetch/lib/time writes
the times, and sizeString, countString, sizeField and countField of
@rfetch/lib/format write the sizes and the counts, as SizeString and
SizeStringField of rclone.

Tests: cmd/lsf/rclone_test.js compares the lines of ls, lsl, lsd and lsf
with the lines of rclone on the same local files, with names that CSV
puts in quotes. It passes with TZ UTC, America/New_York and
Asia/Kathmandu. The tests of each command check the lines on memory, and
lib/format_test.js checks the texts of SizeSuffix and CountSuffix of Go.
listLines read the body of lsjson with a TransformStream on chunks, not
on lines. It worked only because lsjson gives each line in its own chunk.
When the body comes in other chunks, for example from the Worker on the
network, the list was empty and there was no error.

The new LineStream of cmd/lsjson/main.js is a TransformStream from bytes
to bytes. It keeps only the part of a line that has no "\n" yet, as the
ParseStream of @rfetch/lib/ini, and gives the line of each item. A line
that is not an item is an error of the stream. ls, lsl, lsd and lsf send
the body of lsjson through it, thus any body of lsjson can go through it.

Tests: LineStream reads a body in one chunk, in a chunk for each byte, and
in chunks of 7 bytes, and gives an error for a broken line.
On Bun 1.3, rfetch cannot read the list of a local folder: the
HTMLRewriter of Bun gives the error "Failed to pipe stream". An http
remote works. On Bun 1.4.0 and 1.4.3, the lists of a local folder and of
an http remote are correct.

The README tells the minimum version and the error. The engines of
package.json have bun >=1.4.0, next to node.
@sntran
sntran merged commit 9316ad1 into main Oct 18, 2026
10 of 11 checks passed
@sntran
sntran deleted the refactor/node-package branch October 18, 2026 23:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant