@reim-developer/magical-js, for JavaScript and TypeScript. For what the calls
mean rather than what they are called, read Concepts first.
npm install @reim-developer/magical-jsThe module is a single .wasm with zero imports, so there is no glue file and no
import object. Everything is synchronous.
import {
// level 1
detectPath, detectBytes, readHeader, neededBytes, DEFAULT_MAX_BYTES_READ,
readLimits,
// level 1, data first
detected, Detected,
// the answer type
FileKind, isFileKind, allKinds,
// metadata
displayName, mime, extension,
// the table
describe, signatureTable,
// asking
matches,
// level 2
matchTypes, matchAllTypes, releaseRules,
} from "@reim-developer/magical-js";FileKind is a TypeScript string union — the discriminants are the ABI. That
is the one place this binding cannot match the other two, and it is deliberate: a
union gives a caller "Png" narrowed to the literal "Png", where an enum object
would not narrow at all across a .wasm boundary.
| Call | Returns | Notes |
|---|---|---|
detectPath(path, options?) |
FileKind | null |
Synchronous, not a Promise |
detectBytes(bytes, options?) |
FileKind | null |
For bytes you already have |
readHeader(path, options?) |
Uint8Array |
The header itself, if you want to keep it |
neededBytes() |
number |
36,870 — the window that reaches every format |
DEFAULT_MAX_BYTES_READ |
number |
2,048 — what one rule usually declares. Not the same number |
readLimits() |
an object | The smallest window that finds each format |
readLimits() returns a plain frozen object rather than a named type — the
declaration inlines the fields, so there is nothing to import. They are
defaultMaxBytesRead, bytesRead, defaultOffset, isoMaxBytesRead,
tarMaxBytesRead, isoOffsets and tarOffsets. Python's read_limits() returns
a ReadLimits class with the snake_case spelling of the same seven; Rust holds them
as pub const.
import { detectPath, detectBytes, neededBytes } from "@reim-developer/magical-js";
detectPath("photo.jpg"); // "Jpg"
detectBytes(new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])); // "GIF"
detectPath("notes.txt"); // null
neededBytes(); // 36870options is { maxBytesRead?: number } on the detection calls and
{ maxBytes?: number } on readHeader. Naming the window is what tells "no rule
matched" apart from "no rule that fits the window you named matched" — see
Concepts.
A null is one thing here, and it is the same thing it is in the other two: no
rule matched. There is no "unknown format" variant, because all 114 are real
formats and a variant carrying one would be claiming knowledge the table does not
have.
| Call | Returns | Example |
|---|---|---|
displayName(kind) |
string |
"JPEG" |
mime(kind) |
string | null |
"image/jpeg" |
extension(kind) |
string | null |
"jpg" |
Each is a free function rather than a method because a string union cannot carry methods in a way that survives crossing the wasm boundary, and pretending otherwise would mean wrapping every string.
| Call | Returns | Notes |
|---|---|---|
allKinds() |
FileKind[] |
All 114, in the crate's own enum order — not the order detection walks. See Concepts |
isFileKind(value) |
boolean |
A type guard, for a name from a config file or query string |
describe(kind) |
Signature |
One format's entry |
signatureTable() |
Signature[] |
All 114, in detection order. Walking it and stopping at the first match reproduces detectBytes |
Signature.kind |
FileKind |
Narrowed to the literal, from describe("Png") |
Signature.signatures |
Uint8Array[] |
The bytes compared |
Signature.offsets |
number[] |
Where they are compared |
Signature.maxBytesRead |
number |
What this entry declares |
Signature.usesPredicate |
boolean |
Whether this entry reads structure instead of a fixed prefix |
offsets is a list of positions, not a pairing with signatures. Any
signature matching at any of the offsets is enough — the same rule the Python
binding follows, and the same reason a length mismatch between the two is not an
error. usesPredicate is the flag for the entries that cannot be expressed as bytes
at all: ScriptExecute and WEBP are the two, and they are compiled into the
module rather than compared from signatures.
import { describe, isFileKind } from "@reim-developer/magical-js";
const rule = describe("Png");
rule.kind; // "Png", typed as the literal
rule.signatures[0]; // Uint8Array [137, 80, 78, 71, 13, 10, 26, 10]
rule.offsets; // [0]
// A name from somewhere you do not control.
function parse(input: string): FileKind | null {
return isFileKind(input) ? input : null;
}signatureTable() is in detection order — the order detectBytes walks — so
walking it and stopping at the first rule that matches gives the same answer. That
is what makes it worth the order being stated, and it is checked rather than assumed:
test/kinds.test.js tries every (signature, offset) pair of every format and
asserts the walk agrees with detectBytes on all 143 that produce an answer.
Ktx2 is the case that makes the order matter. Its magic is
ab 4b 54 58 20 32 30 bb 0d 0a 1a 0a, and Ktx's is ab 4b 54 58 20 — a
byte-for-byte prefix. Ask Ktx first and every KTX2 file is reported as KTX1:
import { detectBytes, matches, signatureTable } from "@reim-developer/magical-js";
const ktx2 = new Uint8Array([0xAB, 0x4B, 0x54, 0x58, 0x20, 0x32, 0x30, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A]);
matches("Ktx2", ktx2); // true
matches("Ktx", ktx2); // true — its magic is a prefix, so both rules match
detectBytes(ktx2); // "Ktx2"
signatureTable().find((r) => matches(r.kind, ktx2))?.kind; // "Ktx2"Qcow2 over Qcow (51 46 49 fb over 51 46 49) is the other one. These two are
the whole blast radius of the order: every other format has a magic that nothing
shorter also matches.
signatureTable() is not allKinds(). The first is the detection order; the
second is the FileKind declaration order, which is what _kinds.js is generated
as and what describe() is keyed by. Both are 114 long and both are stable, so
using one where the other belongs compiles, runs, and answers a different question.
| Call | Returns | Notes |
|---|---|---|
matches(kind, bytes) |
boolean |
Ignoring table order — the same question as Rust's Detect::is |
detected(src).is(kind) |
boolean |
The same question with the bytes first |
import { matches } from "@reim-developer/magical-js";
matches("Png", bytes); // trueThere is no free isAny, and there is a method. isAny over a kind and some
bytes is a loop over matches, which the language already has, so shipping it as a
function would be one more name to keep in step. On a Detected it is
not a wrapper over anything the caller can see — the bytes are already read and kept,
so it is a loop that does no I/O.
The deliberate part is the name. detected(p).is(k) could have been spelled
matches(k) to pair with the free matches(kind, data), and is not, because one
name for two things with opposite argument orders is a trap rather than a pair.
import { detected } from "@reim-developer/magical-js";
detected(src).is("Png"); // true
detected(src).isAny("Png", "GIF"); // varargs
detected(src).isAny(["Png", "GIF"]); // or one iterable
detected(src).isAny(); // falseA single FileKind is a string, and a string is iterable as characters — so the
one-argument case has to recognise it before treating it as a collection, or
isAny("Png") would test "P", "n" and "g" and find nothing.
The data-first spelling of detectBytes and detectPath, for a scan that asks about
every file in a directory: there the subject reads better first and the answer is
usually no.
import { detected } from "@reim-developer/magical-js";
for (const path of directory) {
if (detected(path).isAny("Png", "GIF", "Jpg")) { ... }
}source is a path, bytes already in memory (Uint8Array, ArrayBuffer or a
Node Buffer, which is a Uint8Array), or anything with a synchronous
read(size) — the third for a caller holding a stream it wants read once.
| Member | Returns | Notes |
|---|---|---|
kind |
FileKind | null |
Same answer as detectBytes, cached |
matched |
boolean |
Whether anything matched. Use this, not truthiness |
is(kind) |
boolean |
One named format, ignoring table order |
isAny(...kinds) |
boolean |
Varargs or one iterable. Empty is false |
within(maxBytesRead) |
this |
Re-classifies in another window. Chains |
window |
number |
The window in force, 36,870 by default |
rule |
Signature | null |
What kind compares |
mime / extension |
string | null |
|
displayName |
string |
"no match" when nothing matched |
Nothing is read until a member needs an answer. Building one is free, and passing it on never touches the disk. After that the bytes are read once, however many members you read off it — which matters for a stream, because a pipe has its bytes once.
matched, not truthiness. A JavaScript object is always truthy, so
if (detected(path)) is always true and there is no hook the language offers to
change it. magical_py writes bool(detected(path)) because Python has __bool__;
this is the one thing it cannot copy, and it is why matched is a member rather than
something to reach for.
There is no toString and no Symbol.toPrimitive. Python's __repr__ reads the
file, which is right — a repr printing the same thing for every input would be the one
thing a repr must not do. A JavaScript toString is not explicit: template literals
and string concatenation call it without the caller asking, so console.log(${d})
would read a file as a side effect of logging it. Print a member instead.
within re-classifies what was read; it cannot reach bytes a first read did not
fetch. Narrowing always works, because what is in hand is a superset. Widening works
too, but only as far as the read went — and for bytes already in memory there is
no such limit, because the whole buffer was in hand before detected was called.
The internal cache is #private, not _underscore. The read-once promise is the
class's central guarantee and a naming convention does not enforce it: a caller who
reached in and set the cached bytes would not get a wrong answer from a member, they
would get a wrong answer instead of one, with nothing to say so.
import { matchTypes, releaseRules } from "@reim-developer/magical-js";
// `const` type parameter: the answer is the union of the kinds you declared.
const rules = [
{ kind: "Png", signatures: [PNG_BYTES], offsets: [0] },
{ kind: "GIF", signatures: [GIF_BYTES], offsets: [0] },
] as const;
const found = matchTypes(rules, bytes); // "Png" | "GIF" | null
if (found === "Jpg") { } // error: "Jpg" is a real format, just not one of these two| Call | Returns | Notes |
|---|---|---|
matchTypes(rules, bytes) |
K | null |
The first rule that matches, typed as the union you declared |
matchAllTypes(rules, bytes) |
K[] |
Every rule that matches |
releaseRules(rules) |
boolean |
Drops the compiled handle. false if there was nothing to release |
The generics here are the point of this binding: the answer is typed as the kinds
you declared, not as all 114. Neither that nor describe("Png").kind narrowing
to a literal is expressible in JSDoc, and neither is worth giving up for a .wasm
that loads with no glue.
A rule set is a readonly array of plain objects, so it can live in a JSON file,
cross a message, or be built at run time. It is compiled into the module's linear
memory on first use.
The compiled copy is leaked once, deliberately, so the MagicCustom values
inside the module can hold a reference to it. releaseRules drops the handle; it
does not return the memory, and the name implies something stronger than it does.
A caller who builds rule sets once at startup never needs it. A caller building them in a loop should call it when done with one, and should understand that the wasm-side allocation is not returned to the allocator — so the honest advice is to build fewer, larger rule sets rather than many small ones.
index.d.ts is hand-written and is where the generics live. The package ships no
.d.ts generated from JSDoc, because the parts worth typing cannot be expressed in
JSDoc at all.
Everything is synchronous, deliberately. The detection calls return their answer
rather than a Promise. Making them async would buy nothing but an await, and
the alternative — a real async API — means crossing a .wasm boundary per call,
which is the one cost this binding's single-threaded synchronous design avoids.
The kind is a string, not an enum. See the top of this page for why, and Across the three languages for what that costs a caller porting from the other two.