We have spent the whole module surrounded by Buffer objects without looking at them head on. When we wrote fs.readFile(EVENTS_FILE, 'utf8') we asked for a translation; when we left it out — copying a poster, compressing the history with zlib — what traveled through the code was a pure Buffer. The chunks of a binary stream are Buffer objects. The response of an HTTP request in Module 4 will arrive as a Buffer. A hash in Module 8 is a Buffer.
This lesson opens the box: what exactly a Buffer is and why Node had to invent it, how to create one without leaving security holes, what each encoding means, how binary numbers are read and why the order of their bytes matters. You will also see the two classic mistakes of treating bytes as if they were characters: a subarray that shares memory with the original, and an emoji cut in half between two chunks of a stream. And we will apply it to Escena Viva: checking that the poster an organizer uploads really is a PNG — by looking at its first bytes, not its extension — and generating the payload of a ticket's QR code in base64url, signed and comparable in constant time.
Contents
- What a
Bufferis and why it exists - Creating buffers:
from,allocand the danger ofallocUnsafe - Encodings and what each one is for
- Binary numbers and byte order
- Operations:
subarray,concat,copy,compare,indexOf - Bytes versus characters: the deceptive length
StringDecoder: never split a character in two- Escena Viva: validating the poster by its magic numbers
- Escena Viva: a ticket's QR in
base64url - Comparing secrets in constant time
Buffer,TypedArrayandArrayBuffer
- What a
Buffer is and why it exists
Buffer is and why it existsJavaScript was born in the browser to manipulate text and documents. Until 2011 it had no type capable of representing binary data: only UTF-16 strings, floating point numbers and objects. Node, on the other hand, was born to read files and talk over sockets — that is, to move bytes. It needed a type the language did not give it, so it created one: Buffer.
A Buffer is a fixed-length sequence of bytes: each position holds an integer between 0 and 255, and it can neither grow nor shrink — for a bigger one you create another and copy. Two traits set it apart from a normal array:
- It is a
Uint8Array. Literally:Buffer.prototypeinherits fromUint8Array.prototype. Everything that works on aUint8Arrayworks on aBuffer, plus the methods Node adds on top (toString,write,readUInt32BE…). - Its memory lives outside the V8 heap. The
Bufferobject you manipulate is on the heap, but the bytes sit in separately reserved memory. That lets the operating system write straight into it without copying, and lets a process move hundreds of megabytes without pressuring the garbage collector. It is also the reason why in lesson 03-04heapUsedbarely moved whilerssdid grow.
const buf = Buffer.from('Teatro Almendra', 'utf8');
console.log(buf instanceof Uint8Array, buf.length, buf[0]); // true 15 84 <-- bytes, not characters
console.log(buf.toString('hex')); // 5465617472...
console.log(buf); // <Buffer 54 65 61 74 72 6f 20 ...>Notice how the console prints it: <Buffer ...> followed by the bytes in hexadecimal. If you see that in your logs where you expected text, the cause is almost always the same: you forgot the encoding when reading.
- Creating buffers:
from, alloc and the danger of allocUnsafe
from, alloc and the danger of allocUnsafe| Form | What it does | When to use it |
|---|---|---|
Buffer.from(string, enc) |
Encodes the text into bytes | Converting text to binary |
Buffer.from([1, 2, 3]) |
One byte per element (& 255) |
Signatures and literal data |
Buffer.from(otherBuffer) |
Copies the content | Isolating an independent copy |
Buffer.from(arrayBuffer) |
Shares memory, does not copy | Interoperating with TypedArray |
Buffer.alloc(n) |
n bytes set to zero |
By default, always |
Buffer.allocUnsafe(n) |
n uninitialized bytes |
Only if you will overwrite it entirely |
Buffer.concat([a, b]) |
Joins several into a new one | Rebuilding an accumulated stream |
Watch the third and fourth rows, which look interchangeable and are not: Buffer.from(otherBuffer) copies, while Buffer.from(arrayBuffer) shares memory, so modifying one would affect the other only in the second case.
The name allocUnsafe is not documentation hyperbole. Buffer.alloc(n) walks the n bytes setting them to zero; allocUnsafe(n) hands them over exactly as they sat in the reserved memory, which may contain leftovers of the same process's earlier data: fragments of a JSON file read a second ago, pieces of a password, half an HTTP header.
Buffer.alloc(64).fill('session-token-abc123'); // we dirty memory and release it
console.log(Buffer.alloc(64).toString('hex').slice(0, 24)); // 000000000000000000000000
console.log(Buffer.allocUnsafe(64).toString('latin1').slice(0, 40)); // garbage, sometimes recognizableIn 2018 this difference produced a whole family of information leaks in npm packages that reserved a buffer with allocUnsafe and sent it over the network without filling it completely: the spare bytes traveled with whatever had been there before. The practical rule is simple: always use Buffer.alloc; allocUnsafe only when the next line will overwrite the entire buffer and the performance gain has been measured and justified.
- Encodings and what each one is for
An encoding is a translation contract between bytes and characters. Node supports these:
| Encoding | Bytes per character | Output alphabet | Typical use |
|---|---|---|---|
utf8 |
1 to 4 | All of Unicode | The default for text |
utf16le |
2 or 4 | All of Unicode | Interoperating with Windows/native APIs |
latin1 |
Always 1 | 0–255 | Binary headers, byte↔character 1:1 |
ascii |
1 (drops the high bit) | 0–127 | Almost never: it corrupts accents |
hex |
2 characters per byte | 0-9a-f |
Hashes, dumps, debugging |
base64 |
~1.33 characters per byte | A-Za-z0-9+/= |
Binary inside JSON or email |
base64url |
~1.33 characters per byte | A-Za-z0-9-_ |
Binary inside a URL (no padding) |
const text = 'Session at Teatro Almendra';
const buf = Buffer.from(text, 'utf8');
console.log(buf.toString('base64')); // U2Vzc2lvbiBhdCBUZWF0cm8gQWxtZW5kcmE=
console.log(buf.toString('base64url')); // U2Vzc2lvbiBhdCBUZWF0cm8gQWxtZW5kcmE
console.log(buf.toString('hex').slice(0, 8)); // 53657373Two important clarifications:
base64is not encryption. It is a reversible representation with no secret whatsoever. It exists to put bytes where only printable characters fit, not to protect anything.base64urlis the URL-safe variant: it replaces+with-,/with_and drops the=padding. It is exactly what the JWTs of Module 8 use and what we will use for the ticket QR codes, because a+inside a URL is read as a space and a/splits the path.- An unknown encoding does not fail silently:
Buffer.from(x, 'utf-9')throwsTypeError: Unknown encoding, and you can check the list withBuffer.isEncoding('base64url').
- Binary numbers and byte order
A binary format does not store "1189" as text: it stores the number in a fixed number of bytes. Reading it requires knowing how many bytes it takes, whether it is signed and in what order its bytes sit. That last point is the byte order, or endianness:
- BE (big endian): the most significant byte first. It is the order of networks and of most file formats (PNG, JPEG).
- LE (little endian): the least significant first. It is the native order of the usual x86 and ARM processors.
const buf = Buffer.alloc(4);
buf.writeUInt32BE(1189, 0); // the available tickets in the seed
console.log(buf); // <Buffer 00 00 04 a5>
console.log(buf.readUInt32BE(0)); // 1189
console.log(buf.readUInt32LE(0)); // 2768994304 <-- same bytes, different order
console.log(require('node:os').endianness()); // 'LE' on most machinesThe same four bytes are worth 1189 or 2768994304 depending on how you read them. That is why the order is not guessed: the format fixes it and you have to read its specification. The methods all follow the same pattern: read/write + U if unsigned + Int + size in bits + BE/LE. For 64 bits there are readBigUInt64BE and friends, which return a BigInt. Writing an out-of-range value or at an offset beyond the buffer throws ERR_OUT_OF_RANGE: it is a loud error, not silent corruption.
- Operations:
subarray, concat, copy, compare, indexOf
subarray, concat, copy, compare, indexOf| Operation | What it does | Trap |
|---|---|---|
buf.subarray(start, end) |
A view onto the same buffer | Shares memory |
buf.slice(start, end) |
Alias of subarray (deprecated) |
It does not copy, unlike on arrays |
Buffer.concat([a, b], n) |
New buffer holding everything | It copies: it costs memory |
source.copy(target, tOff) |
Copies bytes into another buffer | The target must have room |
a.equals(b) / a.compare(b) |
Equality / ordering | compare returns -1, 0 or 1 |
buf.indexOf('EV-') |
Searches for bytes or text | Returns a position in bytes |
buf.fill(value) |
Fills the whole buffer | Modifies in place |
The classic mistake is in the first row. On an array, slice returns a copy; on a Buffer, slice and subarray return a window onto the same memory:
const original = Buffer.from('EV-2026-000123', 'utf8');
const year = original.subarray(3, 7); // 2026
year.write('1999'); // looks harmless...
console.log(original.toString()); // EV-1999-000123 <-- the original has changedIf you need a real copy, ask for it explicitly: Buffer.from(original.subarray(3, 7)) or Buffer.copyBytesFrom(...). This detail is the cause of a very hard bug to find: storing chunks of a stream with chunks.push(data) without copying them, when the stream reuses the same internal buffer between reads; the accumulated chunks all end up holding the same thing, the last one. As for concat, it is the correct way of rebuilding accumulated content — Buffer.concat(chunks) after a for await over a stream — and it accepts a third argument with the total length, which saves recomputing it if you already know it. That pattern is exactly what readFile does internally, with the same memory consequence we measured in 03-04: use it only when you know the content is small.
- Bytes versus characters: the deceptive length
String.length counts UTF-16 units. Buffer.length counts bytes. And Buffer.byteLength(string) tells you how many bytes a text will take before converting it. The three numbers can all differ:
for (const text of ['Almendra', 'Boveda', 'Bóveda', 'Monólogos', '🎭']) {
console.log(text.padEnd(10),
`String.length=${text.length}`,
`bytes=${Buffer.byteLength(text, 'utf8')}`,
`characters=${[...text].length}`);
}Almendra String.length=8 bytes=8 characters=8 Boveda String.length=6 bytes=6 characters=6 Bóveda String.length=6 bytes=7 characters=6 Monólogos String.length=9 bytes=10 characters=9 🎭 String.length=2 bytes=4 characters=1
Three lessons from this table. First: an ó takes two bytes in UTF-8, so reserving a buffer with Buffer.alloc(text.length) to hold accented text truncates it. Second: an emoji takes two units in String.length and one when iterated with [...text], because JavaScript represents characters outside the basic plane as a pair of surrogate values. Third, and the one that really breaks programs: cutting a buffer at an arbitrary position can split a character in two.
const venue = Buffer.from('Sala Bóveda', 'utf8'); // 12 bytes
const part1 = venue.subarray(0, 7); // cuts inside the 'ó'
const part2 = venue.subarray(7);
console.log(part1.toString('utf8')); // Sala B� <-- replacement character
console.log(part1.toString() + part2.toString()); // Sala B��veda <-- unrecoverable
console.log(Buffer.concat([part1, part2]).toString()); // Sala Bóveda <-- correctConverting each chunk to text separately destroys the split character: the � (U+FFFD) can no longer be undone. Joining the bytes first and decoding afterwards works, but it requires having all the content in memory, which is exactly what a stream avoids.
StringDecoder: never split a character in two
StringDecoder: never split a character in twoThe previous problem is not theoretical: it is exactly what happens when a stream delivers 64 KB chunks and character number 65,536 falls astride two of them. The solution is in node:string_decoder, a stateful decoder that holds back the incomplete bytes at the end of a chunk until the missing ones arrive.
const { StringDecoder } = require('node:string_decoder');
const decoder = new StringDecoder('utf8');
const venue = Buffer.from('Sala Bóveda', 'utf8');
console.log(JSON.stringify(decoder.write(venue.subarray(0, 7)))); // "Sala B"
console.log(JSON.stringify(decoder.write(venue.subarray(7)))); // "óveda"
console.log(JSON.stringify(decoder.end())); // ""The first write returns "Sala B" without the ó: the decoder saw the character's first byte and kept it. The second write completes it and emits óveda. The final end() returns whatever was left pending — if the flow ends with an incomplete character, that is where the � appears, a sign that the input was truncated. When you pass { encoding: 'utf8' } to createReadStream or call setEncoding('utf8') on a stream, Node uses a StringDecoder internally, and that is why the chunks already arrive as correct text. You only need to use it by hand when working with raw Buffer objects: when decrypting, when decompressing chunk by chunk or when implementing a protocol of your own.
- Escena Viva: validating the poster by its magic numbers
Organizers upload the poster for their event. The file extension proves nothing: renaming virus.exe to poster.png takes a second. What does identify a format is its first bytes, the so-called signature or magic number.
| Format | Initial bytes (hex) | Interpretation |
|---|---|---|
| PNG | 89 50 4E 47 0D 0A 1A 0A |
.PNG + control breaks |
| JPEG | FF D8 FF |
Start of image marker |
25 50 44 46 2D |
%PDF- |
|
| WEBP | 52 49 46 46 … 57 45 42 50 |
RIFF + WEBP at byte 8 |
| GZIP | 1F 8B |
The .gz we generated in 03-05 |
We put the FileHandle API of lesson 03-02 to work to read only the first bytes, instead of loading a ten-megabyte image to look at eight:
// src/utils/file-type.js
// Detects a file's real format by its first bytes (magic number),
// never by its extension: the extension is chosen by whoever uploads the file.
const fs = require('node:fs/promises');
const HEADER_BYTES = 16;
const SIGNATURES = [
{ type: 'png', extension: '.png', offset: 0, signature: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) },
{ type: 'jpeg', extension: '.jpg', offset: 0, signature: Buffer.from([0xff, 0xd8, 0xff]) },
{ type: 'pdf', extension: '.pdf', offset: 0, signature: Buffer.from('%PDF-', 'latin1') },
{ type: 'gzip', extension: '.gz', offset: 0, signature: Buffer.from([0x1f, 0x8b]) },
{ type: 'webp', extension: '.webp', offset: 8, signature: Buffer.from('WEBP', 'latin1') }
];
// Reads the first bytes of the file and returns only the ones that really exist.
async function readHeader(file, bytes = HEADER_BYTES) {
const handle = await fs.open(file, 'r');
try {
const target = Buffer.alloc(bytes);
const { bytesRead } = await handle.read(target, 0, bytes, 0);
return Buffer.from(target.subarray(0, bytesRead));
} finally {
await handle.close();
}
}
function detectType(header) {
for (const { type, extension, offset, signature } of SIGNATURES) {
if (header.subarray(offset, offset + signature.length).equals(signature)) {
return { type, extension };
}
}
return null;
}
async function validatePoster(file, acceptedTypes = ['png', 'jpeg']) {
const header = await readHeader(file);
const detected = detectType(header);
if (detected === null || !acceptedTypes.includes(detected.type)) {
const error = new Error(`The poster ${file} is not a ${acceptedTypes.join(' or ')} file`);
error.appCode = 'UNSUPPORTED_FORMAT';
error.header = header.subarray(0, 8).toString('hex');
throw error;
}
return detected;
}
module.exports = { readHeader, detectType, validatePoster, SIGNATURES, HEADER_BYTES };Four decisions worth commenting on: Buffer.alloc and not allocUnsafe, because if the file is under 16 bytes the spare ones would be memory garbage compared against real signatures; Buffer.from(target.subarray(...)), which returns an independent copy instead of a view with access to the spare bytes; equals instead of toString, because comparing bytes with bytes removes any encoding doubt — 0x89 is not valid UTF-8 text; and error.appCode following the course's domain convention, with the header in hex attached to diagnose what the organizer actually uploaded.
node -e "require('./src/utils/file-type.js').validatePoster('data/events.json').catch((e) => console.error(e.appCode, e.header))"
# UNSUPPORTED_FORMAT 7b0a20202265 <-- 0x7b is '{': a JSON file dressed as a posterThe check works. Mind the scope of this technique: the signature proves the format, not that the content is harmless. In Module 4, when receiving real uploads, we will combine it with a size limit and with the hardened path of lesson 03-03.
- Escena Viva: a ticket's QR in
base64url
base64urlEvery Escena Viva ticket carries a code such as EV-2026-000123 and a QR the usher scans at the door. The QR holds a URL, and inside that URL travels a payload: the ticket data plus a signature that prevents forgery. That content is binary and has to fit in a URL with no escaping: the exact use case for base64url.
// src/utils/ticket-qr.js
// Payload of a ticket's QR code: body in base64url and an HMAC signature.
// The format is <body>.<signature>, the same scheme we will see with JWT.
const crypto = require('node:crypto');
const SECRET = process.env.ESCENA_VIVA_SECRET ?? 'development-secret';
const sign = (body) => crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
function fail(code, message) {
const error = new Error(message);
error.appCode = code;
throw error;
}
function encodeQr({ ticketCode, sessionId, issuedAt }) {
const payload = JSON.stringify({ ticketCode, sessionId, issuedAt });
const body = Buffer.from(payload, 'utf8').toString('base64url');
return `${body}.${sign(body)}`;
}
function decodeQr(text) {
const [body, signature] = String(text).split('.');
if (body === undefined || signature === undefined) fail('MALFORMED_QR', 'The QR does not follow the <body>.<signature> format');
if (!areEqual(signature, sign(body))) fail('INVALID_QR_SIGNATURE', 'The QR code signature is not valid');
return JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
}
module.exports = { encodeQr, decodeQr };const qr = encodeQr({ ticketCode: 'EV-2026-000123', sessionId: 'ses-001-1', issuedAt: '2026-08-14T19:30:00' });
console.log(qr); // eyJ0aWNrZXRDb2RlIjoiRVYtMjAyNi0wMDAxMjMiLCJzZXNzaW9uSWQiOi...Dw8
console.log(decodeQr(qr).sessionId); // ses-001-1
decodeQr(qr.replace(/.$/, 'X')); // throws INVALID_QR_SIGNATUREareEqual is the safe comparison function of the next section, which lives in this same module. The body is not encrypted — anyone can decode it with Buffer.from(body, 'base64url').toString() — and that is acceptable: there is nothing secret in a ticket's QR. What the signature protects is integrity: without knowing the secret nobody can forge a valid ticket or change its session. Comparing that signature, however, cannot be done with ===, and that is the last technical section of the lesson.
- Comparing secrets in constant time
a === b on strings compares character by character and stops at the first one that differs. That optimization, harmless in any other context, leaks information when what is compared is a secret: an attacker measuring response time can deduce how many leading characters they got right and rebuild the signature byte by byte.
crypto.timingSafeEqual always compares every byte, however long it takes to find the first difference. It has one requirement: both buffers must be exactly the same length, or it throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH, and you do not control the length of someone else's data. The standard solution is to normalize the length with a hash before comparing:
// Add to src/utils/ticket-qr.js
function areEqual(a, b) {
// The hash equalizes the lengths (always 32 bytes) without leaking information.
const digestA = crypto.createHash('sha256').update(String(a)).digest();
const digestB = crypto.createHash('sha256').update(String(b)).digest();
return crypto.timingSafeEqual(digestA, digestB);
}digest() with no argument returns a Buffer — with 'hex' it would return a string — and two SHA-256 values are 32 bytes each no matter what. We will pick this function up untouched in Module 8, where the same reasoning applies to API keys and session tokens.
Buffer, TypedArray and ArrayBuffer
Buffer, TypedArray and ArrayBufferThe three pieces fit together like this: an ArrayBuffer is a raw block of memory that cannot be read or written directly; a TypedArray (Uint8Array, Int16Array, Float64Array…) is a view that interprets that block as numbers of a certain type, and several views can look at the same block; a Buffer is a Uint8Array with methods added by Node.
const buf = Buffer.from('Ribera', 'utf8');
console.log(buf.byteOffset, buf.byteLength); // e.g. 88 6 <-- mind the offset
console.log(new Uint8Array(buf.buffer).length); // 8192 <-- NOT 6There lies the subtlest trap in the module. Node allocates small buffers (under 4 KB) inside a shared 8 KB pool, so buf.buffer is not your buffer's memory: it is the whole pool's, and buf.byteOffset says where your slice starts. Passing buf.buffer to an API expecting the exact data hands over 8 KB of somebody else's memory. The correct way to convert:
const view = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); // shares memory
const back = Buffer.from(view.buffer, view.byteOffset, view.byteLength); // and the way backIn practice this shows up when interoperating with web standard APIs inside Node — fetch, crypto.subtle, WebSocket, the worker threads of Module 10 — which speak Uint8Array and ArrayBuffer, not Buffer.
Common Mistakes and Tips
- Seeing
<Buffer 7b 0a ...>where you expected text. The encoding is missing:readFile(file, 'utf8')or.toString('utf8'). Never useString(buf)hoping it will do the right thing with binary data. - Using
allocUnsafeout of habit. If you do not fill the whole buffer, you publish the process's old memory.allocby default, always. - Believing
slicecopies. It shares memory, the opposite of arrays. Copy explicitly withBuffer.from(view)if the original is going to change or be reused. - Allocating with
text.lengthinstead ofBuffer.byteLength(text). A single accent already leaves you a byte short and the text comes out truncated. - Concatenating chunks as strings. A
chunk.toString()per stream chunk splits multibyte characters: accumulateBufferobjects and decode at the end, or useStringDecoder. - Comparing signatures with
===. It leaks information through timing.timingSafeEqualover buffers of equal length, normalized with a hash. - Tip: work with
Bufferobjects until the last possible moment and convert to text once, at your system's boundary. Every round trip costs CPU and is an opportunity to corrupt data.
Exercises
Exercise 1: poster inventory
Write src/lab/poster-inventory.js that walks a directory (by default data/posters/) and, for each file, prints in a table its name, its declared extension, the real type detected with detectType and whether they match. It must clearly flag the files whose extension lies. Use readdir with withFileTypes from lesson 03-02 and the paths from src/config/paths.js.
Exercise 2: safe text splitter
Write a function splitText(text, maxBytes) that returns an array of Buffer objects of at most maxBytes bytes each without splitting any character. Verify with 'Noche de Monólogos 🎭 en la Sala Bóveda' and maxBytes = 10 that concatenating the chunks and decoding gives back the original text and that no chunk produces a � on its own.
Exercise 3: a binary header for the report
Design a minimal binary format for the daily occupancy report: 4 bytes of signature 'EVIV', 1 version byte, 2 big endian bytes with the number of sessions, 4 big endian bytes with the tickets sold and the rest as UTF-8 JSON. Write writeBinaryReport(file, report) and readBinaryReport(file), and check that the full round trip returns the original data and that a file with the wrong signature throws an error with appCode: 'UNSUPPORTED_FORMAT'.
Solutions
Solution 1. The comparison between declared extension and real type is the core:
const { readdir } = require('node:fs/promises');
const path = require('node:path');
const { readHeader, detectType } = require('../utils/file-type.js');
async function inventory(directory) {
const rows = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (!entry.isFile()) continue;
const detected = detectType(await readHeader(path.join(directory, entry.name)));
const declared = path.extname(entry.name).toLowerCase();
const normalized = declared === '.jpeg' ? '.jpg' : declared; // .jpeg and .jpg are both legitimate
rows.push({ file: entry.name, declared, actual: detected?.type ?? 'unknown', matches: detected?.extension === normalized });
}
console.table(rows);
return rows;
}The .jpeg/.jpg case is a reminder that a format can have several legitimate extensions: the check must not raise false alarms. An unknown is not always an attack — it may be a format missing from SIGNATURES — but it is reason enough not to accept it.
Solution 2. The key is not to cut blindly, but to step back to the start of a character. In UTF-8, continuation bytes have the shape 10xxxxxx, that is, (byte & 0xc0) === 0x80:
function splitText(text, maxBytes) {
const whole = Buffer.from(text, 'utf8');
const chunks = [];
for (let start = 0; start < whole.length; ) {
let end = Math.min(start + maxBytes, whole.length);
// We step back while 'end' lands on a continuation byte.
while (end > start + 1 && (whole[end] & 0xc0) === 0x80) end -= 1;
chunks.push(Buffer.from(whole.subarray(start, end)));
start = end;
}
return chunks;
}
console.log(splitText('Noche de Monólogos 🎭 en la Sala Bóveda', 10).map((c) => c.toString('utf8')));The Buffer.from(...) around the subarray is essential: without it, the chunks would be views onto the same buffer and any later modification would affect them all. A shorter alternative if you only want text: walk [...text] accumulating by the Buffer.byteLength of each character.
Solution 3. The format is written and read in the same order in which it is defined:
const SIGNATURE = Buffer.from('EVIV', 'latin1');
const VERSION = 1;
function serialize(report) {
const header = Buffer.alloc(11);
SIGNATURE.copy(header, 0);
header.writeUInt8(VERSION, 4);
header.writeUInt16BE(report.sessions.length, 5);
header.writeUInt32BE(report.ticketsSold, 7);
return Buffer.concat([header, Buffer.from(JSON.stringify(report), 'utf8')]);
}
function deserialize(binary) {
if (!binary.subarray(0, 4).equals(SIGNATURE)) throw Object.assign(new Error('Not a report'), { appCode: 'UNSUPPORTED_FORMAT' });
return {
version: binary.readUInt8(4),
sessions: binary.readUInt16BE(5),
ticketsSold: binary.readUInt32BE(7),
details: JSON.parse(binary.subarray(11).toString('utf8'))
};
}With the seed data, readUInt32BE(7) returns 1811 and readUInt16BE(5) returns 7. Notice the version byte: it is what will let you read old files when the format changes, and leaving it out is the most frequent mistake when designing a binary format of your own. A writeUInt16BE with more than 65,535 sessions would throw ERR_OUT_OF_RANGE, a reminder that every binary field has a ceiling that must be chosen deliberately.
Conclusion
You now know what is inside the box. A Buffer is a Uint8Array over memory outside the V8 heap, of fixed length, which exists because JavaScript had no binary type when Node needed one. You create it with Buffer.from — copying from text, an array or another buffer, sharing from an ArrayBuffer — or with Buffer.alloc, never with allocUnsafe unless you are going to overwrite it entirely, because its initial content is memory the process itself has used.
You know the encodings and how the roles are shared out: utf8 for text, latin1 to treat bytes as characters one to one, hex for debugging and hashes, base64 to put binary inside JSON and base64url to put it inside a URL. You can read and write integers with readUInt32BE and friends, and you know the byte order is fixed by the format, not by your machine. You have mastered the operations and their traps: subarray shares memory — the classic mistake —, concat copies, equals compares with no encoding ambiguity. And you are clear on why String.length, Buffer.byteLength and [...text].length give three different numbers, why cutting a buffer at an arbitrary position produces an unrecoverable � and how StringDecoder avoids it by holding the incomplete bytes between one chunk and the next.
Escena Viva takes away two new modules: src/utils/file-type.js, which validates an event's poster by its magic number reading only sixteen bytes with FileHandle, and src/utils/ticket-qr.js, which encodes the QR payload in base64url with an HMAC signature compared in constant time with timingSafeEqual over equal-length digests. And you know that buf.buffer is not your buffer but the 8 KB pool it lives in: byteOffset and byteLength are not optional. With this you close Module 3. Escena Viva has gone from having its data inlined in the code to reading its catalog from disk asynchronously, organizing reports by month, resolving paths that work from any directory and on any system, processing a sales history with constant memory through pipeline pipes, and understanding the bytes flowing through all of the above. The project can read and write; what it still cannot do is talk to anyone. That starts in Module 4: HTTP, and you will see right away that it is not new territory: a Node HTTP server is an EventEmitter that emits request; the request you receive is a readable stream and the response you send back is a writable stream; the JSON body of a POST arrives in Buffer chunks that must be accumulated carefully; and serving a static file is exactly createReadStream plus the hardened path of lesson 03-03. Everything from this module comes back, this time wired to the network.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
