Node.js

Um estudante perguntou: “Os programadores de antigamente usavam apenas máquinas simples e nenhuma linguagem de programação, ainda assim criavam programas belíssimos. Por que usamos máquinas e linguagens de programação complicadas?” Fu-Tzu respondeu: “Os construtores de antigamente usavam apenas gravetos e argila, ainda assim faziam belas cabanas.”

Master Yuan-Ma, The Book of Programming
Ilustração mostrando um poste telefônico com um emaranhado de fios indo em todas as direções

Até agora, usamos a linguagem JavaScript em um único ambiente: o navegador. Este capítulo e o próximo vão introduzir brevemente o Node.js, um programa que permite aplicar suas habilidades em JavaScript fora do navegador. Com ele, você pode construir desde pequenas ferramentas de linha de comando até servidores HTTP que alimentam sites dinâmicos.

Esses capítulos têm como objetivo ensinar os principais conceitos usados pelo Node.js e dar informações suficientes para escrever programas úteis para ele. Eles não tentam ser um tratamento completo, nem mesmo aprofundado, da plataforma.

Enquanto você podia executar o código dos capítulos anteriores diretamente nestas páginas, porque era JavaScript puro ou voltado para o navegador, os exemplos deste capítulo são escritos para Node e frequentemente não vão funcionar no navegador.

Se quiser acompanhar e executar o código deste capítulo, você precisará instalar o Node.js versão 18 ou superior. Para isso, acesse https://nodejs.org e siga as instruções de instalação para o seu sistema operacional. Você também pode encontrar mais documentação sobre Node.js lá.

Contexto

Ao construir sistemas que se comunicam pela rede, a forma como você gerencia entrada e saída—isto é, a leitura e escrita de dados de e para a rede e o disco rígido—pode fazer uma grande diferença na rapidez com que um sistema responde ao usuário ou a requisições de rede.

Nesses programas, a programação assíncrona costuma ser útil. Ela permite que o programa envie e receba dados de múltiplos dispositivos ao mesmo tempo, sem gerenciamento complicado de threads e sincronização.

O Node foi inicialmente concebido com o objetivo de tornar a programação assíncrona fácil e conveniente. JavaScript se adapta bem a um sistema como o Node. É uma das poucas linguagens de programação que não possui uma forma embutida de fazer entrada e saída. Assim, JavaScript pôde se encaixar na abordagem um tanto excêntrica do Node para programação de rede e de sistema de arquivos sem acabar com duas interfaces inconsistentes. Em 2009, quando o Node estava sendo projetado, as pessoas já faziam programação baseada em callback no navegador, então a comunidade da linguagem já estava acostumada com um estilo assíncrono.

O comando node

Quando o Node.js é instalado em um sistema, ele fornece um programa chamado node, que é usado para executar arquivos JavaScript. Suponha que você tenha um arquivo hello.js, contendo este código:

let message = "Hello world";
console.log(message);

Você pode então executar node a partir da linha de comando assim para rodar o programa:

$ node hello.js
Hello world

O método console.log no Node faz algo semelhante ao que faz no navegador. Ele imprime um texto. Mas no Node, o texto vai para o fluxo de saída padrão do processo, em vez do console JavaScript do navegador. Ao executar node pela linha de comando, isso significa que você vê os valores registrados no seu terminal.

Se você executar node sem fornecer um arquivo, ele oferece um prompt onde você pode digitar código JavaScript e ver imediatamente o resultado.

$ node
> 1 + 1
2
> [-1, -2, -3].map(Math.abs)
[1, 2, 3]
> process.exit(0)
$

A ligação process, assim como o console, está disponível globalmente no Node. Ele fornece várias formas de inspecionar e manipular o programa atual. O método exit encerra o processo e pode receber um código de status de saída, que informa ao programa que iniciou o node (neste caso, o shell da linha de comando) se o programa terminou com sucesso (código zero) ou encontrou um erro (qualquer outro código).

Para encontrar os argumentos de linha de comando fornecidos ao seu script, você pode ler process.argv, que é um array de strings. Note que ele também inclui o nome do comando node e o nome do seu script, então os argumentos reais começam no índice 2. Se showargv.js contiver a instrução console.log(process.argv), você pode executá-lo assim:

$ node showargv.js one --and two
["node", "/tmp/showargv.js", "one", "--and", "two"]

Todas as ligações globais padrão do JavaScript, como Array, Math e JSON, também estão presentes no ambiente do Node. Funcionalidades relacionadas ao navegador, como document ou prompt, não estão.

Módulos

Além das ligações mecionadas, como console e process, o Node coloca poucas ligações adicionais no escopo global. Se você quiser acessar funcionalidades embutidas, precisa solicitá-las ao sistema de módulos.

O Node começou usando o sistema de módulos CommonJS, baseado na função require, que vimos no Capítulo 10. Ele ainda usa esse sistema por padrão ao carregar um arquivo .js.

Mas hoje o Node também suporta o sistema de módulos ES mais moderno. Quando o nome de um script termina em .mjs, ele é considerado desse tipo, e você pode usar import e export nele (mas não require). Usaremos módulos ES neste capítulo.

Ao importar um módulo—seja com require ou import—o Node precisa resolver a string fornecida para um arquivo real que ele possa carregar. Nomes que começam com /, ./ ou ../ são resolvidos como arquivos, relativos ao caminho do módulo atual. Aqui, . representa o diretório atual, ../ um diretório acima, e / a raiz do sistema de arquivos. Se você pedir "./graph.mjs" a partir do arquivo /tmp/robot/robot.mjs, o Node tentará carregar o arquivo /tmp/robot/graph.mjs.

Quando uma string que não parece um caminho relativo ou absoluto é importada, presume-se que ela se refere a um módulo embutido ou a um módulo instalado em um diretório node_modules. Por exemplo, importar "node:fs" lhe dará o módulo de sistema de arquivos embutido do Node. Importar "robot" pode tentar carregar a biblioteca encontrada em node_modules/robot/. É comum instalar essas bibliotecas usando NPM, ao qual voltaremos em breve.

Vamos montar um pequeno projeto com dois arquivos. O primeiro, chamado main.mjs, define um script que pode ser chamado pela linha de comando para inverter uma string.

import {reverse} from "./reverse.mjs";

// O índice 2 contém o primeiro argumento real da linha de comando
let argument = process.argv[2];

console.log(reverse(argument));

O arquivo reverse.mjs define uma biblioteca para inverter strings, que pode ser usada tanto por essa ferramenta de linha de comando quanto por outros scripts que precisem de acesso direto a uma função de inversão de string.

export function reverse(string) {
  return Array.from(string).reverse().join("");
}

Lembre-se de que export é usado para declarar que uma ligação faz parte da interface do módulo. Isso permite que main.mjs importe e use a função.

Agora podemos chamar nossa ferramenta assim:

$ node main.mjs JavaScript
tpircSavaJ

Instalando com NPM

O NPM, apresentado no Capítulo 10, é um repositório online de módulos JavaScript, muitos dos quais são escritos especificamente para Node. Quando você instala o Node no seu computador, também recebe o comando npm, que pode ser usado para interagir com esse repositório.

O principal uso do NPM é baixar pacotes. Vimos o pacote ini no Capítulo 10. Podemos usar o NPM para buscar e instalar esse pacote no nosso computador.

$ npm install ini
added 1 package in 723ms

$ node
> const {parse} = require("ini");
> parse("x = 1\ny = 2");
{ x: '1', y: '2' }

Após executar npm install, o NPM terá criado um diretório chamado node_modules. Dentro dele haverá um diretório ini que contém a biblioteca. Você pode abri-lo e ver o código. Quando importamos "ini", essa biblioteca é carregada, e podemos chamar sua propriedade parse para interpretar um arquivo de configuração.

Por padrão, o NPM instala pacotes no diretório atual em vez de um local central. Se você está acostumado a outros gerenciadores de pacotes, isso pode parecer incomum, mas tem vantagens—coloca cada aplicação no controle total dos pacotes que instala e facilita gerenciar versões e limpar tudo ao remover uma aplicação.

Arquivos de pacote

Após executar npm install para instalar algum pacote, você encontrará não apenas um diretório node_modules, mas também um arquivo chamado package.json no diretório atual. Recomenda-se ter esse arquivo para cada projeto. Você pode criá-lo manualmente ou executar npm init. Esse arquivo contém informações sobre o projeto, como nome e versão, e lista suas dependências.

A simulação do robô do Capítulo 7, modularizada no exercício do Capítulo 10, pode ter um arquivo package.json assim:

{
  "author": "Marijn Haverbeke",
  "name": "eloquent-javascript-robot",
  "description": "Simulation of a package-delivery robot",
  "version": "1.0.0",
  "main": "run.mjs",
  "dependencies": {
    "dijkstrajs": "^1.0.1",
    "random-item": "^1.0.0"
  },
  "license": "ISC"
}

Quando você executa npm install sem especificar um pacote, o NPM instala as dependências listadas em package.json. Quando você instala um pacote específico que ainda não está listado como dependência, o NPM o adiciona ao package.json.

Versões

Um arquivo package.json lista tanto a versão do próprio programa quanto as versões de suas dependências. Versões são uma forma de lidar com o fato de que pacotes evoluem separadamente, e código escrito para funcionar com um pacote em determinado momento pode não funcionar com uma versão posterior modificada.

O NPM exige que seus pacotes sigam um esquema chamado versionamento semântico, que codifica informações sobre quais versões são compatíveis (não quebram a interface antiga) no número da versão. Uma versão semântica consiste em três números separados por pontos, como 2.3.0. Sempre que nova funcionalidade é adicionada, o número do meio deve ser incrementado. Sempre que a compatibilidade é quebrada, de modo que código existente possa não funcionar com a nova versão, o primeiro número deve ser incrementado.

Um caractere de acento circunflexo (^) antes do número de versão de uma dependência em package.json indica que qualquer versão compatível com aquele número pode ser instalada. Por exemplo, "^2.3.0" significa que qualquer versão maior ou igual a 2.3.0 e menor que 3.0.0 é permitida.

O comando npm também é usado para publicar novos pacotes ou novas versões de pacotes. Se você executar npm publish em um diretório que tenha um arquivo package.json, ele publicará um pacote com o nome e a versão listados no arquivo JSON no registro. Qualquer pessoa pode publicar pacotes no NPM—desde que use um nome que ainda não esteja em uso, já que não seria bom se pessoas aleatórias pudessem atualizar pacotes existentes.

Este livro não vai se aprofundar mais nos detalhes de uso do NPM. Consulte https://npmjs.com para mais documentação e para buscar pacotes.

O módulo filesystem

Um dos módulos embutidos mais usados no Node é o módulo node:fs, que significa sistema de arquivos (file system). Ele exporta funções para trabalhar com arquivos e diretórios.

Por exemplo, a função readFile lê um arquivo e então chama um callback com o conteúdo do arquivo.

import {readFile} from "node:fs";
readFile("file.txt", "utf8", (error, text) => {
  if (error) throw error;
  console.log("O arquivo contém:", text);
});

O segundo argumento de readFile indica a codificação de caracteres usada para decodificar o arquivo em uma string. Existem várias formas de codificar texto em dados binários, mas a maioria dos sistemas modernos usa UTF-8. A menos que você tenha motivos para acreditar que outra codificação está sendo usada, passe "utf8" ao ler um arquivo de texto. Se você não passar uma codificação, o Node assumirá que você quer os dados binários e retornará um objeto Buffer em vez de uma string. Esse é um objeto semelhante a um array que contém números representando os bytes (pedaços de 8 bits) nos arquivos.

import {readFile} from "node:fs";
readFile("file.txt", (error, buffer) => {
  if (error) throw error;
  console.log("O arquivo continha", buffer.length, "bytes.",
              "O primeiro byte é:", buffer[0]);
});

Uma função semelhante, writeFile, é usada para escrever um arquivo no disco.

import {writeFile} from "node:fs";
writeFile("graffiti.txt", "Node esteve aqui", err => {
  if (err) console.log(`Falha ao escrever arquivo: ${err}`);
  else console.log("Arquivo escrito.");
});

Aqui não foi necessário especificar a codificação—writeFile assume que, ao receber uma string em vez de um objeto Buffer, deve gravá-la como texto usando sua codificação padrão, que é UTF-8.

O módulo node:fs contém muitas outras funções úteis: readdir fornece os arquivos em um diretório como um array de strings, stat obtém informações sobre um arquivo, rename renomeia um arquivo, unlink remove um arquivo, e assim por diante. Veja a documentação em https://nodejs.org para detalhes.

A maioria dessas funções recebe um callback como último parâmetro, que é chamado com um erro (primeiro argumento) ou com um resultado bem-sucedido (segundo). Como vimos no Capítulo 11, esse estilo tem desvantagens—principalmente o fato de que o tratamento de erros se torna verboso e propenso a falhas.

O módulo node:fs/promises exporta a maioria das mesmas funções do antigo node:fs, mas usa promises em vez de callbacks.

import {readFile} from "node:fs/promises";
readFile("file.txt", "utf8")
  .then(text => console.log("O arquivo contém:", text));

Às vezes você não precisa de assincronicidade e ela só atrapalha. Muitas funções em node:fs também têm uma variante síncrona, com o mesmo nome acrescido de Sync. Por exemplo, a versão síncrona de readFile se chama readFileSync.

import {readFileSync} from "node:fs";
console.log("O arquivo contém:",
            readFileSync("file.txt", "utf8"));

Note que, enquanto uma operação síncrona está sendo executada, seu programa fica totalmente parado. Se ele precisar responder ao usuário ou a outras máquinas na rede, ficar preso em uma ação síncrona pode causar atrasos incômodos.

O módulo HTTP

Outro módulo central é o node:http. Ele fornece funcionalidades para executar um servidor HTTP.

Isso é tudo que é necessário para iniciar um servidor HTTP:

import {createServer} from "node:http";
let server = createServer((request, response) => {
  response.writeHead(200, {"Content-Type": "text/html"});
  response.write(`
    <h1>Olá!</h1>
    <p>Você pediu <code>${request.url}</code></p>`);
  response.end();
});
server.listen(8000);
console.log("Escutando! (porta 8000)");

Se você executar esse script na sua máquina, pode apontar seu navegador para http://localhost:8000/hello para fazer uma requisição ao seu servidor. Ele responderá com uma pequena página HTML.

A função passada como argumento para createServer é chamada sempre que um cliente se conecta ao servidor. As ligações request e response são objetos que representam os dados de entrada e saída. O primeiro contém informações sobre a requisição, como sua propriedade url, que indica a URL para a qual a requisição foi feita.

Quando você abre essa página no navegador, ele envia uma requisição para o seu próprio computador. Isso faz com que a função do servidor seja executada e envie uma resposta, que você então vê no navegador.

Para enviar algo ao cliente, você chama métodos no objeto response. O primeiro, writeHead, escreve os cabeçalhos da resposta (veja Capítulo 18). Você fornece o código de status (200 para “OK” neste caso) e um objeto com os valores dos cabeçalhos. O exemplo define o cabeçalho Content-Type para informar ao cliente que enviaremos um documento HTML.

Em seguida, o corpo da resposta (o documento em si) é enviado com response.write. Você pode chamar esse método várias vezes se quiser enviar a resposta em partes—por exemplo, para transmitir dados ao cliente conforme eles ficam disponíveis. Por fim, response.end sinaliza o fim da resposta.

A chamada para server.listen faz com que o servidor comece a aguardar conexões na porta 8000. É por isso que você precisa se conectar a localhost:8000 para falar com esse servidor, em vez de apenas localhost, que usaria a porta padrão 80.

Quando você executa esse script, o processo fica ali esperando. Quando um script está ouvindo eventos—neste caso, conexões de rede—o node não sai automaticamente ao chegar ao fim do script. Para encerrá-lo, pressione ctrl-C.

Um servidor web real geralmente faz mais do que o do exemplo—ele observa o método da requisição (propriedade method) para ver qual ação o cliente quer realizar e olha a URL da requisição para descobrir em qual recurso essa ação está sendo executada. Veremos um servidor mais avançado mais adiante neste capítulo.

O módulo node:http também fornece uma função request que pode ser usada para fazer requisições HTTP. No entanto, ela é bem mais complicada de usar do que fetch, que vimos no Capítulo 18. Felizmente, fetch também está disponível no Node como ligação global. A menos que você queira fazer algo muito específico, como processar o documento de resposta em partes conforme os dados chegam pela rede, recomendo usar fetch.

Streams

O objeto de resposta no servidor HTTP é um exemplo de writable stream (fluxo de escrita), um conceito amplamente usado no Node. Esses objetos têm um método write que pode receber uma string ou um objeto Buffer para escrever algo no fluxo. O método end fecha o fluxo e opcionalmente recebe um valor para escrever antes de fechar. Ambos também podem receber um callback como argumento adicional, que será chamado quando a escrita ou o fechamento terminar.

É possível criar um fluxo de escrita apontando para um arquivo com a função createWriteStream do módulo node:fs. Você pode então usar o método write no objeto resultante para escrever o arquivo em partes, em vez de tudo de uma vez, como em writeFile.

Readable streams são um pouco mais complexos. O argumento request do callback do servidor HTTP é um fluxo de leitura. A leitura de um fluxo é feita usando manipuladores de eventos, em vez de métodos.

Objetos que emitem eventos no Node têm um método chamado on, semelhante ao método addEventListener no navegador. Você fornece um nome de evento e uma função, e ele registra essa função para ser chamada sempre que o evento ocorrer.

Readable streams têm eventos "data" e "end". O primeiro é disparado sempre que dados chegam, e o segundo quando o fluxo chega ao fim. Esse modelo é mais adequado para streaming de dados que podem ser processados imediatamente, mesmo quando o documento completo ainda não está disponível. Um arquivo pode ser lido como fluxo com createReadStream de node:fs.

Este código cria um servidor que lê corpos de requisição e os envia de volta ao cliente em texto todo em maiúsculas:

import {createServer} from "node:http";
createServer((request, response) => {
  response.writeHead(200, {"Content-Type": "text/plain"});
  request.on("data", chunk =>
    response.write(chunk.toString().toUpperCase()));
  request.on("end", () => response.end());
}).listen(8000);

O valor chunk passado ao manipulador de dados será um Buffer binário. Podemos convertê-lo para string decodificando como UTF-8 com o método toString.

O código a seguir, quando executado com o servidor de maiúsculas ativo, enviará uma requisição a esse servidor e exibirá a resposta:

fetch("http://localhost:8000/", {
  method: "POST",
  body: "Hello server"
}).then(resp => resp.text()).then(console.log);
// → HELLO SERVER

Um servidor de arquivos

Vamos combinar nosso conhecimento sobre servidores HTTP e trabalho com o sistema de arquivos para criar uma ponte entre os dois: um servidor HTTP que permite acesso remoto a um sistema de arquivos. Esse tipo de servidor tem vários usos—permite que aplicações web armazenem e compartilhem dados, ou que um grupo de pessoas tenha acesso compartilhado a vários arquivos.

Ao tratar arquivos como recursos HTTP, os métodos GET, PUT e DELETE podem ser usados para ler, escrever e apagar arquivos, respectivamente. Vamos interpretar o caminho na requisição como o caminho do arquivo ao qual a requisição se refere.

Provavelmente não queremos compartilhar todo o nosso sistema de arquivos, então vamos interpretar esses caminhos como começando no diretório de trabalho do servidor, que é o diretório onde ele foi iniciado. Se eu executar o servidor em /tmp/public/ (ou C:\tmp\public\ no Windows), então uma requisição para /file.txt deve se referir a /tmp/public/file.txt (ou C:\tmp\public\file.txt).

Vamos construir o programa aos poucos, usando um objeto chamado methods para armazenar as funções que lidam com os diferentes métodos HTTP. Os manipuladores são funções async que recebem o objeto de requisição e retornam uma promise que resolve para um objeto que descreve a resposta.

import {createServer} from "node:http";

const methods = Object.create(null);

createServer((request, response) => {
  let handler = methods[request.method] || notAllowed;
  handler(request).catch(error => {
    if (error.status != null) return error;
    return {body: String(error), status: 500};
  }).then(({body, status = 200, type = "text/plain"}) => {
    response.writeHead(status, {"Content-Type": type});
    if (body?.pipe) body.pipe(response);
    else response.end(body);
  });
}).listen(8000);

async function notAllowed(request) {
  return {
    status: 405,
    body: `Method ${request.method} not allowed.`
  };
}

Isso inicia um servidor que apenas retorna respostas de erro 405, que é o código usado para indicar que o servidor se recusa a lidar com determinado método.

Quando a promise de um manipulador é rejeitada, o catch transforma o erro em um objeto de resposta, se ainda não for um, para que o servidor possa enviar uma resposta de erro ao cliente.

O campo status pode ser omitido, caso em que assume 200 (OK). O tipo de conteúdo, na propriedade type, também pode ser omitido, e a resposta será considerada texto simples.

Quando o valor de body é um stream legível, ele terá um método pipe que podemos usar para encaminhar todo o conteúdo para um stream de escrita. Caso contrário, assume-se que seja null, uma string ou um buffer, e ele é passado diretamente para o método end da resposta.

Para descobrir qual caminho de arquivo corresponde a uma URL de requisição, a função urlPath usa a classe URL (também presente no navegador) para fazer o parse. Esse construtor espera uma URL completa, então fornecemos um domínio fictício. Ele extrai o pathname, como "/file.txt", decodifica para remover códigos de escape como %20, e resolve em relação ao diretório de trabalho.

import {resolve, sep} from "node:path";

const baseDirectory = process.cwd();

function urlPath(url) {
  let {pathname} = new URL(url, "http://d");
  let path = resolve(decodeURIComponent(pathname).slice(1));
  if (path != baseDirectory &&
      !path.startsWith(baseDirectory + sep)) {
    throw {status: 403, body: "Forbidden"};
  }
  return path;
}

Assim que você configura um programa para aceitar requisições de rede, precisa começar a se preocupar com segurança. Nesse caso, se não tomarmos cuidado, é provável que exponhamos acidentalmente todo o nosso sistema de arquivos para a rede.

Caminhos de arquivos são strings no Node. Para mapear uma string dessas para um arquivo real, há uma quantidade não trivial de interpretação envolvida. Por exemplo, caminhos podem incluir ../ para se referir a um diretório pai. Uma fonte óbvia de problemas seriam requisições para caminhos como /../secret_file.

Para evitar esses problemas, urlPath usa a função resolve do módulo node:path, que resolve caminhos relativos. Depois, verifica se o resultado está abaixo do diretório de trabalho. A função process.cwd (onde cwd significa current working directory) pode ser usada para encontrar esse diretório de trabalho. A constante sep do pacote node:path é o separador de caminho do sistema — uma contrabarra no Windows e uma barra na maioria dos outros sistemas. Quando o caminho não começa com o diretório base, a função lança um objeto de erro como resposta, usando o código HTTP que indica que o acesso ao recurso é proibido.

Vamos configurar o método GET para retornar uma lista de arquivos ao ler um diretório e retornar o conteúdo do arquivo ao ler um arquivo regular.

Uma questão complicada é qual tipo de cabeçalho Content-Type devemos definir ao retornar o conteúdo de um arquivo. Como esses arquivos podem ser qualquer coisa, nosso servidor não pode simplesmente retornar o mesmo tipo de conteúdo para todos eles. NPM pode nos ajudar novamente aqui. O pacote mime-types (indicadores de tipo de conteúdo como text/plain também são chamados de tipos MIME) conhece o tipo correto para uma grande quantidade de extensões de arquivo.

O seguinte comando npm, na pasta onde o script do servidor está, instala uma versão específica do pacote mime:

$ npm install mime-types@2.1.0

Quando um arquivo solicitado não existe, o código HTTP correto a ser retornado é 404. Usaremos a função stat, que obtém informações sobre um arquivo, para descobrir tanto se o arquivo existe quanto se ele é um diretório.

import {createReadStream} from "node:fs";
import {stat, readdir} from "node:fs/promises";
import {lookup} from "mime-types";

methods.GET = async function(request) {
  let path = urlPath(request.url);
  let stats;
  try {
    stats = await stat(path);
  } catch (error) {
    if (error.code != "ENOENT") throw error;
    else return {status: 404, body: "File not found"};
  }
  if (stats.isDirectory()) {
    return {body: (await readdir(path)).join("\n")};
  } else {
    return {body: createReadStream(path),
            type: lookup(path)};
  }
};

Porque precisa acessar o disco e, portanto, pode demorar um pouco, stat é assíncrono. Como estamos usando promises em vez do estilo callback, ele precisa ser importado de node:fs/promises em vez de diretamente de node:fs.

Quando o arquivo não existe, stat lançará um objeto de erro com uma propriedade code igual a "ENOENT". Esses códigos um pouco obscuros, inspirados em Unix, são como você reconhece os tipos de erro no Node.

O objeto stats retornado por stat nos informa várias coisas sobre um arquivo, como seu tamanho (propriedade size) e sua data de modificação (propriedade mtime). Aqui estamos interessados na questão de saber se ele é um diretório ou um arquivo comum, o que o método isDirectory nos diz.

Usamos readdir para ler o array de arquivos em um diretório e retorná-lo para o cliente. Para arquivos normais, criamos um stream de leitura com createReadStream e retornamos isso como corpo, junto com o tipo de conteúdo que o pacote mime nos fornece para o nome do arquivo.

O código para lidar com requisições DELETE é um pouco mais simples.

import {rmdir, unlink} from "node:fs/promises";

methods.DELETE = async function(request) {
  let path = urlPath(request.url);
  let stats;
  try {
    stats = await stat(path);
  } catch (error) {
    if (error.code != "ENOENT") throw error;
    else return {status: 204};
  }
  if (stats.isDirectory()) await rmdir(path);
  else await unlink(path);
  return {status: 204};
};

Quando uma resposta HTTP não contém dados, o código de status 204 (“sem conteúdo”) pode ser usado para indicar isso. Já que a resposta para a deleção não precisa transmitir nenhuma informação além de ter sido bem sucedida, isso faz sentido para ser retornado aqui.

Você pode estar se perguntando por que tentar deletar um arquivo inexistente retorna um código de sucesso em vez de um erro. Quando o arquivo que está sendo deletado não está lá, pode-se dizer que o objetivo da requisição já foi cumprido. O padrão HTTP nos incentiva a tornar as requisições idempotentes, o que significa que fazer a mesma requisição várias vezes produz o mesmo resultado que fazê-la uma única vez. De certa forma, se você tenta deletar algo que já desapareceu, o efeito que você queria criar foi alcançado—o item não está mais lá.

Este é o manipulador para requisições PUT:

import {createWriteStream} from "node:fs";

function pipeStream(from, to) {
  return new Promise((resolve, reject) => {
    from.on("error", reject);
    to.on("error", reject);
    to.on("finish", resolve);
    from.pipe(to);
  });
}

methods.PUT = async function(request) {
  let path = urlPath(request.url);
  await pipeStream(request, createWriteStream(path));
  return {status: 204};
};

Não precisamos verificar se o arquivo existe desta vez—se existir, simplesmente o sobrescreveremos. Novamente usamos pipe para mover dados de um stream legível para um escrevível, neste caso da requisição para o arquivo. Mas como pipe não foi escrito para retornar uma promise, temos que escrever um wrapper, pipeStream, que cria uma promise em torno do resultado da chamada a pipe.

Quando algo dá errado ao abrir o arquivo, createWriteStream ainda retornará um stream, mas esse stream disparará um evento "error". O stream da requisição também pode falhar—por exemplo, se a rede cair. Então nós conectamos os eventos "error" de ambos os streams para rejeitar a promise. Quando pipe termina, ele fecha o stream de saída, o que faz disparar um evento "finish". Esse é o momento em que podemos resolver a promise com sucesso (sem retornar nada).

O script completo do servidor está disponível em https://eloquentjavascript.net/code/file_server.mjs. Você pode baixá-lo e, depois de instalar suas dependências, executá-lo com Node para iniciar seu próprio servidor de arquivos. E, claro, você pode modificar e estendê-lo para resolver os exercícios deste capítulo ou para experimentar.

A ferramenta de linha de comando curl, amplamente disponível em sistemas Unix-like (como macOS e Linux), pode ser usada para fazer requisições HTTP. A sessão a seguir testa brevemente nosso servidor. A opção -X é usada para definir o método da requisição, e -d é usada para incluir um corpo na requisição.

$ curl http://localhost:8000/file.txt
File not found
$ curl -X PUT -d CONTENT http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
CONTENT
$ curl -X DELETE http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
File not found

A primeira requisição para file.txt falha pois o arquivo ainda não existe. A requisição PUT cria o arquivo, e veja só, a próxima requisição o recupera com sucesso. Depois de deletá-lo com uma requisição DELETE, o arquivo desaparece novamente.

Resumo

Node é um sistema pequeno e agradável que nos permite rodar JavaScript fora do contexto do navegador. Ele foi originalmente projetado para tarefas de rede, para atuar como um nó na rede, mas se adapta a todo tipo de tarefa de script. Se escrever JavaScript é algo que você gosta, automatizar tarefas com Node pode funcionar bem para você.

O NPM oferece pacotes para tudo que você pode imaginar (e para várias coisas que provavelmente você nunca imaginaria), e permite que você busque e instale esses pacotes com o programa npm. O Node vem com vários módulos internos, incluindo o módulo node:fs para trabalhar com o sistema de arquivos e o módulo node:http para rodar servidores HTTP.

Toda entrada e saída no Node é feita assincronamente, a menos que você explicitamente use uma variante síncrona de uma função, como readFileSync. O Node originalmente usava callbacks para funcionalidades assíncronas, mas o pacote node:fs/promises fornece uma interface baseada em promises para o sistema de arquivos.

Exercícios

Ferramenta de busca

Em sistemas Unix, há uma ferramenta de linha de comando chamada grep que pode ser usada para buscar rapidamente arquivos por uma expressão regular.

Escreva um script Node que possa ser executado a partir da linha de comando e que funcione de forma semelhante ao grep. Ele deve tratar o primeiro argumento da linha de comando como uma expressão regular e os demais argumentos como arquivos a serem buscados. Deve imprimir os nomes de qualquer arquivo cujo conteúdo combine com a expressão regular.

Quando isso funcionar, estenda para que, quando um dos argumentos for um diretório, ele busque em todos os arquivos desse diretório e seus subdiretórios.

Use funções do sistema de arquivos assíncronas ou síncronas conforme achar melhor. Configurar para que múltiplas ações assíncronas sejam requisitadas simultaneamente pode acelerar um pouco as coisas, mas não muito, já que a maioria dos sistemas de arquivos só consegue ler uma coisa por vez.

Mostrar dicas...

Seu primeiro argumento na linha de comando, a expressão regular, pode ser encontrado em process.argv[2]. Os arquivos de entrada vêm depois disso. Você pode usar o construtor RegExp para transformar uma string em um objeto de expressão regular.

Fazer isso de forma síncrona, com readFileSync, é mais direto, mas se você usar node:fs/promises para obter funções que retornam promises e escrever uma função async, o código fica similar.

Para descobrir se algo é um diretório, você pode usar stat (ou statSync) e o método isDirectory do objeto de estatísticas.

Explorar um diretório é um processo que ramifica. Você pode fazer isso usando uma função recursiva ou mantendo um array de trabalhos (arquivos que ainda precisam ser explorados). Para encontrar os arquivos em um diretório, você pode chamar readdir ou readdirSync. Note a estranha capitalização—o nome das funções do sistema de arquivos no Node é vagamente baseado nas funções padrão Unix, como readdir, que são todas minúsculas, mas então ele adiciona Sync com uma letra maiúscula.

Para ir de um nome de arquivo lido com readdir para um nome de caminho completo, você precisa combiná-lo com o nome do diretório, ou colocando sep do node:path entre eles ou usando a função join desse mesmo pacote.

Criação de diretório

Embora o método DELETE em nosso servidor de arquivos seja capaz de deletar diretórios (usando rmdir), o servidor atualmente não oferece nenhuma forma de criar um diretório.

Adicione suporte ao método MKCOL (“make collection”), que deve criar um diretório chamando mkdir do módulo node:fs. MKCOL não é um método HTTP amplamente usado, mas existe para esse mesmo propósito no padrão WebDAV, que especifica um conjunto de convenções sobre HTTP que o tornam adequado para criação de documentos.

Mostrar dicas...

Você pode usar a função que implementa o método DELETE como modelo para o método MKCOL. Quando nenhum arquivo é encontrado, tente criar um diretório com mkdir. Quando um diretório existe nesse caminho, você pode retornar uma resposta 204 para que requisições de criação de diretório sejam idempotentes. Se um arquivo que não é diretório existir aqui, retorne um código de erro. O código 400 (“bad request”) seria apropriado.

Um espaço público na web

Como o servidor de arquivos serve qualquer tipo de arquivo e ainda inclui o cabeçalho Content-Type correto, você pode usá-lo para servir um website. Considerando que este servidor permite que qualquer pessoa apague e substitua arquivos, isso faria um tipo interessante de site: um que pode ser modificado, melhorado e vandalizado por qualquer pessoa que tenha tempo para fazer a requisição HTTP correta.

Escreva uma página básica HTML que inclua um arquivo JavaScript simples. Coloque os arquivos em um diretório servido pelo servidor de arquivos e abra-os no seu navegador.

Em seguida, como um exercício avançado ou até mesmo um projeto de final de semana, combine todo o conhecimento que você adquiriu neste livro para construir uma interface mais amigável para modificar o website — de dentro do próprio website.

Use um formulário HTML para editar o conteúdo dos arquivos que compõem o website, permitindo ao usuário atualizá-los no servidor usando requisições HTTP, conforme descrito em Capítulo 18.

Comece tornando apenas um único arquivo editável. Depois, faça com que o usuário possa selecionar qual arquivo deseja editar. Use o fato de que nosso servidor de arquivos retorna listas de arquivos ao ler um diretório.

Não trabalhe diretamente no código exposto pelo servidor de arquivos, pois se você cometer um erro, provavelmente danificará os arquivos lá. Em vez disso, mantenha seu trabalho fora do diretório publicamente acessível e copie-o para lá ao testar.

Mostrar dicas...

Você pode criar um elemento <textarea> para conter o conteúdo do arquivo que está sendo editado. Uma requisição GET, usando fetch, pode recuperar o conteúdo atual do arquivo. Você pode usar URLs relativas como index.html, ao invés de http://localhost:8000/index.html, para referir-se a arquivos no mesmo servidor do script em execução.

Então, quando o usuário clicar em um botão (você pode usar um elemento <form> e o evento "submit"), faça uma requisição PUT para a mesma URL, com o conteúdo do <textarea> como corpo da requisição, para salvar o arquivo.

Você pode então adicionar um elemento <select> que contenha todos os arquivos na raiz do diretório do servidor, adicionando elementos <option> contendo as linhas retornadas por uma requisição GET para a URL /. Quando o usuário selecionar outro arquivo (um evento "change" no campo), o script deve buscar e exibir esse arquivo. Ao salvar um arquivo, use o nome do arquivo atualmente selecionado.